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
45 changes: 45 additions & 0 deletions docs/adr/0030-process-lock-exclusion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Process lock exclusion

## Status

Accepted for the hardened host-kit protocol. Mixed access with the legacy host-kit protocol
is unsupported.

## Rules at a glance

- Publication, reclaim and release hold the same non-expiring mutation guard.
- The guard covers only filesystem compare-and-mutate steps. Owner liveness is judged before
the guard is taken; under it, a reclaim only confirms that the record it judged is unchanged.
- Release waits a bounded time for a held guard. A guard still held after that bound leaves the
release unverified, as an unprovable owner record or a directory it cannot remove also does.
- A claim identifies an acquisition; a PID alone cannot authorize release.
- Unknown owners and abandoned guards remain retained. Age never proves abandonment.
- Before upgrading, stop every legacy process using the shared state or cache paths. Keep
legacy versions from returning while hardened users operate those paths. Use a single
deployed version or separate environments; deleting a lock is not an upgrade procedure.
- Manual guard recovery requires external confirmation that every user of its paths stopped.

The implementation and its executable invariants live in
[`process-lock.ts`](../../packages/host-kit/src/internal/process-lock.ts) and its sibling tests.

## Why the guard does not expire

A process can pause after creating a directory or verifying a dead claim. Expiring its guard
lets another process enter, then the first process resumes and overwrites or deletes the
replacement. Retaining an uncertain guard trades automatic crash recovery for exclusion.

The guard is an exclusive file at the existing guard path. Legacy age-based `rmdir` cannot
remove it, so a fresh legacy reclaimer cannot steal a hardened publisher's guard. This does
not revoke a legacy reclaimer admitted before cutover: another legacy process may already
have stolen its directory guard. A real paused-child experiment reproduced both protocols
returning acquired after that older reclaimer resumed. Quiescence must cover all legacy
users, and continued mixed access remains unsupported.

A different lock path would split exclusion. A version check, an owner re-read or a new
guard cannot constrain legacy code after its final check. The support boundary therefore
requires deployment control; host-kit does not claim to detect or evict every legacy user.

Daemon registration has a separate cutover boundary: keep the same `daemon.lock` path and
refuse legacy files rather than automatically reclaiming them. Its startup tests must cover
an already-running older daemon and concurrent old/new startup. This does not expand the
host-kit mixed-protocol support contract.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
| [0027 Descriptor Root Size vs Eager-Closure Budget (Proposed)](0027-descriptor-root-vs-eager-closure-budget.md) | splitting `packages/command-registry/src/registry.ts`, the ADR-0019 eager-closure module-count budget, and why a byte-neutral hub split is currently unshippable |
| [0028 Capability-Family Cell Vocabulary — One Runtime Source (Proposed)](0028-capability-family-cell-vocabulary.md) | adding a capability operation family, `UnavailablePlatformRuntimeFacts` / `UNAVAILABLE_CELLS`, `INTERACTOR_OPERATIONS`, and why an eight-package fan-out recurs |
| [0029 Daemon Policy](0029-daemon-policy.md) | `AGENT_DEVICE_DAEMON_POLICY`, confining a daemon's commands, devices, or device shutdown, and where operator rules are enforced for batch/replay steps |
| [0030 Process Lock Exclusion](0030-process-lock-exclusion.md) | process-lock publication/reclaim/release, retained mutation guards, and the single-protocol upgrade boundary |

ADRs record *why*; the registries and gates they describe are the living source of truth — when
prose and a registry disagree, the registry wins and the ADR needs a follow-up.
Expand Down
36 changes: 23 additions & 13 deletions packages/capture-kit/src/recording/swift-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,16 @@ test('extra source paths participate in the cache key and reach the compiler', a
expect(compiledPaths).toContainEqual(['swiftc', sourcePath, changedSupportPath]);
});

test('stale cache locks are removed before compiling', async () => {
test('cache locks with a proven dead owner are recovered before compiling', async () => {
const { sourcePath, executablePath, lockDir } = await createBlockedCacheEntry();
const staleTime = new Date(Date.now() - 1_000);
fs.utimesSync(lockDir, staleTime, staleTime);
fs.writeFileSync(
path.join(lockDir, 'owner.json'),
JSON.stringify({
pid: 999_999_999,
startTime: null,
acquiredAtMs: Date.now(),
}),
);

await expect(
compileSwiftSourceFile({
Expand All @@ -115,10 +121,10 @@ test('stale cache locks are removed before compiling', async () => {
expect(mockRunCmd).toHaveBeenCalledTimes(1);
});

test('cache lock timeout reports the lock path', async () => {
test('an ownerless cache lock is retained with verified-recovery guidance regardless of age', async () => {
const { sourcePath, lockDir } = await createBlockedCacheEntry();
const futureTime = new Date(Date.now() + 60_000);
fs.utimesSync(lockDir, futureTime, futureTime);
const old = new Date(Date.now() - 60_000);
fs.utimesSync(lockDir, old, old);

await expect(
compileSwiftSourceFile({
Expand All @@ -132,11 +138,12 @@ test('cache lock timeout reports the lock path', async () => {
details: {
lockDir,
timeoutMs: 1,
hint: expect.stringContaining(`remove "${lockDir}"`),
hint: expect.stringContaining('confirming all users of this state directory have stopped'),
},
});

expect(mockRunCmd).not.toHaveBeenCalled();
expect(fs.existsSync(lockDir)).toBe(true);
});

test('compileSwiftSourceText resolves a cache name with a long interior dash run in sub-second time', async () => {
Expand Down Expand Up @@ -228,15 +235,18 @@ async function expectConcurrentCacheReuse(compile: () => Promise<string>): Promi

const firstCompile = compile();
await compileStarted;
const originalMkdirSync = fs.mkdirSync;
const originalReadFileSync = fs.readFileSync;
const lockAttempted = new Promise<void>((resolve) => {
const mkdirSpy = vi.spyOn(fs, 'mkdirSync').mockImplementation((dirPath, options) => {
if (typeof dirPath === 'string' && dirPath.endsWith('.lock')) {
const readSpy = vi.spyOn(fs, 'readFileSync').mockImplementation(((
filePath: fs.PathOrFileDescriptor,
options?: Parameters<typeof fs.readFileSync>[1],
) => {
if (typeof filePath === 'string' && filePath.endsWith(`.lock${path.sep}owner.json`)) {
resolve();
mkdirSpy.mockRestore();
readSpy.mockRestore();
}
return originalMkdirSync(dirPath, options);
});
return originalReadFileSync(filePath, options);
}) as typeof fs.readFileSync);
});
const secondCompile = compile();

Expand Down
7 changes: 1 addition & 6 deletions packages/capture-kit/src/recording/swift-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,19 +204,14 @@ async function acquireSwiftCacheLock(
},
timeoutMs,
pollMs: LOCK_RETRY_DELAY_MS,
ownerGraceMs: timeoutMs,
description: `Swift cache lock: ${lockDir} (${timeoutMs}ms)`,
});
} catch (error) {
if (
error instanceof AppError &&
error.message.startsWith('Timed out waiting for Swift cache lock:')
) {
if (error instanceof AppError && error.details?.reason === 'process_lock_timeout') {
throw new AppError('COMMAND_FAILED', error.message, {
...error.details,
lockDir,
timeoutMs,
hint: `Another agent-device process may still be compiling this Swift helper. Retry shortly; if no agent-device process is active, remove "${lockDir}" and retry.`,
});
}
throw error;
Expand Down
6 changes: 6 additions & 0 deletions packages/host-kit/src/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,13 @@ export {
export { expandUserHomePath, resolveUserPath } from './internal/path-resolution.ts';
export {
acquireProcessLock,
acquireProcessLockAcquisition,
tryAcquireProcessLock,
inspectProcessLock,
withProcessLock,
type ProcessLockAcquisition,
type ProcessLockAttempt,
type ProcessLockInspection,
type ProcessLockOwner,
type ProcessLockRelease,
} from './internal/process-lock.ts';
29 changes: 29 additions & 0 deletions packages/host-kit/src/internal/legacy-process-lock.fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import fs from 'node:fs';

// Guard acquisition from c237027737bf4737e324adae2146ba63891152a3, before protocol hardening.
export function holdLegacyReclaimMutex(mutexPath: string, abandonedAfterMs: number): boolean {
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
fs.mkdirSync(mutexPath);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
if (!clearAbandonedReclaimMutex(mutexPath, abandonedAfterMs)) return false;
}
}
return false;
}

function clearAbandonedReclaimMutex(mutexPath: string, abandonedAfterMs: number): boolean {
let stats: fs.Stats;
try {
stats = fs.statSync(mutexPath);
} catch {
return true;
}
if (Date.now() - stats.mtimeMs < abandonedAfterMs) return false;
try {
fs.rmdirSync(mutexPath);
} catch {}
return true;
}
43 changes: 43 additions & 0 deletions packages/host-kit/src/internal/process-lock.fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import fs from 'node:fs';
import { vi } from 'vitest';
import { readProcessStartTime } from './host-process.ts';
import type { ProcessLockOwner } from './process-lock.ts';

export function currentProcessOwner(): ProcessLockOwner {
return {
pid: process.pid,
startTime: readProcessStartTime(process.pid),
acquiredAtMs: Date.now(),
};
}

export function stampDirectoryAbandoned(directory: string): void {
const abandoned = new Date(Date.now() - 60_000);
fs.utimesSync(directory, abandoned, abandoned);
}

/** A reclaim that finishes leaves neither a parked directory nor a mutex behind. */
export function listReclaimSiblings(directory: string): string[] {
return fs
.readdirSync(directory)
.filter((entry) => entry.includes('.reclaim'))
.sort();
}

/** Runs `action` the first time a contender opens the guard file, before the open itself. */
export function onFirstGuardOpen(mutexPath: string, action: () => void) {
let fired = false;
const realOpen = fs.openSync;
const spy = vi.spyOn(fs, 'openSync').mockImplementation(((
target: fs.PathLike,
flags: fs.OpenMode,
mode?: fs.Mode,
) => {
if (String(target) === mutexPath && !fired) {
fired = true;
action();
}
return realOpen(target, flags, mode);
}) as typeof fs.openSync);
return { fired: () => fired, restore: () => spy.mockRestore() };
}
Loading
Loading