Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
1a131ca
fix(host-kit): process lock proves ownership before release and never…
thymikee Sep 14, 2026
5d3444f
fix(host-kit): a reclaimed lock is proven to be the one judged stale …
thymikee Sep 14, 2026
47f58af
fix(host-kit): a lock claim is identified by its token, not only by i…
thymikee Sep 14, 2026
6c8c7c7
fix(host-kit): a reclaim re-decides in place, and a release never spe…
thymikee Sep 14, 2026
17ca384
fix(host-kit): a spent claim is dead to the next reclaim from this pr…
thymikee Sep 15, 2026
805ddd2
fix(apple-runner): a redirect's best-effort give-back forgives only i…
thymikee Sep 15, 2026
63ab4b2
test(apple-runner): one device-set redirect double for the session tests
thymikee Sep 15, 2026
1702697
test(apple-runner): the close tests' redirect double carries both giv…
thymikee Sep 15, 2026
606060d
fix(host-kit): a spent claim belongs to the loading that issued it
thymikee Sep 15, 2026
91453f9
fix(apple-runner): one ordered give-back decides what the caller hears
thymikee Sep 15, 2026
db0ab80
refactor(runner): one hand-back answers for all three redirect exits
thymikee Sep 15, 2026
530d818
fix(apple-runner): a leftover redirect is undone before the redirect …
thymikee Sep 15, 2026
d081f84
refactor(apple-runner): the redirect decides in place and reports thr…
thymikee Sep 15, 2026
e732fe6
fix(platform-apple): exit the device-set acquire only by throw or return
thymikee Sep 16, 2026
615c58c
test(apple-runner): pin the backup path a half-finished restore may name
thymikee Sep 16, 2026
876f746
refactor: drop the lock leftovers withProcessLock made redundant
thymikee Sep 16, 2026
6aa2f85
refactor: the snapshot bridge cache runs under withProcessLock too
thymikee Sep 16, 2026
f71d2e5
test: a failed build outranks an unverifiable lock release at both ar…
thymikee Sep 17, 2026
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
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,31 @@
against the instant that attempt signalled the recorder rather than the time of the retry. A stop
that fails and is then abandoned keeps the pulled set on the host beside `--out`. Chunk paths and
`--client-output-path` naming are unchanged.
- Fixed: a process lock that could not be given back no longer waits for the daemon to restart. A
release that cannot verify ownership — a refused `unlink`, an unreadable record — left its record
standing and naming the live daemon, and the next acquire read that as a live owner and timed out
after 30 s on every runner build or launch until the process restarted. The claim inside is spent
the moment the release is asked for, so a reclaim here now reads it as dead and takes the path
back, and the failed release is recorded in the request log as `process_lock_release_unverified`.
A record is only read that way when this loading of the lock code issued it: a second bundled copy
of the module in the same process shares the pid and cannot have a live claim cleared under it.
- Changed (lock errors): code that takes a process lock now answers one question in one helper, which
of its two failures the caller hears. The work inside the lock outranks a lock that could not be
handed back, so a build or a publish that failed keeps its own error instead of being replaced by
`Timed out waiting for …`, and work that succeeded still reports the lock it could not give back.
The Apple runner's artifact, cache, lease and disposal paths, the managed-allocation store, the
device-claim store, the iOS snapshot bridge cache, the Swift recording cache and the agent-browser setup
moved onto it, replacing hand-written try/catch pairs that each chose differently.
- Fixed: the redirect of `~/Library/Developer/XCTestDevices` gives itself back in one order — restore
the host's own device set, then release the lock — and a restore that was refused is what the caller
is told, whichever give-back door a teardown used. The lock's own complaint used to replace it in a
`finally` and the best-effort door then dropped it, leaving the symlink pointed at the agent-device
simulator set with nothing said about why. A simulator whose set already is `XCTestDevices` is no
longer failed by a lock it could not verify, either. A leftover from an interrupted build — the host's
set renamed aside and `XCTestDevices` symlinked into a simulator's own set — is now put back before the
redirect decides whether it is needed, so the first build after an interruption still gets its own
device set instead of the host's. A redirect that could not be installed reports the restore that failed
with it, and names a backup path only when that backup is really on disk.
- Changed (sessions): the implicit session is now keyed by workspace **and platform**, so one checkout

can drive iOS and Android without inventing a `--session` name for every command (#2580). An
Expand Down
26 changes: 26 additions & 0 deletions packages/capture-kit/src/recording/swift-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,32 @@ test('compileSwiftSourceText falls back to swift-helper when the cache name sani
expect(fs.statSync(executablePath).mode & 0o111).not.toBe(0);
});

test('a compile that failed is reported over a cache lock that could not be given back', async () => {
const sourcePath = writeSourceFile();
const buildFailure = new Error('swiftc: error: build failed');
let lockDir = '';
mockRunCmd.mockImplementationOnce(async (_cmd: string, args: string[]) => {
// The temp executable sits one directory under the cache entry, and the lock beside it.
const outputPath = args[args.indexOf('-o') + 1]!;
const executablePath = path.join(
path.dirname(path.dirname(outputPath)),
path.basename(outputPath),
);
lockDir = `${executablePath}.lock`;
// A record that cannot be read is a release that cannot prove ownership.
const ownerFile = path.join(lockDir, 'owner.json');
fs.rmSync(ownerFile);
fs.mkdirSync(ownerFile);
throw buildFailure;
});

await expect(compileSwiftSourceFile({ sourcePath, cacheName: 'recording-overlay' })).rejects.toBe(
buildFailure,
);
// The release really could not verify itself: the lock is still standing.
expect(fs.existsSync(lockDir)).toBe(true);
});

function writeSourceFile(source = 'print("recording")'): string {
const sourcePath = path.join(tmpDir, 'recording-overlay.swift');
fs.writeFileSync(sourcePath, source);
Expand Down
76 changes: 38 additions & 38 deletions packages/capture-kit/src/recording/swift-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import { trimEdgeDashes } from '@agent-device/kernel/collections';
import { AppError } from '@agent-device/kernel/errors';
import { runCmd } from '@agent-device/host-kit/command';
import { readProcessStartTime } from '@agent-device/host-kit/process';
import { acquireProcessLock } from '@agent-device/host-kit/file';
import {
acquireProcessLock,
withProcessLock,
type ProcessLockRelease,
} from '@agent-device/host-kit/file';

const SWIFT_CACHE_VERSION = '2';
const LOCK_RETRY_DELAY_MS = 25;
Expand Down Expand Up @@ -98,48 +102,44 @@ async function ensureSwiftExecutable(params: {

const executableDir = path.dirname(params.executablePath);
fs.mkdirSync(executableDir, { recursive: true });
const lockDir = `${params.executablePath}.lock`;
const releaseLock = await acquireSwiftCacheLock(
lockDir,
params.executablePath,
params.timeoutMs ?? 120_000,
);
if (!releaseLock) {
return;
}

const tempDir = fs.mkdtempSync(
path.join(executableDir, `.${path.basename(params.executablePath)}.${process.pid}.`),
);
const tempExecutablePath = path.join(tempDir, path.basename(params.executablePath));
try {
if (isExecutableFile(params.executablePath)) {
return;
}
const [primarySourcePath] = params.sourcePaths;
if (params.sourceText !== undefined && primarySourcePath && !fs.existsSync(primarySourcePath)) {
fs.mkdirSync(path.dirname(primarySourcePath), { recursive: true });
fs.writeFileSync(primarySourcePath, params.sourceText);
}
await runCmd('xcrun', ['swiftc', ...params.sourcePaths, '-o', tempExecutablePath], {
timeoutMs: params.timeoutMs ?? 120_000,
env: buildSwiftToolEnv(),
});
fs.renameSync(tempExecutablePath, params.executablePath);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
await releaseLock();
}
const timeoutMs = params.timeoutMs ?? 120_000;
await withProcessLock({
acquire: () => acquireSwiftCacheLock(`${params.executablePath}.lock`, timeoutMs),
task: async () => {
// Another process may have published the executable while this one waited for the lock.
if (isExecutableFile(params.executablePath)) {
return;
}
const tempDir = fs.mkdtempSync(
path.join(executableDir, `.${path.basename(params.executablePath)}.${process.pid}.`),
);
const tempExecutablePath = path.join(tempDir, path.basename(params.executablePath));
try {
const [primarySourcePath] = params.sourcePaths;
if (
params.sourceText !== undefined &&
primarySourcePath &&
!fs.existsSync(primarySourcePath)
) {
fs.mkdirSync(path.dirname(primarySourcePath), { recursive: true });
fs.writeFileSync(primarySourcePath, params.sourceText);
}
await runCmd('xcrun', ['swiftc', ...params.sourcePaths, '-o', tempExecutablePath], {
timeoutMs,
env: buildSwiftToolEnv(),
});
fs.renameSync(tempExecutablePath, params.executablePath);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
},
});
}

async function acquireSwiftCacheLock(
lockDir: string,
executablePath: string,
timeoutMs: number,
): Promise<(() => Promise<void>) | null> {
if (isExecutableFile(executablePath)) {
return null;
}
): Promise<ProcessLockRelease> {
try {
return await acquireProcessLock({
lockDirPath: lockDir,
Expand Down
7 changes: 6 additions & 1 deletion packages/host-kit/src/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,9 @@ export {
openVerifiedFileForTruncate,
} from './internal/verified-file.ts';
export { expandUserHomePath, resolveUserPath } from './internal/path-resolution.ts';
export { acquireProcessLock, type ProcessLockOwner } from './internal/process-lock.ts';
export {
acquireProcessLock,
withProcessLock,
type ProcessLockOwner,
type ProcessLockRelease,
} from './internal/process-lock.ts';
Loading
Loading