fix(runtime): harden application service boundaries
This commit is contained in:
@@ -12,7 +12,6 @@ class MpvPlayerCore: MpvPlayerCoreBase {
|
||||
private weak var window: UIWindow?
|
||||
private var mainBlankView: UIView?
|
||||
private var isVisible = false
|
||||
private var isDisposed = false
|
||||
private static var activeDisplayCriteriaKey: String?
|
||||
private var lastDisplayCriteriaMutation: DisplayCriteriaMutation = .skipped
|
||||
#if os(tvOS)
|
||||
@@ -777,11 +776,7 @@ class MpvPlayerCore: MpvPlayerCoreBase {
|
||||
#endif
|
||||
|
||||
func dispose(preserveDisplayCriteria: Bool = false) {
|
||||
// Guard double-dispose: the plugin calls dispose() then drops the
|
||||
// strong ref, which fires deinit → dispose() again. The second call
|
||||
// would re-enter and crash on weak-ref formation during dealloc.
|
||||
guard !isDisposed else { return }
|
||||
isDisposed = true
|
||||
guard beginDisposal() else { return }
|
||||
|
||||
#if os(tvOS)
|
||||
if preserveDisplayCriteria {
|
||||
@@ -867,7 +862,7 @@ class MpvPlayerCore: MpvPlayerCoreBase {
|
||||
}
|
||||
|
||||
@objc private func enterBackground() {
|
||||
isBackgrounded = true
|
||||
setBackgrounded(true)
|
||||
if isPipActive || isPipStarting {
|
||||
print("[MpvPlayerCore] Entering background - PiP active/starting, keeping video")
|
||||
return
|
||||
@@ -878,7 +873,7 @@ class MpvPlayerCore: MpvPlayerCoreBase {
|
||||
}
|
||||
|
||||
@objc private func enterForeground() {
|
||||
isBackgrounded = false
|
||||
setBackgrounded(false)
|
||||
if isPipActive {
|
||||
print("[MpvPlayerCore] Entering foreground - PiP active, skipping vid restore")
|
||||
return
|
||||
@@ -890,7 +885,7 @@ class MpvPlayerCore: MpvPlayerCoreBase {
|
||||
|
||||
#if os(iOS)
|
||||
@objc private func sceneDidActivate() {
|
||||
isBackgrounded = false
|
||||
setBackgrounded(false)
|
||||
if isPipActive {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -239,7 +239,8 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
|
||||
isManualPipRequest = false
|
||||
stopPipTimebaseSync()
|
||||
if pause {
|
||||
playerCore?.setPropertyAsync("pause", value: "yes") { [weak self] _ in
|
||||
playerCore?.setPropertyAsync("pause", value: "yes") { [weak self] propertyResult in
|
||||
guard case .success = propertyResult else { return }
|
||||
self?.pipController?.invalidatePlaybackState()
|
||||
self?.syncPipTimebase()
|
||||
}
|
||||
@@ -485,7 +486,8 @@ extension MpvPlayerPlugin: MpvPipDelegate {
|
||||
}
|
||||
|
||||
func pipSetPlaying(_ playing: Bool) {
|
||||
playerCore?.setPropertyAsync("pause", value: playing ? "no" : "yes") { [weak self] _ in
|
||||
playerCore?.setPropertyAsync("pause", value: playing ? "no" : "yes") { [weak self] propertyResult in
|
||||
guard case .success = propertyResult else { return }
|
||||
self?.pipController?.invalidatePlaybackState()
|
||||
self?.syncPipTimebase()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import Flutter
|
||||
import XCTest
|
||||
|
||||
@testable import Runner
|
||||
|
||||
final class ControllablePropertyCore: MpvPlayerCoreBase {
|
||||
var nextResult: Result<Void, Error>?
|
||||
private(set) var propertyCalls: [(String, String)] = []
|
||||
private var pendingCompletion: ((Result<Void, Error>) -> Void)?
|
||||
|
||||
override func setPropertyAsync(
|
||||
_ name: String,
|
||||
value: String,
|
||||
completion: @escaping (Result<Void, Error>) -> Void
|
||||
) {
|
||||
propertyCalls.append((name, value))
|
||||
if let nextResult {
|
||||
self.nextResult = nil
|
||||
completion(nextResult)
|
||||
} else {
|
||||
pendingCompletion = completion
|
||||
}
|
||||
}
|
||||
|
||||
func finish(_ result: Result<Void, Error>) {
|
||||
let completion = pendingCompletion
|
||||
pendingCompletion = nil
|
||||
completion?(result)
|
||||
}
|
||||
}
|
||||
|
||||
final class RecordingMpvPlugin: MpvPluginShared {
|
||||
var coreBase: MpvPlayerCoreBase?
|
||||
var eventSink: FlutterEventSink?
|
||||
var nameToId: [String: Int] = [:]
|
||||
private(set) var pauseHookValues: [String] = []
|
||||
|
||||
init(core: MpvPlayerCoreBase?) {
|
||||
coreBase = core
|
||||
}
|
||||
|
||||
func setPlayerVisible(_ visible: Bool, restoreOnWindowVisible: Bool) {}
|
||||
func updatePlayerFrame() {}
|
||||
|
||||
func didSetPauseProperty(value: String) {
|
||||
pauseHookValues.append(value)
|
||||
}
|
||||
}
|
||||
|
||||
final class MpvPlayerContractTests: XCTestCase {
|
||||
private let failure = NSError(
|
||||
domain: "MpvPlayerContractTests",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "controlled failure"]
|
||||
)
|
||||
|
||||
func testSharedSetPropertyMapsSuccessFailureMissingCoreAndInvalidArguments() {
|
||||
let core = ControllablePropertyCore()
|
||||
let plugin = RecordingMpvPlugin(core: core)
|
||||
|
||||
core.nextResult = .success(())
|
||||
let success = invokeSetProperty(plugin, name: "pause", value: "no")
|
||||
XCTAssertEqual(success.count, 1)
|
||||
XCTAssertNil(success[0])
|
||||
XCTAssertEqual(plugin.pauseHookValues, ["no"])
|
||||
|
||||
core.nextResult = .failure(failure)
|
||||
let rejected = invokeSetProperty(plugin, name: "pause", value: "yes")
|
||||
XCTAssertEqual(rejected.count, 1)
|
||||
XCTAssertEqual((rejected[0] as? FlutterError)?.code, "SET_PROPERTY_FAILED")
|
||||
XCTAssertEqual(plugin.pauseHookValues, ["no"])
|
||||
|
||||
plugin.coreBase = nil
|
||||
let missing = invokeSetProperty(plugin, name: "volume", value: "50")
|
||||
XCTAssertEqual(missing.count, 1)
|
||||
XCTAssertEqual((missing[0] as? FlutterError)?.code, "NOT_INITIALIZED")
|
||||
|
||||
var invalidResults: [Any?] = []
|
||||
plugin.handleSetProperty(
|
||||
call: FlutterMethodCall(methodName: "setProperty", arguments: ["name": "pause"])
|
||||
) {
|
||||
invalidResults.append($0)
|
||||
}
|
||||
XCTAssertEqual(invalidResults.count, 1)
|
||||
XCTAssertEqual((invalidResults[0] as? FlutterError)?.code, "INVALID_ARGS")
|
||||
}
|
||||
|
||||
func testRealSetPropertyValidInvalidNonexistentAndPauseCache() {
|
||||
let core = MpvAudioPlayerCore()
|
||||
XCTAssertTrue(core.initialize())
|
||||
defer {
|
||||
core.dispose()
|
||||
core.queue.sync {}
|
||||
}
|
||||
|
||||
XCTAssertSuccess(awaitProperty(core, name: "volume", value: "50"))
|
||||
XCTAssertTrue(core.isPaused)
|
||||
|
||||
XCTAssertFailure(awaitProperty(core, name: "pause", value: "not-a-flag"))
|
||||
XCTAssertTrue(core.isPaused, "A rejected raw pause write must not change the cache")
|
||||
|
||||
XCTAssertFailure(
|
||||
awaitProperty(core, name: "plezy-property-does-not-exist", value: "ignored")
|
||||
)
|
||||
XCTAssertTrue(core.isPaused)
|
||||
|
||||
XCTAssertSuccess(awaitProperty(core, name: "pause", value: "no"))
|
||||
XCTAssertFalse(core.isPaused, "The accepted pause write must commit before completion")
|
||||
}
|
||||
|
||||
func testPendingSetPropertyIsCancelledExactlyOnceOnDispose() {
|
||||
let core = MpvAudioPlayerCore()
|
||||
XCTAssertTrue(core.initialize())
|
||||
|
||||
let queueEntered = expectation(description: "mpv queue blocked")
|
||||
let releaseQueue = DispatchSemaphore(value: 0)
|
||||
core.queue.async {
|
||||
queueEntered.fulfill()
|
||||
releaseQueue.wait()
|
||||
}
|
||||
wait(for: [queueEntered], timeout: 2)
|
||||
|
||||
let completion = expectation(description: "cancelled property completion")
|
||||
completion.assertForOverFulfill = true
|
||||
var completionCount = 0
|
||||
core.setPropertyAsync("volume", value: "51") { result in
|
||||
completionCount += 1
|
||||
if case .success = result {
|
||||
XCTFail("Disposal must fail an accepted-but-pending property request")
|
||||
}
|
||||
completion.fulfill()
|
||||
}
|
||||
|
||||
core.dispose()
|
||||
releaseQueue.signal()
|
||||
wait(for: [completion], timeout: 2)
|
||||
core.queue.sync {}
|
||||
XCTAssertEqual(completionCount, 1)
|
||||
XCTAssertFailure(awaitProperty(core, name: "volume", value: "52"))
|
||||
}
|
||||
|
||||
func testRapidAudioCoreReplacementOwnsLifecycleOnce() {
|
||||
for _ in 0..<5 {
|
||||
autoreleasepool {
|
||||
let core = MpvAudioPlayerCore()
|
||||
XCTAssertTrue(core.initialize())
|
||||
core.dispose()
|
||||
core.dispose()
|
||||
core.queue.sync {}
|
||||
XCTAssertFalse(core.hasActiveMpv)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func invokeSetProperty(
|
||||
_ plugin: RecordingMpvPlugin,
|
||||
name: String,
|
||||
value: String
|
||||
) -> [Any?] {
|
||||
var results: [Any?] = []
|
||||
plugin.handleSetProperty(
|
||||
call: FlutterMethodCall(
|
||||
methodName: "setProperty",
|
||||
arguments: ["name": name, "value": value]
|
||||
)
|
||||
) {
|
||||
results.append($0)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
private func awaitProperty(
|
||||
_ core: MpvPlayerCoreBase,
|
||||
name: String,
|
||||
value: String
|
||||
) -> Result<Void, Error> {
|
||||
let completion = expectation(description: "set \(name)")
|
||||
var propertyResult: Result<Void, Error>?
|
||||
core.setPropertyAsync(name, value: value) {
|
||||
propertyResult = $0
|
||||
completion.fulfill()
|
||||
}
|
||||
wait(for: [completion], timeout: 2)
|
||||
return propertyResult ?? .failure(failure)
|
||||
}
|
||||
|
||||
private func XCTAssertSuccess(
|
||||
_ result: Result<Void, Error>,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) {
|
||||
if case .failure(let error) = result {
|
||||
XCTFail("Expected success, received \(error)", file: file, line: line)
|
||||
}
|
||||
}
|
||||
|
||||
private func XCTAssertFailure(
|
||||
_ result: Result<Void, Error>,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) {
|
||||
if case .success = result {
|
||||
XCTFail("Expected failure", file: file, line: line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user