From bc224b81cf6d41aaf5e5e3455971f9b3040ed4a0 Mon Sep 17 00:00:00 2001 From: Jonathan Lipworth Date: Tue, 15 Sep 2026 10:04:06 -0400 Subject: [PATCH 1/6] Pin P7 fallback initialization representation per proxy open --- .../MediaSession/MediaSessionProxy.swift | 3 +- .../PMSKit/MediaSession/P7HDR10Session.swift | 15 ++++ .../P7HDR10InitializationTests.swift | 79 +++++++++++++++++++ docs/research/dv-p7-decoder-boundary.md | 19 +++++ 4 files changed, 115 insertions(+), 1 deletion(-) diff --git a/PMSKit/Sources/PMSKit/MediaSession/MediaSessionProxy.swift b/PMSKit/Sources/PMSKit/MediaSession/MediaSessionProxy.swift index 2b719c46..f834b175 100644 --- a/PMSKit/Sources/PMSKit/MediaSession/MediaSessionProxy.swift +++ b/PMSKit/Sources/PMSKit/MediaSession/MediaSessionProxy.swift @@ -228,7 +228,8 @@ public actor MediaSessionProxy { try await p7Session?.admit(data) case .initialization: guard resp.statusCode == 200 else { throw P7HDR10Playlist.Rejection.unsupported } - let normalized = try P7HDR10Initialization.normalize(data) + guard let p7Session else { throw P7HDR10Playlist.Rejection.unsupported } + let normalized = try await p7Session.normalizeInitialization(data, at: upstreamURL) let range: Range do { range = try P7HDR10Playlist.range(head.value(for: "Range"), length: normalized.count) } catch { diff --git a/PMSKit/Sources/PMSKit/MediaSession/P7HDR10Session.swift b/PMSKit/Sources/PMSKit/MediaSession/P7HDR10Session.swift index 1b2040f2..5051a7a9 100644 --- a/PMSKit/Sources/PMSKit/MediaSession/P7HDR10Session.swift +++ b/PMSKit/Sources/PMSKit/MediaSession/P7HDR10Session.swift @@ -7,6 +7,7 @@ actor P7HDR10Session { private let playlist: URL private var initialization: URL? private var segments: Set = [] + private var initializationBytes: Data? init(playlist: URL) { self.playlist = playlist } @@ -17,6 +18,20 @@ actor P7HDR10Session { throw P7HDR10Playlist.Rejection.unsupported } + /// Pin the complete validated upstream representation before serving any range. + /// A stable MAP URI alone cannot prevent different range requests from receiving + /// different parameter sets or audio descriptions. This actor method has no await: + /// concurrent fetch completions cannot replace the first admitted representation. + func normalizeInitialization(_ data: Data, at url: URL) throws -> Data { + guard initialization == url else { throw P7HDR10Playlist.Rejection.unsupported } + if let initializationBytes, initializationBytes != data { + throw P7HDR10Playlist.Rejection.unsupported + } + let normalized = try P7HDR10Initialization.normalize(data) + initializationBytes = data + return normalized + } + func admit(_ data: Data) throws { let parsed = try P7HDR10Playlist.parse(data, at: playlist) guard parsed.initialization != playlist, diff --git a/PMSKit/Tests/PMSKitTests/P7HDR10InitializationTests.swift b/PMSKit/Tests/PMSKitTests/P7HDR10InitializationTests.swift index d6b5e3fe..1e750df1 100644 --- a/PMSKit/Tests/PMSKitTests/P7HDR10InitializationTests.swift +++ b/PMSKit/Tests/PMSKitTests/P7HDR10InitializationTests.swift @@ -105,7 +105,86 @@ final class P7HDR10InitializationTests: XCTestCase { XCTAssertThrowsError(try P7HDR10Initialization.normalize(input)) } } + func testSessionPinsExactInitializationBytesUntilFreshOpen() async throws { + let root = URL(string: "https://plex.example.internal/media.m3u8")! + let initURL = root.deletingLastPathComponent().appendingPathComponent("init.mp4") + let playlist = Data("#EXTM3U\n#EXT-X-MAP:URI=\"init.mp4\"\n#EXTINF:10,\n0.m4s\n".utf8) + let session = P7HDR10Session(playlist: root) + let original = fixture() + // Both configurations independently pass admission. A stable URL is not + // sufficient to establish that their byte-range representations are identical. + let changed = fixture(audio: audioTrack()) + let expected = try P7HDR10Initialization.normalize(original) + _ = try P7HDR10Initialization.normalize(changed) + do { + _ = try await session.normalizeInitialization(original, at: initURL) + XCTFail("unadmitted initialization accepted") + } catch {} + try await session.admit(playlist) + do { + _ = try await session.normalizeInitialization(Data(), at: initURL) + XCTFail("malformed initialization accepted") + } catch {} + let first = try await session.normalizeInitialization(original, at: initURL) + XCTAssertEqual(first, expected) + do { + _ = try await session.normalizeInitialization(changed, at: initURL) + XCTFail("changed representation accepted at the same URL") + } catch {} + try await session.admit(playlist) + let repeated = try await session.normalizeInitialization(original, at: initURL) + XCTAssertEqual(repeated, expected) + let reopened = P7HDR10Session(playlist: root) + try await reopened.admit(playlist) + let fresh = try await reopened.normalizeInitialization(changed, at: initURL) + XCTAssertEqual(fresh, try P7HDR10Initialization.normalize(changed)) + } + #if canImport(Network) + func testProxyRejectsChangedInitializationDuringRangeAndResetsOnReopen() async throws { + actor Responses { + var initialization: Data + init(_ data: Data) { initialization = data } + func replace(_ data: Data) { initialization = data } + func current() -> Data { initialization } + } + let original = fixture() + let changed = fixture(audio: audioTrack()) + let responses = Responses(original) + let playlist = Data("#EXTM3U\n#EXT-X-MAP:URI=\"init.mp4\"\n#EXTINF:10,\n0.m4s\n".utf8) + let proxy = MediaSessionProxy(upstreamFetch: { request in + let url = request.url! + let body = url.path.hasSuffix("m3u8") ? playlist : await responses.current() + return (body, HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, + headerFields: ["Content-Type": url.path.hasSuffix("m3u8") ? "application/vnd.apple.mpegurl" : "video/mp4"])!) + }, p7HDR10Fallback: true) + let session = URLSession(configuration: .ephemeral) + defer { session.invalidateAndCancel() } + let root = URL(string: "https://plex.example.internal/media.m3u8")! + let first = try await proxy.standUpLoopback(forStream: root) + _ = try await session.data(from: first.localURL) + let initURL = first.localURL.deletingLastPathComponent().appendingPathComponent("init.mp4") + var range = URLRequest(url: initURL) + range.setValue("bytes=0-7", forHTTPHeaderField: "Range") + let (initial, initialResponse) = try await session.data(for: range) + XCTAssertEqual((initialResponse as? HTTPURLResponse)?.statusCode, 206) + XCTAssertEqual(initial, Data(try P7HDR10Initialization.normalize(original).prefix(8))) + await responses.replace(changed) + let (rejected, rejectedResponse) = try await session.data(for: range) + XCTAssertEqual((rejectedResponse as? HTTPURLResponse)?.statusCode, 502) + XCTAssertTrue(rejected.isEmpty) + let (_, fullResponse) = try await session.data(from: initURL) + XCTAssertEqual((fullResponse as? HTTPURLResponse)?.statusCode, 502) + await proxy.stop(generation: first.generation) + let reopened = try await proxy.standUpLoopback(forStream: root) + _ = try await session.data(from: reopened.localURL) + let freshURL = reopened.localURL.deletingLastPathComponent().appendingPathComponent("init.mp4") + let (fresh, freshResponse) = try await session.data(from: freshURL) + XCTAssertEqual((freshResponse as? HTTPURLResponse)?.statusCode, 200) + XCTAssertEqual(fresh, try P7HDR10Initialization.normalize(changed)) + await proxy.stop(generation: reopened.generation) + } + func testProxyNormalizesOnlyAdmittedInitializationAndServesRanges() async throws { let initData = fixture() let expected = try P7HDR10Initialization.normalize(initData) diff --git a/docs/research/dv-p7-decoder-boundary.md b/docs/research/dv-p7-decoder-boundary.md index 85c0c40c..787f5bb7 100644 --- a/docs/research/dv-p7-decoder-boundary.md +++ b/docs/research/dv-p7-decoder-boundary.md @@ -423,3 +423,22 @@ prove clean UI builds. Those historical artifacts are retained with that limitat replacement fixtures explicitly move the actual UI DerivedData aside before building. Both replacement semantic fixtures pass from those clean UI build paths, with the iPhone detail and tvOS home app-view attachments inspected. All owned simulators are shut down. + +### Initialization representation identity follow-up + +The candidate now binds the complete validated initialization bytes to each proxy open, +not merely its admitted MAP URL. Previously, two independently valid initializations +served at the same URL could each pass normalization, allowing successive player range +requests to receive parts of different representations. The session actor now validates +and pins the first complete initialization atomically, rejects later byte changes before +serving any full or partial body, and starts without that pin on a fresh open. Failed +validation cannot seed the pin; playlist refresh cannot replace it. Media bytes, profile +admission and default-off DEBUG macOS routing are unchanged. + +Hermetic coverage exercises unchanged repeats, a malformed first response, two individually +admissible but different initializations at one URL, full/range rejection through the +loopback proxy, and fresh-open acceptance. This is a transport identity guard, not new +decoded-frame or live-backend acceptance. Exact-source live regression of this increment, +server-session cleanup confirmation, later-segment extracted-base equivalence, broader +source coverage and physical acceptance remain open. No new encoding authorization or +Release enablement follows from this guard. From 507fbdb95a8454c9cd749da1715f502600e41e82 Mon Sep 17 00:00:00 2001 From: Jonathan Lipworth Date: Tue, 15 Sep 2026 12:05:04 -0400 Subject: [PATCH 2/6] Record fresh P7 initialization identity validation --- docs/research/dv-p7-decoder-boundary.md | 50 +++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/docs/research/dv-p7-decoder-boundary.md b/docs/research/dv-p7-decoder-boundary.md index 787f5bb7..3e201187 100644 --- a/docs/research/dv-p7-decoder-boundary.md +++ b/docs/research/dv-p7-decoder-boundary.md @@ -442,3 +442,53 @@ decoded-frame or live-backend acceptance. Exact-source live regression of this i server-session cleanup confirmation, later-segment extracted-base equivalence, broader source coverage and physical acceptance remain open. No new encoding authorization or Release enablement follows from this guard. + +### Initialization identity: fresh bounded validation + +The initialization-pinning revision `bc224b81` was rebuilt from clean native macOS +DerivedData and run with the existing authenticated, isolated development identity. +The staged executable matches the freshly built product by SHA-256. These are new +exact-revision results, not reuse of the earlier candidate's playback evidence. + +- Hermetic PMSKit passes 1,707 Swift Testing cases and 121 XCTest cases. The range + regressions verify unchanged partial responses, rejection of changed complete and + partial initialization responses, failed-first-validation recovery, playlist-refresh + pin retention, and acceptance of a different initialization only after a fresh open. + No live server initialization was deliberately mutated; that adversarial boundary is + established by the hermetic loopback tests. +- The exact P7 source passes native Plex Original video-copy playback, a seek to + 600 seconds with a 35-second advancing hold, and a separate close/reopen hold. + An audio-track transition also passes with replacement-generation evidence: video + remains copy while audio changes from copy to encoding. No video encoding was + authorized or requested by these scenarios. +- The same source, independently bound to each backend's expected item/source, + passes Jellyfin's seek to 600 seconds and Emby's forward seek to 1,200 seconds, + each with a 25-second hold. Emby's separate Original reopen passes. Jellyfin's + first reopen reports `failed/playbackFailed`; its saved progress evidence identifies + a 2.014-second observation gap, beyond the unchanged two-second oracle bound, + while sampled positions continued advancing. It has no post-hold visual pass. + One bounded reopen repeat passes the unchanged 25-second hold and frame gates; + this does not retroactively pass the first attempt or resolve older pause findings. +- Fresh initial and post-scenario decoded frames were inspected for every passing + run. They show recognizable, untinted video with 3840-by-2160 PQ/BT.2020 signaling. + Some initial capture attempts lack a new pixel buffer; subsequent fresh frames + provide the visual evidence. These tone-mapped captures do not establish HDR + luminance accuracy, native Dolby Vision reconstruction, or physical audio output. +- Jellyfin/Emby app evidence reports request-enforced video copy and unknown audio; + separate read-only server job logs confirm video copy and AAC encoding. The matched + jobs show graceful quit and their specific playlists are absent after stop. Plex + seek, reopen and audio-transition stops receive HTTP 200, with observed job exits + and removal of the corresponding transcode directories. These are bounded job + cleanup observations, not proof of every server resource's lifecycle. +- Fresh generic visionOS, iOS and tvOS builds pass, as do hygiene and strict + documentation checks. Two full macOS hosted runs each pass 703 of 704 tests and + fail `delayedTrackReplacementDoesNotAcceptThePredecessor` at its replacement + deadline. A separate focused evidence run passes all 12 tests. The full-suite + failure remains open; the focused result is not a waiver. No simulator was booted; + visionOS run verification remains blocked by the missing golden-simulator pointer, + and mobile/tvOS hosted and interactive lanes were not rerun in this validation. + +No product code, profile selection, admission guard, session authority or consent policy +changed during validation. The candidate remains default-off, DEBUG macOS only. Broader +source coverage, later-segment extracted-base equivalence, full-suite stability and +physical-device acceptance remain open. No merge, upload or release enablement is implied. From fed053b9097be8ad47977ba38dcfbc21735d0f2b Mon Sep 17 00:00:00 2001 From: Jonathan Lipworth Date: Tue, 15 Sep 2026 13:16:19 -0400 Subject: [PATCH 3/6] Make replacement-wait regression coverage deterministic --- .../Shared/Debug/DebugPlaybackScenario.swift | 12 +- .../PlaybackAgentEvidenceTests.swift | 114 ++++++++++++++++-- docs/MEDIA-CORPUS-TESTING.md | 6 +- 3 files changed, 115 insertions(+), 17 deletions(-) diff --git a/Labstream/Shared/Debug/DebugPlaybackScenario.swift b/Labstream/Shared/Debug/DebugPlaybackScenario.swift index aa7cf2ce..608c7c11 100644 --- a/Labstream/Shared/Debug/DebugPlaybackScenario.swift +++ b/Labstream/Shared/Debug/DebugPlaybackScenario.swift @@ -64,15 +64,19 @@ enum DebugPlaybackScenario { timeoutSeconds: Int, sessionIsCurrent: () -> Bool = { true }, playbackFailed: () -> Bool = { false }, - consentPending: () -> Bool = { false }) async throws { - let deadline = ContinuousClock.now.advanced(by: .seconds(timeoutSeconds)) - while ContinuousClock.now < deadline { + consentPending: () -> Bool = { false }, + now: () -> ContinuousClock.Instant = { .now }, + poll: (Duration) async throws -> Void = { try await Task.sleep(for: $0) }) async throws { + // Injection is only for deterministic DEBUG tests. Live callers retain the + // continuous-clock deadline and 250ms cancellation-aware polling interval. + let deadline = now().advanced(by: .seconds(timeoutSeconds)) + while now() < deadline { try Task.checkCancellation() guard sessionIsCurrent() else { throw Blocked(reason: .backendChanged) } if playbackFailed() { throw Blocked(reason: .playbackFailed) } if consentPending() { throw Blocked(reason: .consentRequired) } if let current = player.currentItem, current !== priorItem { return } - try await Task.sleep(for: .milliseconds(250)) + try await poll(.milliseconds(250)) } throw Blocked(reason: .deadline) } diff --git a/LabstreamTests/PlaybackAgentEvidenceTests.swift b/LabstreamTests/PlaybackAgentEvidenceTests.swift index d33a839a..f5410be1 100644 --- a/LabstreamTests/PlaybackAgentEvidenceTests.swift +++ b/LabstreamTests/PlaybackAgentEvidenceTests.swift @@ -139,32 +139,122 @@ struct PlaybackAgentEvidenceTests { #expect(try FileManager.default.contentsOfDirectory(atPath: directory.path).isEmpty) } + /// Virtual polling separates the identity/deadline oracle from MainActor load. @Test func delayedTrackReplacementDoesNotAcceptThePredecessor() async throws { let old = AVPlayerItem(asset: AVMutableComposition()) let replacement = AVPlayerItem(asset: AVMutableComposition()) let player = AVPlayer(playerItem: old) - let task = Task { @MainActor in - try await Task.sleep(for: .milliseconds(20)) - player.replaceCurrentItem(with: nil) - try await Task.sleep(for: .milliseconds(20)) - player.replaceCurrentItem(with: replacement) - } - defer { task.cancel(); player.replaceCurrentItem(with: nil) } - try await DebugPlaybackScenario.waitForReplacement(of: old, player: player, timeoutSeconds: 2) + defer { player.replaceCurrentItem(with: nil) } + var now = ContinuousClock.now + var polls = 0 + try await DebugPlaybackScenario.waitForReplacement(of: old, player: player, timeoutSeconds: 2, + now: { now }, poll: { delay in + #expect(delay == .milliseconds(250)) + polls += 1 + now = now.advanced(by: delay) + switch polls { + case 1: + #expect(player.currentItem === old) + // Keep the predecessor for another complete observation. + case 2: + #expect(player.currentItem === old) + player.replaceCurrentItem(with: nil) + case 3: + #expect(player.currentItem == nil) + player.replaceCurrentItem(with: replacement) + default: + Issue.record("Replacement must finish without another poll") + } + }) + #expect(polls == 3) #expect(player.currentItem === replacement) - try await task.value } - @Test func missingReplacementFailsClosed() async throws { + @Test(arguments: [false, true]) + func missingReplacementFailsClosed(detached: Bool) async throws { let old = AVPlayerItem(asset: AVMutableComposition()) + let player = AVPlayer(playerItem: detached ? nil : old) + defer { player.replaceCurrentItem(with: nil) } + let start = ContinuousClock.now + var now = start + var polls = 0 + do { + try await DebugPlaybackScenario.waitForReplacement(of: old, player: player, timeoutSeconds: 2, + now: { now }, poll: { delay in + #expect(delay == .milliseconds(250)) + polls += 1 + now = now.advanced(by: delay) + }) + Issue.record("Neither predecessor nor detached item may satisfy replacement") + } catch let error as DebugPlaybackScenario.Blocked { + #expect(error.reason == .deadline) + } + #expect(polls == 8) + #expect(start.duration(to: now) == .seconds(2)) + } + + @Test func replacementAtExpiredDeadlineIsNotAccepted() async throws { + let old = AVPlayerItem(asset: AVMutableComposition()) + let replacement = AVPlayerItem(asset: AVMutableComposition()) let player = AVPlayer(playerItem: old) defer { player.replaceCurrentItem(with: nil) } + var now = ContinuousClock.now do { - try await DebugPlaybackScenario.waitForReplacement(of: old, player: player, timeoutSeconds: 0) - Issue.record("The predecessor must not satisfy replacement") + try await DebugPlaybackScenario.waitForReplacement(of: old, player: player, timeoutSeconds: 2, + now: { now }, poll: { _ in + now = now.advanced(by: .seconds(2)) + player.replaceCurrentItem(with: replacement) + }) + Issue.record("A late replacement must not bypass the deadline") } catch let error as DebugPlaybackScenario.Blocked { #expect(error.reason == .deadline) } + #expect(player.currentItem === replacement) + } + + @Test(arguments: [DebugPlaybackScenario.Reason.backendChanged, .playbackFailed, .consentRequired]) + func replacementCannotBypassSafetyGates(reason: DebugPlaybackScenario.Reason) async throws { + let old = AVPlayerItem(asset: AVMutableComposition()) + let replacement = AVPlayerItem(asset: AVMutableComposition()) + let player = AVPlayer(playerItem: old) + defer { player.replaceCurrentItem(with: nil) } + var now = ContinuousClock.now + var replaced = false + do { + try await DebugPlaybackScenario.waitForReplacement(of: old, player: player, timeoutSeconds: 2, + sessionIsCurrent: { !(replaced && reason == .backendChanged) }, + playbackFailed: { replaced && reason == .playbackFailed }, + consentPending: { replaced && reason == .consentRequired }, + now: { now }, poll: { delay in + now = now.advanced(by: delay) + player.replaceCurrentItem(with: replacement) + replaced = true + }) + Issue.record("A replacement cannot supersede a safety gate") + } catch let error as DebugPlaybackScenario.Blocked { + #expect(error.reason == reason) + } + #expect(replaced) + } + + @Test func cancelledReplacementWaitDoesNotPoll() async throws { + let old = AVPlayerItem(asset: AVMutableComposition()) + let player = AVPlayer(playerItem: old) + defer { player.replaceCurrentItem(with: nil) } + let task = Task { @MainActor in + do { + try await DebugPlaybackScenario.waitForReplacement(of: old, player: player, timeoutSeconds: 2, + poll: { _ in Issue.record("Cancelled wait must not poll") }) + Issue.record("Cancelled wait must throw") + } catch is CancellationError { + // Expected before any item observation or suspension. + } catch { + Issue.record("Unexpected cancellation error: \(error)") + } + } + // Both run on MainActor: cancellation occurs before the task can begin. + task.cancel() + await task.value } @Test func onlyMetadataSubtitleMechanismsRequireReplacement() { diff --git a/docs/MEDIA-CORPUS-TESTING.md b/docs/MEDIA-CORPUS-TESTING.md index e32bba0e..b11c3a15 100644 --- a/docs/MEDIA-CORPUS-TESTING.md +++ b/docs/MEDIA-CORPUS-TESTING.md @@ -179,7 +179,11 @@ server defect. Original/Maximum and Generic-profile production policies are unch Metadata-owned audio and subtitle changes restart asynchronously. Their probes wait for a nonnil replacement AVPlayerItem before testing readiness and sustained progress, just as quality transitions do. Soft AVFoundation/offline subtitle switches do not require -replacement. The hold still fails if its item changes unexpectedly; it is not weakened +replacement. Replacement-wait unit tests inject a monotonic clock and poll step to +exercise predecessor, detached, replacement, deadline and safety-gate states without +racing independently scheduled fixture sleeps. Live probes retain the continuous-clock +deadline and 250-millisecond polling interval. The hold still fails if its item changes +unexpectedly; it is not weakened to tolerate arbitrary restarts. See [issue #312](https://github.com/jlipworth/Labstream/issues/312). The [historical bounded Plex audit](https://github.com/jlipworth/Labstream/blob/main/docs/evidence/audits/2026-09-13-plex-corpus.md) records the tested From f56afe8ceb4fc18f48c50ce937b50924620ec96f Mon Sep 17 00:00:00 2001 From: Jonathan Lipworth Date: Tue, 15 Sep 2026 13:19:57 -0400 Subject: [PATCH 4/6] Await deletion persistence before reopening integration-test index --- LabstreamTests/SeasonPlannerIntegrationTests.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/LabstreamTests/SeasonPlannerIntegrationTests.swift b/LabstreamTests/SeasonPlannerIntegrationTests.swift index 6254feda..188866c6 100644 --- a/LabstreamTests/SeasonPlannerIntegrationTests.swift +++ b/LabstreamTests/SeasonPlannerIntegrationTests.swift @@ -60,6 +60,16 @@ struct SeasonPlannerIntegrationTests { try #require(await waitUntil { store.record(for: deleted) == nil && !store.isDeletionPending(ratingKey: deleted) }) + // Row removal precedes the artifact worker's terminal index commit. In-memory + // absence is not a durability barrier; await the already-submitted revision + // before asking a fresh store to prove deletion survived reopening. + let deletionTicket = store.currentPersistenceTicket() + let flush = await store.flushPersistence(through: deletionTicket, timeout: 10) + guard case .committed(let revision) = flush else { + Issue.record("Deletion snapshot did not commit: \(flush)") + return + } + #expect(revision >= deletionTicket.revision) // A newly opened index must agree before the suspended response is released. #expect(DownloadStore(baseDirectory: directory).record(for: deleted) == nil) } From f4fc8da424839c13ff55dff01204cdfc46e66378 Mon Sep 17 00:00:00 2001 From: Jonathan Lipworth Date: Tue, 15 Sep 2026 13:43:27 -0400 Subject: [PATCH 5/6] Record deterministic test fixes and scoped PR validation gates --- docs/DEVELOPMENT.md | 10 +++ docs/MEDIA-CORPUS-TESTING.md | 3 +- docs/research/dv-p7-decoder-boundary.md | 83 +++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 2 deletions(-) diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 0aad1614..b218fa9c 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -388,6 +388,16 @@ uv run python scripts/check-docs-mermaid.py Run the app suite for the platform affected by a change (all three hosts — iOS, macOS, and tvOS — for shared app infrastructure): +On Xcode 27, a printed test-pass summary is not a completed hosted run: require the +terminal `xcodebuild` result and a readable `.xcresult`. If the runner stops making +progress, take a bounded process sample before termination. One observed iOS stall +was inside `collectSimulatorDiagnostics` / `simCtlDiagnose` after the test host exited. +A diagnostic repeat with `-collect-test-diagnostics never` can separate test execution +from that ancillary verbose-diagnostics collection; retain the original non-green run +and state the flag explicitly. It does not waive test failures, replace result bundles +or prove every runner hang has the same cause. The [P7 review-preparation evidence](https://github.com/jlipworth/Labstream/blob/main/docs/research/dv-p7-decoder-boundary.md#review-preparation-deterministic-replacement-and-persistence-barriers) +records the bounded observation and its limitations. + For a deterministic smoke, affected-platform, or full plan, use the checked [`native-test-matrix.py`](TESTING-STRATEGY.md#native-apple-matrix-driver) driver. The commands below remain the direct forms for an individually leased platform lane. diff --git a/docs/MEDIA-CORPUS-TESTING.md b/docs/MEDIA-CORPUS-TESTING.md index b11c3a15..a6b4afbd 100644 --- a/docs/MEDIA-CORPUS-TESTING.md +++ b/docs/MEDIA-CORPUS-TESTING.md @@ -183,8 +183,7 @@ replacement. Replacement-wait unit tests inject a monotonic clock and poll step exercise predecessor, detached, replacement, deadline and safety-gate states without racing independently scheduled fixture sleeps. Live probes retain the continuous-clock deadline and 250-millisecond polling interval. The hold still fails if its item changes -unexpectedly; it is not weakened -to tolerate arbitrary restarts. See [issue #312](https://github.com/jlipworth/Labstream/issues/312). +unexpectedly; it is not weakened to tolerate arbitrary restarts. See [issue #312](https://github.com/jlipworth/Labstream/issues/312). The [historical bounded Plex audit](https://github.com/jlipworth/Labstream/blob/main/docs/evidence/audits/2026-09-13-plex-corpus.md) records the tested cohorts, first failures, independent fixes and hardware gates. For focused hosted diff --git a/docs/research/dv-p7-decoder-boundary.md b/docs/research/dv-p7-decoder-boundary.md index 3e201187..132e84a9 100644 --- a/docs/research/dv-p7-decoder-boundary.md +++ b/docs/research/dv-p7-decoder-boundary.md @@ -492,3 +492,86 @@ No product code, profile selection, admission guard, session authority or consen changed during validation. The candidate remains default-off, DEBUG macOS only. Broader source coverage, later-segment extracted-base equivalence, full-suite stability and physical-device acceptance remain open. No merge, upload or release enablement is implied. + + +### Review preparation: deterministic replacement and persistence barriers + +The full-suite replacement failure was reproduced with test-only timing diagnostics: +the fixture's MainActor task did not begin until 1.849 seconds after the wait started, +and the unchanged two-second wait expired at 2.316 seconds before the first scheduled +20-millisecond continuation detached the predecessor. The player still held the old +item. This identifies fixture scheduling contention, not rejection of an observed +replacement. The diagnostic instrumentation was removed after establishing the cause. + +Revision `fed053b9` injects the DEBUG replacement wait's monotonic clock and polling +step for deterministic tests. The live defaults remain `ContinuousClock` and a +cancellation-aware 250-millisecond sleep; deadline comparisons, authority, playback +failure, consent and item-identity checks are unchanged. Coverage now observes the +predecessor across multiple polls, a detached interval, a distinct replacement, +predecessor/detached deadline expiry, rejection exactly at the deadline, safety gates +that become active with replacement, and cancellation before polling. There is no +deadline extension, skipped test or weaker progress assertion. + +The first clean macOS run passed all 712 tests with no skips, including the hermetic +browse-probe fixtures omitted by the earlier matrix command. An exact-build repeat +then failed a separate season-planner deletion test: its in-memory absence condition +could precede the artifact worker's terminal on-disk index commit. The existing store +explicitly removes the row and enqueues persistence before awaiting that commit; the +test incorrectly treated the earlier observation as durability proof. The isolated +prerequisite `f56afe8c` awaits the existing persistence ticket with a bounded ten-second +flush and asserts its committed revision before preserving the original fresh-index +deletion assertion. It changes no download production behavior and is kept on `codex/season-deletion-test-barrier` for +separate review. The P7 branch includes that prerequisite for stacked validation. + +At integrated revision `a25c6ec8`, three consecutive full macOS hosted runs pass all +712 tests with zero skips, the first from clean DerivedData. These runs retain the +historical failures above rather than retroactively passing them. Hermetic PMSKit +passes 1,707 Swift Testing cases and 121 XCTest cases. Fresh generic visionOS, iOS +and tvOS builds also pass. The live host product was built from clean DerivedData at +`fed053b9`, with a fresh executable mtime and matching staged SHA-256; the prerequisite +and integration change only tests, so the runtime source is identical. + +The fresh isolated Mac product passes the exact P7 source's Plex audio transition +and 600-second seek with the unchanged 35-second advancing holds. The audio run +changes replacement generation while video remains copy (audio changes from encoding +to copy); the seek stays video/audio copy. Fresh initial and post-scenario PNGs were +inspected and show recognizable untinted video with 3840-by-2160 PQ/BT.2020 signaling. +These tone-mapped frames do not establish physical HDR or audio-output accuracy. + +Later native LaunchServices attempts do **not** extend that acceptance: Plex reopen +reports `blocked/playbackFailed`, and Jellyfin/Emby audio transitions report +`blocked/missingAuth`. PID-bounded host logs for each attempt show `Local network +prohibited` and URL error -1009 before playback; the media-browser report is not proof +that saved credentials disappeared. No privacy setting was changed or bypassed. +A preceding reopen launch-orchestration attempt was stopped before a probe result; +it has no visual pass. Existing pre-increment cross-backend/reopen evidence remains +historical, not a substitute for these blocked fresh lanes. Fresh cross-backend and +LaunchServices reopen acceptance therefore remain open pending the host permission +gate. App reports request cleanup; the earlier bounded server-job cleanup observations +are not reclassified as fresh server-side proof for this increment. + +The owned iPhone's clean-built semantic fixture passes with its detail attachment +inspected. Its first full hosted run prints 629 Swift Testing and 67 XCTest passes, +but does not produce a terminal result: a bounded process sample locates the wait in +Xcode's `collectSimulatorDiagnostics` / `simCtlDiagnose` after the app process exits. +The run is terminated at the 300-second no-progress boundary and remains non-green. +One diagnostic repeat of the same compiled tests with `-collect-test-diagnostics never` +exits normally: 696 of 696 pass, zero skips. This flag disables ancillary verbose +simulator diagnostics, not tests, assertions or result bundles. The repeat does not +establish resolution of default diagnostic collection or of historical runner hangs. + +The owned tvOS full hosted run exits normally with 383 of 383 passing and zero skips. +Both named tvOS launch/remote-navigation smoke tests pass from a clean UI DerivedData +path, with the focused home and leaf-detail attachments inspected. All created iPhone +and tvOS simulators were shut down and removed; the isolated live Mac staged app was +removed without resetting its saved container or credentials. visionOS run verification +remains blocked by the missing golden-simulator pointer; no replacement golden was +invented. These simulator fixtures are credential-free UI evidence, not live-backend +or physical-device acceptance. + +This branch is reviewable as default-off hardening, not a shipping P7 solution. Local +network permission, default iOS diagnostic-collection completion, broader source +coverage, later-segment extracted-base equivalence and physical HDR/DV/audio acceptance +remain explicit gates. P5 safety, Plex Generic, consent, source/session authority and +Release behavior are unchanged. No video encoding was authorized, no PR was opened, +and no merge to the default branch or release upload is implied. From 1f2d944a77811c5138114b693753b5c4ce0069d4 Mon Sep 17 00:00:00 2001 From: Jonathan Lipworth Date: Tue, 15 Sep 2026 14:06:00 -0400 Subject: [PATCH 6/6] Record fresh local-network permission recheck evidence --- docs/research/dv-p7-decoder-boundary.md | 30 +++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/docs/research/dv-p7-decoder-boundary.md b/docs/research/dv-p7-decoder-boundary.md index 132e84a9..543f6059 100644 --- a/docs/research/dv-p7-decoder-boundary.md +++ b/docs/research/dv-p7-decoder-boundary.md @@ -575,3 +575,33 @@ coverage, later-segment extracted-base equivalence and physical HDR/DV/audio acc remain explicit gates. P5 safety, Plex Generic, consent, source/session authority and Release behavior are unchanged. No video encoding was authorized, no PR was opened, and no merge to the default branch or release upload is implied. + +### Local-network permission recheck + +After the user reported granting local-network access, revision `f4fc8da4` was rebuilt +from clean native macOS DerivedData and staged under the same isolated development +identity. Fresh executable mtime and matching built/staged SHA-256 were verified. +All three bounded LaunchServices retries used the existing saved authentication and +unchanged exact source bindings. None reproduced `Local network prohibited` or URL +error -1009 in their PID-bounded host logs; no privacy settings or credentials were +changed. The earlier permission block is therefore not the current observed blocker. + +- Plex Original reopen remains blocked before playback: the exact metadata request + returns HTTP 404, while other server requests succeed. No replacement item binding + was substituted, and no fresh Plex frame pass is claimed. +- Jellyfin's audio-transition run now authenticates and plays. Initial decoded frames + are fresh, recognizable and untinted, but the unchanged 25-second progress check + fails after a 2.135-second observation gap; sampled positions continue advancing. + There is no post-hold frame pass, and the failed scenario remains a failure. +- Emby's audio transition passes the unchanged 25-second hold and replacement-generation + check. Request-enforced video copy remains in effect; audio decision and server-side + cleanup remain unknown in app evidence. Fresh initial and post-scenario frames were + inspected and show recognizable untinted video. These tone-mapped captures do not + establish physical HDR or audio-output accuracy. + +These results supersede only the outstanding permission diagnosis, not the prior +failures or broader acceptance gates. Plex exact-source availability and Jellyfin's +progress-observation failure remain open. No video encoding was requested or authorized; +P5 safety, Plex Generic, consent, default-off DEBUG macOS routing and Release behavior +are unchanged. The run processes and staged host app were cleaned up while preserving +saved sessions. No simulator was booted, and no PR, merge or release action followed.