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 @@ -88,18 +88,23 @@ func makeRecordingExporter(
return exporter
}

/// Bounded asynchronous export: signals completion through a semaphore and cancels after 120s so a
/// wedged encoder cannot hang the recording pipeline past the caller's own timeout budget.
/// How long an export may run when the caller names no budget of its own.
let defaultRecordingExportTimeoutSeconds: Double = 120

/// Bounded asynchronous export: signals completion through a semaphore and cancels after
/// `timeoutSeconds` so a wedged or slow encoder cannot hang the recording pipeline past the
/// caller's own timeout budget.
func runRecordingExport(
_ exporter: AVAssetExportSession,
timeoutSeconds: Double = defaultRecordingExportTimeoutSeconds,
timeoutMessage: String,
failureMessage: String
) throws {
let semaphore = DispatchSemaphore(value: 0)
exporter.exportAsynchronously {
semaphore.signal()
}
if semaphore.wait(timeout: .now() + 120) == .timedOut {
if semaphore.wait(timeout: .now() + timeoutSeconds) == .timedOut {
exporter.cancelExport()
throw RecordingScriptError.exportFailed(timeoutMessage)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ func run() throws {
)
try runRecordingExport(
exporter,
timeoutSeconds: parsedArgs.timeoutSeconds,
timeoutMessage: "Touch overlay export timed out.",
failureMessage: "Touch overlay export failed."
)
Expand All @@ -157,10 +158,11 @@ func run() throws {

func parseArguments(
_ arguments: [String]
) throws -> (inputPath: String, outputPath: String, eventsPath: String) {
) throws -> (inputPath: String, outputPath: String, eventsPath: String, timeoutSeconds: Double) {
var inputPath: String?
var outputPath: String?
var eventsPath: String?
var timeoutSeconds = defaultRecordingExportTimeoutSeconds
var index = 0

while index < arguments.count {
Expand All @@ -185,17 +187,24 @@ func parseArguments(
throw RecordingScriptError.invalidArgs("--quality must be one of: medium, high")
}
index += 2
case "--timeout-ms":
let rawValue = try recordingOptionValue(arguments, nextIndex, "--timeout-ms")
guard let milliseconds = Double(rawValue), milliseconds > 0 else {
Comment thread
harrisrobin marked this conversation as resolved.
throw RecordingScriptError.invalidArgs("--timeout-ms must be a positive number")
}
timeoutSeconds = milliseconds / 1000
index += 2
default:
throw RecordingScriptError.invalidArgs("Unknown argument: \(argument)")
}
}

guard let inputPath, let outputPath, let eventsPath else {
throw RecordingScriptError.invalidArgs(
"Usage: recording-overlay.swift --input <video> --output <video> --events <json> [--quality <medium|high>]"
"Usage: recording-overlay.swift --input <video> --output <video> --events <json> [--quality <medium|high>] [--timeout-ms <ms>]"
)
}
return (inputPath, outputPath, eventsPath)
return (inputPath, outputPath, eventsPath, timeoutSeconds)
}

/// The composited overlay must keep the captured track's dimensions, so it can only use a preset
Expand Down Expand Up @@ -329,19 +338,26 @@ func frameMeanLuma(_ image: CGImage) -> Double? {
return total / Double(count)
}

/// The composited export renders at most this many frames a second, which is smooth for a touch
/// that stays visible for 0.45s.
let maximumCompositedFrameRate: Int32 = 30

/// A variable-frame-rate capture (simctl recordVideo, adb screenrecord) reports one tick of its
/// timescale as `minFrameDuration` (1/600s from simctl). Rendering at that asked the compositor for
/// every frame the encoder could make, 75 a second from an iOS simulator and 60 from an Android
/// emulator, about three times the frames the capture holds; a 5-minute recording's export then
/// outlasted the `record stop` request. So the frame duration is the capture's, but never shorter
/// than one frame at `maximumCompositedFrameRate`. A capture that reports none renders at that
/// rate: its average (`nominalFrameRate`) can be a couple of frames a second over a still screen,
/// which would skip a touch.
func resolvedFrameDuration(for track: AVAssetTrack) -> CMTime {
let shortest = CMTime(value: 1, timescale: maximumCompositedFrameRate)
let minFrameDuration = track.minFrameDuration
if minFrameDuration.isValid && !minFrameDuration.isIndefinite && minFrameDuration.seconds > 0 {
return minFrameDuration
}

let nominalFrameRate = track.nominalFrameRate
if nominalFrameRate > 0 {
let timescale = Int32(max(1, round(nominalFrameRate)))
return CMTime(value: 1, timescale: timescale)
return CMTimeMaximum(minFrameDuration, shortest)
}

return CMTime(value: 1, timescale: 60)
return shortest
}

func overlayPoint(event: GestureEvent, x: Double, y: Double, renderSize: CGSize) -> CGPoint {
Expand Down
46 changes: 45 additions & 1 deletion packages/capture-kit/src/recording/__tests__/overlay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ vi.mock('../video.ts', () => ({
waitForPlayableVideo: vi.fn(async () => {}),
}));

import { overlayRecordingTouches } from '../overlay.ts';
import { OVERLAY_BUDGET_MS, overlayRecordingTouches } from '../overlay.ts';
import { AppError } from '@agent-device/kernel/errors';
import { runCmd } from '@agent-device/host-kit/command';

Expand All @@ -48,6 +48,7 @@ beforeEach(() => {
});

afterEach(() => {
vi.useRealTimers();
fs.rmSync(tmpDir, { recursive: true, force: true });
});

Expand Down Expand Up @@ -129,3 +130,46 @@ test('overlay forwards the requested high export preset', async () => {
expect.arrayContaining(['--events', telemetryPath, '--quality', 'high']),
);
});

test('overlay hands the helper what is left of its budget, inside the record request', async () => {
const videoPath = path.join(tmpDir, 'recording.mp4');
const telemetryPath = path.join(tmpDir, 'recording.gesture-telemetry.json');
fs.writeFileSync(videoPath, 'original');
fs.writeFileSync(telemetryPath, '{"events":[]}');

await overlayRecordingTouches({ videoPath, telemetryPath });

const compileCall = mockRunCmd.mock.calls.find(([cmd]) => cmd === 'xcrun');
const helperCall = mockRunCmd.mock.calls.find(([cmd]) => cmd !== 'xcrun');
const args = helperScriptArgs();
const exportMs = Number(args[args.indexOf('--timeout-ms') + 1]);
expect(exportMs).toBeGreaterThan(0);
// The compile, the export and the helper's exit all fit in the budget. That the budget fits in
// `record`'s request envelope is checked where the envelope is declared.
expect(compileCall?.[2]?.timeoutMs).toBeLessThanOrEqual(OVERLAY_BUDGET_MS);
expect(helperCall?.[2]?.timeoutMs).toBeLessThanOrEqual(OVERLAY_BUDGET_MS);
expect(exportMs).toBeLessThan(helperCall?.[2]?.timeoutMs ?? 0);
});

test('overlay leaves the video as recorded when compiling the helper spent its budget', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
const videoPath = path.join(tmpDir, 'recording.mp4');
const telemetryPath = path.join(tmpDir, 'recording.gesture-telemetry.json');
fs.writeFileSync(videoPath, 'original');
fs.writeFileSync(telemetryPath, '{"events":[]}');

mockRunCmd.mockImplementationOnce(async (_cmd, args) => {
vi.setSystemTime(Date.now() + OVERLAY_BUDGET_MS);
const outputPath = args[args.indexOf('-o') + 1]!;
fs.writeFileSync(outputPath, 'compiled');
fs.chmodSync(outputPath, 0o755);
return { stdout: '', stderr: '', exitCode: 0 };
});

await expect(overlayRecordingTouches({ videoPath, telemetryPath })).rejects.toMatchObject({
code: 'COMMAND_FAILED',
message: 'Failed to add touch overlays to the recording',
});
expect(mockRunCmd.mock.calls.filter(([cmd]) => cmd !== 'xcrun')).toHaveLength(0);
expect(fs.readFileSync(videoPath, 'utf8')).toBe('original');
});
45 changes: 41 additions & 4 deletions packages/capture-kit/src/recording/overlay.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { runCmd } from '@agent-device/host-kit/command';
import { Deadline } from '@agent-device/host-kit/retry';
import { AppError } from '@agent-device/kernel/errors';
import { findProjectRoot } from '@agent-device/host-kit/version';
import {
Expand All @@ -23,6 +24,20 @@ export function getRecordingOverlaySupportWarning(
return 'touch overlay burn-in is only available on macOS hosts; returning raw video plus gesture telemetry';
}

/**
* `record stop` is one daemon request, which the client abandons after `record`'s 90 s envelope:
* the caller gets "Daemon request timed out" while the daemon finishes the stop. So the overlay must
* end well inside it. Compiling the helper, its export and its exit all come out of this budget;
* past it the stop keeps the raw video and its gesture telemetry, with a warning.
*/
export const OVERLAY_BUDGET_MS = 70_000;

/**
* The helper's time outside its export: starting up, then verifying the composited output or
* cancelling the export, and exiting.
*/
const HELPER_EXIT_GRACE_MS = 5_000;

let overlayScriptPath: string | undefined;
let exportSupportScriptPath: string | undefined;

Expand All @@ -44,8 +59,10 @@ async function exportProcessedVideo(params: {
scriptPath: string;
scriptArgs: string[];
commandDescription: string;
budgetMs: number;
}): Promise<void> {
const { videoPath, scriptPath, scriptArgs, commandDescription } = params;
const deadline = Deadline.fromTimeoutMs(params.budgetMs);
await waitForStableFile(videoPath);
await waitForPlayableVideo(videoPath);

Expand All @@ -54,11 +71,30 @@ async function exportProcessedVideo(params: {
const executablePath = await compileSwiftSourceFile({
sourcePath: scriptPath,
extraSourcePaths: [getExportSupportScriptPath()],
// `runCmd` reads a timeout of 0 as none.
timeoutMs: Math.max(1, deadline.remainingMs()),
});
await runCmd(executablePath, ['--input', videoPath, '--output', outputPath, ...scriptArgs], {
timeoutMs: 120_000,
env: buildSwiftToolEnv(),
});
const helperMs = deadline.remainingMs();
const exportMs = helperMs - HELPER_EXIT_GRACE_MS;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The process-kill timer (runCmd timeoutMs: helperMs) and the export timer (--timeout-ms = helperMs - 5s) start at different moments, but the difference between them is the only budget for everything else that can keep the helper alive past a completed export: helper startup, the post-export verifyCompositedOverlay, and exit. Because runCmd's kill fires only when the process is still running at helperMs (measured from spawn), a helper whose export ran close to its --timeout-ms can be SIGKILLed before it finishes verifyCompositedOverlay/exits, discarding a good overlay down the raw-video fallback path. Give the post-export steps their own margin instead of deriving both from a single shared 5s grace.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/capture-kit/src/recording/overlay.ts, line 73:

<comment>The process-kill timer (`runCmd` `timeoutMs: helperMs`) and the export timer (`--timeout-ms = helperMs - 5s`) start at different moments, but the difference between them is the only budget for everything else that can keep the helper alive past a completed export: helper startup, the post-export `verifyCompositedOverlay`, and exit. Because `runCmd`'s kill fires only when the process is still running at `helperMs` (measured from spawn), a helper whose export ran close to its `--timeout-ms` can be SIGKILLed before it finishes `verifyCompositedOverlay`/exits, discarding a good overlay down the raw-video fallback path. Give the post-export steps their own margin instead of deriving both from a single shared 5s grace.</comment>

<file context>
@@ -54,11 +67,29 @@ async function exportProcessedVideo(params: {
-      env: buildSwiftToolEnv(),
-    });
+    const helperMs = deadline - Date.now();
+    const exportMs = helperMs - HELPER_EXIT_GRACE_MS;
+    if (exportMs <= 0) {
+      throw new AppError(
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Measured on a 600 s simulator recording with this helper:

  • 0.02 s from launch to the export;
  • after a completed export, 0.06 s in verifyCompositedOverlay and 0.06 s to exit;
  • after a timed-out export, 0.24 s from cancel to exit.

The shared 5 s covers each about 20 times, so 71650bb keeps one margin and makes HELPER_EXIT_GRACE_MS's comment name start-up, verify and exit.

if (exportMs <= 0) {
throw new AppError(
'COMMAND_FAILED',
`No time was left for the export within the ${params.budgetMs}ms budget`,
);
}
await runCmd(
executablePath,
[
'--input',
videoPath,
'--output',
outputPath,
...scriptArgs,
'--timeout-ms',
String(Math.round(exportMs)),
],
{ timeoutMs: helperMs, env: buildSwiftToolEnv() },
);
await waitForPlayableVideo(outputPath);
fs.renameSync(outputPath, videoPath);
} catch (error) {
Expand Down Expand Up @@ -109,5 +145,6 @@ export async function overlayRecordingTouches(params: {
scriptPath: getOverlayScriptPath(),
scriptArgs: ['--events', telemetryPath, '--quality', exportQuality],
commandDescription: `Failed to add touch overlays to the ${targetLabel}`,
budgetMs: OVERLAY_BUDGET_MS,
});
}
13 changes: 13 additions & 0 deletions src/__tests__/command-descriptor-timeout-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from '@agent-device/command-registry/registry';
import { INTERACTION_DISPATCH_PATHS } from '@agent-device/contracts/interaction-guarantees';
import { SELECTOR_PIPELINE_POLICIES } from '@agent-device/selectors/selector-pipeline-policy';
import { OVERLAY_BUDGET_MS } from '@agent-device/capture-kit/recording-overlay';
import {
DEFAULT_TIMEOUT_POLICY,
READINESS_BUDGET_MAX_MS,
Expand Down Expand Up @@ -334,6 +335,18 @@ test('snapshot uses the standard daemon request timeout with an explicit overrid
);
});

test('the touch overlay budget leaves record stop room inside its request envelope', () => {
// The recorder stop, the copies and the playability checks around the overlay are outside its
// budget (about 1 s for a 10-minute simulator recording), so the envelope keeps a reserve for
// them beyond it.
const reserveMs = 20_000;
const envelopeMs = resolveCommandRequestTimeoutMs(resolveCommandTimeoutPolicy('record'), {
positionals: ['stop'],
flags: {},
});
assert.ok(envelopeMs !== undefined && OVERLAY_BUDGET_MS + reserveMs <= envelopeMs);
});

test('open and prepare startup budgets keep a client-envelope margin over the daemon deadline', () => {
const base = { positionals: [] as string[], flags: {} };

Expand Down
Loading