Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&
Expand All @@ -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<CodingKeys>
) 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 {
Expand Down
113 changes: 81 additions & 32 deletions Apps/CLI/Sources/PeekabooCLI/Commands/Core/CaptureCommand+Action.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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
Expand All @@ -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(
Expand All @@ -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,
Expand Down Expand Up @@ -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<CaptureActionCaptureCompletion, any Error>,
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
Expand All @@ -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,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1028,6 +1075,7 @@ struct CaptureActionTiming {

private struct CaptureActionCaptureCompletion: Sendable {
let result: CaptureSessionResult
let lastSampleStartedNs: UInt64
let samplingCompletedMs: Int
let completedMs: Int
}
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -804,7 +804,7 @@ struct CaptureActionCommandEndToEndTests {
}
}

private func makeRuntime() -> CommandRuntime {
func makeRuntime() -> CommandRuntime {
CommandRuntime(
configuration: .init(verbose: false, jsonOutput: true, logLevel: nil),
services: PeekabooServices()
Expand All @@ -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")
}
Expand Down Expand Up @@ -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] = []
Expand Down
Loading