diff --git a/Apps/CLI/Sources/PeekabooCLI/Commands/Core/CaptureActionManifest.swift b/Apps/CLI/Sources/PeekabooCLI/Commands/Core/CaptureActionManifest.swift index 922c45bc9..2cfd02f9c 100644 --- a/Apps/CLI/Sources/PeekabooCLI/Commands/Core/CaptureActionManifest.swift +++ b/Apps/CLI/Sources/PeekabooCLI/Commands/Core/CaptureActionManifest.swift @@ -192,9 +192,21 @@ struct CaptureActionManifest: Codable { else { return "Capture action manifest fields contradict the retained action evidence" } + if let sample = timeline.sampleBoundary { + guard sample.actionCompletedOffsetNs / 1_000_000 == UInt64(timeline.actionCompletedMs), + sample.lastSampleStartedOffsetNs / 1_000_000 <= UInt64(timeline.samplingCompletedMs), + request.postRollMs == 0 || !result.validation.ok || sample.samplesAfterAction + else { + return "Capture action manifest sample evidence contradicts post-roll coverage" + } + } return nil } + var provesPostActionSample: Bool { + self.timeline.sampleBoundary?.samplesAfterAction == true + } + private static func artifactsAreCanonical(_ artifacts: [Artifact]) -> Bool { !artifacts.isEmpty && Set(artifacts.map(\.path)).count == artifacts.count && @@ -209,6 +221,70 @@ struct CaptureActionManifest: Codable { let actionCompletedMs: Int let samplingCompletedMs: Int let captureCompletedMs: Int + /// Older version-1 manifests retain elapsed-time evidence without this sample proof. + let sampleBoundary: SampleBoundary? + + init( + captureStartedAtUnixMs: Int64, + actionStartedMs: Int, + actionCompletedMs: Int, + samplingCompletedMs: Int, + captureCompletedMs: Int, + sampleBoundary: SampleBoundary? = nil + ) { + self.captureStartedAtUnixMs = captureStartedAtUnixMs + self.actionStartedMs = actionStartedMs + self.actionCompletedMs = actionCompletedMs + self.samplingCompletedMs = samplingCompletedMs + self.captureCompletedMs = captureCompletedMs + self.sampleBoundary = sampleBoundary + } + } + + struct SampleBoundary: Codable, Sendable { + let actionCompletedOffsetNs: UInt64 + let lastSampleStartedOffsetNs: UInt64 + + var samplesAfterAction: Bool { + self.lastSampleStartedOffsetNs >= self.actionCompletedOffsetNs + } + + init(actionCompletedOffsetNs: UInt64, lastSampleStartedOffsetNs: UInt64) { + self.actionCompletedOffsetNs = actionCompletedOffsetNs + self.lastSampleStartedOffsetNs = lastSampleStartedOffsetNs + } + + private enum CodingKeys: String, CodingKey { + case actionCompletedOffsetNs + case lastSampleStartedOffsetNs + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.actionCompletedOffsetNs = try Self.decodeOffset(.actionCompletedOffsetNs, from: container) + self.lastSampleStartedOffsetNs = try Self.decodeOffset(.lastSampleStartedOffsetNs, from: container) + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(String(self.actionCompletedOffsetNs), forKey: .actionCompletedOffsetNs) + try container.encode(String(self.lastSampleStartedOffsetNs), forKey: .lastSampleStartedOffsetNs) + } + + private static func decodeOffset( + _ key: CodingKeys, + from container: KeyedDecodingContainer + ) throws -> UInt64 { + let text = try container.decode(String.self, forKey: key) + guard let value = UInt64(text), String(value) == text else { + throw DecodingError.dataCorruptedError( + forKey: key, + in: container, + debugDescription: "Sample offset must be canonical unsigned nanoseconds" + ) + } + return value + } } struct Request: Codable { diff --git a/Apps/CLI/Sources/PeekabooCLI/Commands/Core/CaptureCommand+Action.swift b/Apps/CLI/Sources/PeekabooCLI/Commands/Core/CaptureCommand+Action.swift index bc9cda188..69856431c 100644 --- a/Apps/CLI/Sources/PeekabooCLI/Commands/Core/CaptureCommand+Action.swift +++ b/Apps/CLI/Sources/PeekabooCLI/Commands/Core/CaptureCommand+Action.swift @@ -220,32 +220,19 @@ RuntimeOptionsConfigurable, InjectedRuntimeBackedCommand { throw ValidationError("Capture ended before action started") } self.resolvedRuntime.beginInteractionMutation() - let dispatchState = CaptureActionDispatchState() - let action: CaptureActionProcessResult - do { - action = try await self.executionDependencies.processRunner( - self.command, - timing.actionTimeout, - actionCompletionDeadlineNs - ) { dispatchState.markDispatched(at: $0) } - self.recordChildDispatch(dispatchState) - self.childCommandCompleted = true - } catch { - self.recordChildDispatch(dispatchState) - throw error - } - guard let actionStartedNs = dispatchState.dispatchedAtMonotonicNanoseconds else { - throw CaptureActionProcessLaunchError( - message: "Action runner returned without admitting child dispatch" - ) - } + let (action, actionStartedNs) = try await self.runChildAction( + timing: timing, + completionDeadlineNs: actionCompletionDeadlineNs + ) let actionStartedMs = Self.elapsedMilliseconds( since: captureStartedNs, endingAt: actionStartedNs ) let resumedAtNs = DispatchTime.now().uptimeNanoseconds let actionCompletedNs = action.completedAtMonotonicNanoseconds ?? resumedAtNs - guard actionCompletedNs >= actionStartedNs, actionCompletedNs <= resumedAtNs else { + guard actionStartedNs >= captureStartedNs, + actionCompletedNs >= actionStartedNs, actionCompletedNs <= resumedAtNs + else { throw CaptureActionProcessLaunchError( message: "Action runner returned an invalid completion boundary" ) @@ -254,15 +241,13 @@ RuntimeOptionsConfigurable, InjectedRuntimeBackedCommand { since: captureStartedNs, endingAt: actionCompletedNs ) - let postRollDeadlineNs = try timing.postRollDeadline(startingAtNs: actionCompletedNs) - guard postRollDeadlineNs <= captureDeadlineNs else { - throw ValidationError("Action completion left insufficient time for the requested post-roll") - } - try await Self.sleep(untilMonotonicNanoseconds: postRollDeadlineNs) - session.requestStop() - - let captureCompletion = try await captureTask.value - try Task.checkCancellation() + let captureCompletion = try await Self.finishPostRoll( + session: session, + captureTask: captureTask, + timing: timing, + actionCompletedNs: actionCompletedNs, + captureDeadlineNs: captureDeadlineNs + ) let capture = captureCompletion.result try await self.revalidateCaptureHostIdentity(captureHostIdentity) let samplingCompletedMs = captureCompletion.samplingCompletedMs @@ -272,7 +257,8 @@ RuntimeOptionsConfigurable, InjectedRuntimeBackedCommand { artifactValidation: artifactValidation, samplingCompletedMs: samplingCompletedMs, actionCompletedMs: actionCompletedMs, - postRollMs: timing.postRollMs + postRollMs: timing.postRollMs, + sampledAfterAction: captureCompletion.lastSampleStartedNs >= actionCompletedNs ) let childOutcome = CaptureActionOutcomeSemantics.completedChildOutcome let outcome = CaptureActionOutcomeSemantics.aggregate( @@ -289,6 +275,10 @@ RuntimeOptionsConfigurable, InjectedRuntimeBackedCommand { captureStartedAtUnixMs: captureStartedAtUnixMs, actionStartedMs: actionStartedMs, actionCompletedMs: actionCompletedMs, + sampleBoundary: .init( + actionCompletedOffsetNs: actionCompletedNs - captureStartedNs, + lastSampleStartedOffsetNs: captureCompletion.lastSampleStartedNs - captureStartedNs + ), samplingCompletedMs: samplingCompletedMs, captureCompletedMs: captureCompletedMs, timing: timing, @@ -328,11 +318,58 @@ RuntimeOptionsConfigurable, InjectedRuntimeBackedCommand { self.childCommandDispatched = self.childCommandDispatched || dispatchState.wasDispatched } + private mutating func runChildAction( + timing: CaptureActionTiming, + completionDeadlineNs: UInt64 + ) async throws -> (CaptureActionProcessResult, UInt64) { + let dispatchState = CaptureActionDispatchState() + let action: CaptureActionProcessResult + do { + action = try await self.executionDependencies.processRunner( + self.command, + timing.actionTimeout, + completionDeadlineNs + ) { dispatchState.markDispatched(at: $0) } + self.recordChildDispatch(dispatchState) + self.childCommandCompleted = true + } catch { + self.recordChildDispatch(dispatchState) + throw error + } + guard let startedAtNs = dispatchState.dispatchedAtMonotonicNanoseconds else { + throw CaptureActionProcessLaunchError(message: "Action runner returned without admitting child dispatch") + } + return (action, startedAtNs) + } + + private static func finishPostRoll( + session: WatchCaptureSession, + captureTask: Task, + timing: CaptureActionTiming, + actionCompletedNs: UInt64, + captureDeadlineNs: UInt64 + ) async throws -> CaptureActionCaptureCompletion { + let postRollDeadlineNs = try timing.postRollDeadline(startingAtNs: actionCompletedNs) + guard postRollDeadlineNs <= captureDeadlineNs else { + throw ValidationError("Action completion left insufficient time for the requested post-roll") + } + try await Self.sleep(untilMonotonicNanoseconds: postRollDeadlineNs) + if timing.postRollMs > 0 { + session.requestStop(afterSampleStartedAtOrAfter: actionCompletedNs) + } else { + session.requestStop() + } + let completion = try await captureTask.value + try Task.checkCancellation() + return completion + } + private static func validatePostRollCoverage( artifactValidation: CaptureActionArtifactValidation, samplingCompletedMs: Int, actionCompletedMs: Int, - postRollMs: Int + postRollMs: Int, + sampledAfterAction: Bool ) -> CaptureActionArtifactValidation { let requiredCaptureCompletedMs = actionCompletedMs + postRollMs var validationFailures = artifactValidation.missing @@ -341,6 +378,9 @@ RuntimeOptionsConfigurable, InjectedRuntimeBackedCommand { "capture ended before the action and requested post-roll completed" ) } + if postRollMs > 0, !sampledAfterAction { + validationFailures.append("capture ended without a valid sample begun after the action completed") + } return CaptureActionArtifactValidation( ok: validationFailures.isEmpty, checked: artifactValidation.checked, @@ -394,7 +434,8 @@ RuntimeOptionsConfigurable, InjectedRuntimeBackedCommand { actionStartedMs: context.actionStartedMs, actionCompletedMs: context.actionCompletedMs, samplingCompletedMs: context.samplingCompletedMs, - captureCompletedMs: context.captureCompletedMs + captureCompletedMs: context.captureCompletedMs, + sampleBoundary: context.sampleBoundary ), request: .init( commandSHA256: CaptureActionManifestWriter.commandSHA256(self.command), @@ -462,8 +503,14 @@ RuntimeOptionsConfigurable, InjectedRuntimeBackedCommand { guard let samplingEndedNs = session.samplingEndedAtMonotonicNanoseconds else { throw ValidationError("capture session did not report its sampling completion boundary") } + guard let lastSampleStartedNs = session.lastSampleStartedAtMonotonicNanoseconds, + lastSampleStartedNs >= captureStartedNs + else { + throw ValidationError("capture session did not report a valid sample boundary") + } return CaptureActionCaptureCompletion( result: result, + lastSampleStartedNs: lastSampleStartedNs, samplingCompletedMs: Self.elapsedMilliseconds( since: captureStartedNs, endingAt: samplingEndedNs @@ -1028,6 +1075,7 @@ struct CaptureActionTiming { private struct CaptureActionCaptureCompletion: Sendable { let result: CaptureSessionResult + let lastSampleStartedNs: UInt64 let samplingCompletedMs: Int let completedMs: Int } @@ -1038,6 +1086,7 @@ private struct CaptureActionManifestContext { let captureStartedAtUnixMs: Int64 let actionStartedMs: Int let actionCompletedMs: Int + let sampleBoundary: CaptureActionManifest.SampleBoundary let samplingCompletedMs: Int let captureCompletedMs: Int let timing: CaptureActionTiming diff --git a/Apps/CLI/Tests/CoreCLITests/CaptureActionCommandEndToEndTests.swift b/Apps/CLI/Tests/CoreCLITests/CaptureActionCommandEndToEndTests.swift index 70e9e95fb..fc4d14ebf 100644 --- a/Apps/CLI/Tests/CoreCLITests/CaptureActionCommandEndToEndTests.swift +++ b/Apps/CLI/Tests/CoreCLITests/CaptureActionCommandEndToEndTests.swift @@ -804,7 +804,7 @@ struct CaptureActionCommandEndToEndTests { } } - private func makeRuntime() -> CommandRuntime { + func makeRuntime() -> CommandRuntime { CommandRuntime( configuration: .init(verbose: false, jsonOutput: true, logLevel: nil), services: PeekabooServices() @@ -820,7 +820,7 @@ struct CaptureActionCommandEndToEndTests { return size.intValue > 0 } - private static func authenticatedHostIdentity() -> PeekabooBridgeAuthenticatedHostIdentity { + static func authenticatedHostIdentity() -> PeekabooBridgeAuthenticatedHostIdentity { guard let processStartIdentity = SystemIdentityResolver.processStartIdentity(getpid()) else { preconditionFailure("Test process identity must be available") } @@ -1316,7 +1316,7 @@ extension CaptureActionCommandEndToEndTests { } @MainActor -private final class DeterministicCaptureActionFrameSource: CaptureFrameSource { +final class DeterministicCaptureActionFrameSource: CaptureFrameSource { private let engine: String? private(set) var captureCount = 0 var resolvedScopes: [CaptureScope] = [] diff --git a/Apps/CLI/Tests/CoreCLITests/CaptureActionPostRollTests.swift b/Apps/CLI/Tests/CoreCLITests/CaptureActionPostRollTests.swift new file mode 100644 index 000000000..b609819cf --- /dev/null +++ b/Apps/CLI/Tests/CoreCLITests/CaptureActionPostRollTests.swift @@ -0,0 +1,114 @@ +import CoreGraphics +import Foundation +import PeekabooCore +import Testing +@testable import PeekabooCLI + +extension CaptureActionCommandEndToEndTests { + @Test(.timeLimit(.minutes(1)), arguments: [ + (maxFrames: 20, postRollMs: 100, succeeds: true, samplesAfterAction: true), + (maxFrames: 2, postRollMs: 100, succeeds: false, samplesAfterAction: false), + (maxFrames: 20, postRollMs: 0, succeeds: true, samplesAfterAction: false), + ]) + func `post roll samples after a slow earlier frame while honoring caps and explicit zero`( + maxFrames: Int, postRollMs: Int, succeeds: Bool, samplesAfterAction: Bool + ) async throws { + let output = FileManager.default.temporaryDirectory + .appendingPathComponent("peekaboo-post-roll-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: output) } + let source = SlowPostActionFrameSource() + defer { source.finish() } + var childCompletedNs: UInt64? + var command = CaptureActionCommand() + command.mode = "frontmost" + command.durationLimit = CLIDuration(argument: "20s") + command.preRoll = CLIDuration(argument: "100ms") + command.postRoll = CLIDuration(argument: "\(postRollMs)ms") + command.threshold = 0 + command.maxFrames = maxFrames + command.path = output.path + command.command = ["/usr/bin/true"] + command.executionDependencies = CaptureActionExecutionDependencies( + frameSourceFactory: { _ in source }, + deadlineProcessRunner: { arguments, timeout, deadline, onLaunch in + var started = source.secondFrameStarted.makeAsyncIterator() + guard await started.next() != nil else { throw CancellationError() } + let result = try await CaptureActionProcessRunner.run( + command: arguments, + timeoutSeconds: timeout, + completionDeadlineNanoseconds: deadline, + onLaunch: onLaunch + ) + childCompletedNs = result.completedAtMonotonicNanoseconds + return result + }, + hostIdentityProvider: { Self.authenticatedHostIdentity() } + ) + command.runtime = self.makeRuntime() + + let result = try await command.executeActionCapture() + let completion = try #require(childCompletedNs) + #expect(result.success == succeeds) + #expect(result.action.exitCode == 0) + #expect(source.sampleStarts.contains { $0 >= completion } == samplesAfterAction) + let receipt = try #require(result.manifest) + let manifestData = try Data(contentsOf: URL(fileURLWithPath: receipt.path)) + let manifest = try JSONDecoder().decode(CaptureActionManifest.self, from: manifestData) + let sample = try #require(manifest.timeline.sampleBoundary) + #expect(manifest.provesPostActionSample == samplesAfterAction) + #expect(sample.actionCompletedOffsetNs / 1_000_000 == UInt64(manifest.timeline.actionCompletedMs)) + if postRollMs > 0, succeeds { + var object = try #require(JSONSerialization.jsonObject(with: manifestData) as? [String: Any]) + var timeline = try #require(object["timeline"] as? [String: Any]) + timeline["sampleBoundary"] = [ + "actionCompletedOffsetNs": String(sample.actionCompletedOffsetNs), + "lastSampleStartedOffsetNs": String(sample.actionCompletedOffsetNs - 1), + ] + object["timeline"] = timeline + let forged = try JSONSerialization.data(withJSONObject: object) + #expect(throws: (any Error).self) { + try JSONDecoder().decode(CaptureActionManifest.self, from: forged) + } + + timeline.removeValue(forKey: "sampleBoundary") + object["timeline"] = timeline + let legacy = try JSONDecoder().decode( + CaptureActionManifest.self, from: JSONSerialization.data(withJSONObject: object) + ) + #expect(legacy.timeline.sampleBoundary == nil) + #expect(!legacy.provesPostActionSample) + } + if !succeeds { + #expect(result.validation.missing.contains( + "capture ended without a valid sample begun after the action completed" + )) + } + } +} + +@MainActor +private final class SlowPostActionFrameSource: CaptureFrameSource { + private let source = DeterministicCaptureActionFrameSource() + let secondFrameStarted: AsyncStream + private let continuation: AsyncStream.Continuation + private(set) var sampleStarts: [UInt64] = [] + + init() { + let (stream, continuation) = AsyncStream.makeStream() + self.secondFrameStarted = stream + self.continuation = continuation + } + + func nextFrame() async throws -> (cgImage: CGImage?, metadata: CaptureMetadata)? { + self.sampleStarts.append(DispatchTime.now().uptimeNanoseconds) + if self.sampleStarts.count == 2 { + self.continuation.yield(()) + try await Task.sleep(for: .milliseconds(700)) + } + return try await self.source.nextFrame() + } + + func finish() { + self.continuation.finish() + } +} diff --git a/Apps/CLI/Tests/CoreCLITests/CaptureActionSampleBoundaryTests.swift b/Apps/CLI/Tests/CoreCLITests/CaptureActionSampleBoundaryTests.swift new file mode 100644 index 000000000..3d7046fcf --- /dev/null +++ b/Apps/CLI/Tests/CoreCLITests/CaptureActionSampleBoundaryTests.swift @@ -0,0 +1,32 @@ +import Foundation +import Testing +@testable import PeekabooCLI + +struct CaptureActionSampleBoundaryTests { + @Test + func `sample offsets round trip exactly above JSON integer precision`() throws { + let sample = CaptureActionManifest.SampleBoundary( + actionCompletedOffsetNs: 9_007_199_254_740_993, + lastSampleStartedOffsetNs: 9_007_199_254_740_994 + ) + let data = try JSONEncoder().encode(sample) + let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: String]) + #expect(object["actionCompletedOffsetNs"] == "9007199254740993") + let decoded = try JSONDecoder().decode(CaptureActionManifest.SampleBoundary.self, from: data) + #expect(decoded.actionCompletedOffsetNs == sample.actionCompletedOffsetNs) + #expect(decoded.lastSampleStartedOffsetNs == sample.lastSampleStartedOffsetNs) + #expect(decoded.samplesAfterAction) + } + + @Test(arguments: ["", "-1", "+1", "01", "1.0", "18446744073709551616"]) + func `sample offsets reject noncanonical and overflowing decimal strings`(_ offset: String) throws { + for key in ["actionCompletedOffsetNs", "lastSampleStartedOffsetNs"] { + var object = ["actionCompletedOffsetNs": "100", "lastSampleStartedOffsetNs": "101"] + object[key] = offset + let data = try JSONSerialization.data(withJSONObject: object) + #expect(throws: (any Error).self) { + try JSONDecoder().decode(CaptureActionManifest.SampleBoundary.self, from: data) + } + } + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f8ca46c4..2d6cc6e31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased - Fix host-routed screen observations with Accessibility elements by validating their semantic owner separately from the screen raster target. #715, #710. +- Keep action capture running until it samples after the child finishes and retain exact sample-boundary proof in new manifests; capture caps still fail incomplete coverage, while older version-1 manifests remain readable as legacy elapsed-time evidence. - Fix application name and bundle resolution being blocked by reaped processes lingering in LaunchServices; require repeated native absence while retaining refusal for uncertain or changing process identities. #709. ## 4.3.4 - 2026-09-11 diff --git a/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/Capture/WatchCaptureSession+Loop.swift b/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/Capture/WatchCaptureSession+Loop.swift index 88af28584..cdb6e204f 100644 --- a/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/Capture/WatchCaptureSession+Loop.swift +++ b/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/Capture/WatchCaptureSession+Loop.swift @@ -209,6 +209,7 @@ extension WatchCaptureSession { } state.consecutiveDecodeFailures = 0 state.framesSampled += 1 + self.recordValidSample(startedAtNanoseconds: frameStartNs) if self.keepAllFrames { try await self.keepAllFrame( diff --git a/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/Capture/WatchCaptureSession.swift b/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/Capture/WatchCaptureSession.swift index 49b135490..768b3f579 100644 --- a/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/Capture/WatchCaptureSession.swift +++ b/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/Capture/WatchCaptureSession.swift @@ -166,6 +166,8 @@ public final class WatchCaptureSession { var totalBytes: Int = 0 var lastCaptureErrorDescription: String? public private(set) var samplingEndedAtMonotonicNanoseconds: UInt64? + public private(set) var lastSampleStartedAtMonotonicNanoseconds: UInt64? + private var stopAfterSampleStartedAtNanoseconds: UInt64? private let stopSignal = WatchCaptureStopSignal() public init(dependencies: WatchCaptureDependencies, configuration: WatchCaptureConfiguration) { @@ -297,9 +299,29 @@ public final class WatchCaptureSession { } public func requestStop() { + self.stopAfterSampleStartedAtNanoseconds = nil self.stopSignal.request() } + /// Waits for a valid image sampled after the given boundary, subject to the existing capture caps. + public func requestStop(afterSampleStartedAtOrAfter boundary: UInt64) { + self.stopAfterSampleStartedAtNanoseconds = max(self.stopAfterSampleStartedAtNanoseconds ?? boundary, boundary) + self.finishDeferredStopIfSampled() + } + + func recordValidSample(startedAtNanoseconds: UInt64) { + self.lastSampleStartedAtMonotonicNanoseconds = startedAtNanoseconds + self.finishDeferredStopIfSampled() + } + + private func finishDeferredStopIfSampled() { + guard let boundary = self.stopAfterSampleStartedAtNanoseconds, + let sampledAt = self.lastSampleStartedAtMonotonicNanoseconds, + sampledAt >= boundary + else { return } + self.requestStop() + } + func hasStopRequest() -> Bool { self.stopSignal.isRequested() } diff --git a/Core/PeekabooAutomationKit/Tests/PeekabooAutomationKitTests/CaptureCadenceTests.swift b/Core/PeekabooAutomationKit/Tests/PeekabooAutomationKitTests/CaptureCadenceTests.swift index 56d089d79..0a707cc92 100644 --- a/Core/PeekabooAutomationKit/Tests/PeekabooAutomationKitTests/CaptureCadenceTests.swift +++ b/Core/PeekabooAutomationKit/Tests/PeekabooAutomationKitTests/CaptureCadenceTests.swift @@ -54,6 +54,32 @@ struct CaptureCadenceTests { @MainActor struct WatchCaptureCadenceSchedulingTests { + @Test(arguments: [UInt64(99), 100, 101]) + func `deferred stop requires a valid sample at its boundary`(sampleStartedAt: UInt64) { + let session = Self.makeSession(clock: TestWatchCaptureClock()) + session.requestStop(afterSampleStartedAtOrAfter: 100) + #expect(!session.hasStopRequest()) + session.recordValidSample(startedAtNanoseconds: sampleStartedAt) + #expect(session.hasStopRequest() == (sampleStartedAt >= 100)) + #expect(session.lastSampleStartedAtMonotonicNanoseconds == sampleStartedAt) + } + + @Test + func `immediate stop overrides a pending sample boundary`() { + let session = Self.makeSession(clock: TestWatchCaptureClock()) + session.requestStop(afterSampleStartedAtOrAfter: 100) + session.requestStop() + #expect(session.hasStopRequest()) + } + + @Test + func `an already completed sample satisfies deferred stopping`() { + let session = Self.makeSession(clock: TestWatchCaptureClock()) + session.recordValidSample(startedAtNanoseconds: 100) + session.requestStop(afterSampleStartedAtOrAfter: 100) + #expect(session.hasStopRequest()) + } + @Test(arguments: [ (costMs: UInt64(0), expectedSleepMs: UInt64(100)), (costMs: 20, expectedSleepMs: 80), diff --git a/docs/commands/capture.md b/docs/commands/capture.md index 85c81af7a..bc85fab87 100644 --- a/docs/commands/capture.md +++ b/docs/commands/capture.md @@ -75,7 +75,11 @@ add another sleep or extend the session beyond its deadline. The command exits non-zero if the child command exits non-zero, times out, leaves a process-group descendant that Peekaboo cannot terminate, or required capture artifacts fail custody or semantic validation. JSON output includes the child command exit code/stdout/stderr, the normal `CaptureResult`, artifact validation details, the canonical `outcome`, and the SHA-256 receipt for `action.json`. Command success is cross-checked against the child, validation, and manifest receipt; effect/dispatch/retry fields are derived from the canonical outcome. A released child reports dispatched-unverified evidence rather than claiming a verified partial desktop change. Failures before focus or child release report a canonical refused, retry-safe, not-dispatched outcome. The child starts after the requested pre-roll while capture remains active, and capture continues through the full -post-roll. Suspended spawn and process-generation attribution consume the action timeout before `SIGCONT`; Peekaboo +post-roll. Positive post-roll also requires a valid image whose sampling began after the child completed; processing +an older frame does not satisfy that requirement. A slow earlier frame can extend sampling beyond the requested +post-roll boundary, within the existing duration, frame, and size caps. If a cap prevents the required sample, the +capture reports incomplete coverage. Zero post-roll retains immediate stopping after the child completes. +Suspended spawn and process-generation attribution consume the action timeout before `SIGCONT`; Peekaboo reports the effective timeout after the outer capture and cleanup deadline caps it. The pre-roll race does not join the long-running session task. A live capture deadline can also end an in-flight frame attempt, so one slow or cancellation-insensitive capture call cannot defer the action until after the requested session duration. @@ -99,6 +103,12 @@ refuses raw, ad-hoc, unstamped, untrusted-team, or unsigned hosts instead of pub Retain the SHA-256 returned in CLI JSON with the manifest: the manifest is canonical and hash-bound to that result, but it is not by itself an independently signed certification artifact. +New manifests include `timeline.sampleBoundary`, with canonical decimal-string nanosecond offsets from capture start +for action completion and the last valid sample's start. Validation checks their millisecond projections and requires +the sample to begin at or after action completion when positive post-roll is reported valid. Older version-1 manifests +remain readable as elapsed-time evidence but do not prove a post-action sample; consumers requiring that guarantee +must require `sampleBoundary` and verify the retained manifest SHA-256. + The manifest records `containmentScope: process_group`. This lifecycle boundary covers the launched group, including ordinary background children, but it is not a hostile-process sandbox. A command that deliberately calls `setsid` or moves descendants into another process group has escaped that contract; do not use such a command when process