Skip to content
Open
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
10 changes: 6 additions & 4 deletions docs/adr/0021-host-simlock-managed-device-allocation.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,10 +110,12 @@ authorization and attribution around the same boundary. Plain local device selec

Before acquisition, agent-device durably records a non-authoritative allocation operation: the
logical requester, idempotency key, immutable shape request, deadline, and Host attribution when
applicable. After Simlock responds, it records the allocator handle/outcome and whether Host
published or cleaned it. This journal exists only to recover the Host-to-Simlock handoff. It never
mirrors Simlock's queue, provisioning, lease, cleanup, health, or capacity states, and it never
decides whether a device is reusable.
applicable. After Simlock responds, it records the allocator handle/outcome. Before invoking an
external Host binding publisher, it durably records a pending publication; publication success is
then recorded separately, and recovery conservatively cleans a pending or uncertain binding before
releasing the allocator lease. This journal exists only to recover the Host-to-Simlock handoff. It
never mirrors Simlock's queue, provisioning, lease, cleanup, health, or capacity states, and it
never decides whether a device is reusable.

Each logical requester is a restart-stable allocation lane; concurrent leases use distinct lanes.
Replaying the same attempt key returns the same durable outcome, including a refusal. Disconnect,
Expand Down
1 change: 1 addition & 0 deletions packages/capture-kit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export { decodeDurableDescriptor } from './durable-descriptor-codec.ts';
export { createScreenRecordingLiveHandle } from './screen-recording-live-handle.ts';
export { createScreenRecordingCompletion } from './screen-recording-completion.ts';
export { assertScreenRecordingOptionsSupported } from './screen-recording-options.ts';
export { freezeJsonObject, isBoundedJsonObject } from './durable-json.ts';
export {
cleanupManagedAppLogProcess,
reattachCleanupOnlyAppLogProcess,
Expand Down
3 changes: 2 additions & 1 deletion packages/host-kit/src/file.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
export {
isAtomicPublishTemporaryPath,
publishDurableFileSync,
publishFileSync,
withAtomicPublishTempPathSync,
type DurableFilePublishMode,
} from './internal/atomic-file.ts';
export {
lstatIfPresent,
Expand Down
83 changes: 83 additions & 0 deletions packages/host-kit/src/internal/atomic-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,52 @@ export function publishFileSync(options: {
});
}

export type DurableFilePublishMode = 'replace' | 'link-exclusive';

/** Publishes complete UTF-8 contents with a durable file and directory fence. */
export function publishDurableFileSync(options: {
destination: string;
contents: string;
mode?: number;
publish?: DurableFilePublishMode;
}): void {
const directory = path.dirname(options.destination);
withAtomicPublishTempPathSync(options.destination, (temporaryPath) => {
let descriptor: number | undefined;
let failed = false;
let primaryError: unknown;
try {
assertSafeDestination(options.destination);
descriptor = fs.openSync(temporaryPath, 'wx', options.mode ?? 0o600);
fs.writeFileSync(descriptor, options.contents, 'utf8');
fs.fsyncSync(descriptor);
fs.closeSync(descriptor);
descriptor = undefined;
assertSafeDestination(options.destination);
if (options.publish === 'link-exclusive') {
fs.linkSync(temporaryPath, options.destination);
} else {
fs.renameSync(temporaryPath, options.destination);
}
syncDirectoryBestEffort(directory);
} catch (error) {
failed = true;
primaryError = error;
}
if (descriptor !== undefined) {
try {
fs.closeSync(descriptor);
} catch (error) {
if (!failed) {
failed = true;
primaryError = error;
}
}
}
if (failed) throw primaryError;
});
}

/**
* Gives a specialized durable publisher a canonical temp path and cleanup
* ownership while it performs its own open/fsync/safety protocol.
Expand Down Expand Up @@ -63,6 +109,22 @@ export function withAtomicPublishTempPathSync<T>(
}
}

/** Syncs a containing directory when the host filesystem supports directory fsync. */
function syncDirectoryBestEffort(directory: string): void {
let descriptor: number | undefined;
try {
descriptor = fs.openSync(directory, 'r');
fs.fsyncSync(descriptor);
} catch {
} finally {
if (descriptor !== undefined) {
try {
fs.closeSync(descriptor);
} catch {}
}
}
}

/** Returns the canonical same-directory temp path used by atomic publishers. */
function createAtomicPublishTempPath(destination: string): string {
return path.join(
Expand All @@ -78,3 +140,24 @@ export function isAtomicPublishTemporaryPath(value: unknown, destination: string
const name = path.basename(value);
return name.startsWith(`.${path.basename(destination)}.`) && name.endsWith('.tmp');
}

function assertSafeDestination(destination: string): void {
const stats = lstatIfPresent(destination);
if (stats?.isSymbolicLink()) {
throw new Error(`Refusing to replace a durable file symbolic link: ${destination}`);
}
if (stats && !stats.isFile()) {
throw new Error(
`Refusing to replace a durable path that is not a regular file: ${destination}`,
);
}
}

function lstatIfPresent(pathname: string): fs.Stats | undefined {
try {
return fs.lstatSync(pathname);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
throw error;
}
}
122 changes: 122 additions & 0 deletions packages/host-kit/src/internal/durable-file.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, expect, test, vi } from 'vitest';
import { isAtomicPublishTemporaryPath, publishDurableFileSync } from './atomic-file.ts';

const roots: string[] = [];

afterEach(() => {
vi.restoreAllMocks();
for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true });
});

test('fsyncs complete contents before publication and uses the requested mode', () => {
const root = fixtureRoot('ordering');
const destination = path.join(root, 'record.json');
const events: string[] = [];
const realFsync = fs.fsyncSync;
const realRename = fs.renameSync;
vi.spyOn(fs, 'fsyncSync').mockImplementation((descriptor) => {
events.push('fsync');
return realFsync(descriptor);
});
vi.spyOn(fs, 'renameSync').mockImplementation((source, target) => {
events.push('publish');
return realRename(source, target);
});

publishDurableFileSync({ destination, contents: 'durable\n', mode: 0o640 });

expect(fs.readFileSync(destination, 'utf8')).toBe('durable\n');
expect(fs.statSync(destination).mode & 0o777).toBe(0o640);
expect(events.indexOf('fsync')).toBeGreaterThanOrEqual(0);
expect(events.indexOf('fsync')).toBeLessThan(events.indexOf('publish'));
expect(temporaryPaths(root, destination)).toEqual([]);
});

test.each(['symbolic link', 'non-regular path'] as const)(
'refuses a final %s and removes the temporary file',
(kind) => {
const root = fixtureRoot(kind);
const destination = path.join(root, 'record.json');
if (kind === 'symbolic link') {
const outside = path.join(root, 'outside.json');
fs.writeFileSync(outside, 'outside');
fs.symlinkSync(outside, destination);
} else {
fs.mkdirSync(destination);
}

expect(() => publishDurableFileSync({ destination, contents: 'replacement' })).toThrow(
kind === 'symbolic link' ? /symbolic link/ : /not a regular file/,
);
expect(temporaryPaths(root, destination)).toEqual([]);
},
);

test('keeps an existing destination on link-exclusive publication failure', () => {
const root = fixtureRoot('exclusive');
const destination = path.join(root, 'record.json');
fs.writeFileSync(destination, 'original');

expect(() =>
publishDurableFileSync({
destination,
contents: 'replacement',
publish: 'link-exclusive',
}),
).toThrow(/EEXIST/);
expect(fs.readFileSync(destination, 'utf8')).toBe('original');
expect(temporaryPaths(root, destination)).toEqual([]);
});

test('preserves the publication error while cleaning the temporary file', () => {
const root = fixtureRoot('publish-error');
const destination = path.join(root, 'record.json');
const primary = new Error('publication failed');
vi.spyOn(fs, 'renameSync').mockImplementation(() => {
throw primary;
});

assert.throws(
() => publishDurableFileSync({ destination, contents: 'durable' }),
(error: unknown) => error === primary,
);
expect(temporaryPaths(root, destination)).toEqual([]);
});

test('preserves a file fsync error when descriptor cleanup also fails', () => {
const root = fixtureRoot('close-error');
const destination = path.join(root, 'record.json');
const primary = new Error('file fsync failed');
const secondary = new Error('descriptor close failed');
const realClose = fs.closeSync;
vi.spyOn(fs, 'fsyncSync').mockImplementation(() => {
throw primary;
});
vi.spyOn(fs, 'closeSync').mockImplementation((descriptor) => {
realClose(descriptor);
throw secondary;
});

assert.throws(
() => publishDurableFileSync({ destination, contents: 'durable' }),
(error: unknown) => error === primary,
);
expect(temporaryPaths(root, destination)).toEqual([]);
});

function fixtureRoot(label: string): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `agent-device-durable-file-${label}-`));
roots.push(root);
return root;
}

function temporaryPaths(root: string, destination: string): string[] {
return fs
.readdirSync(root)
.map((name) => path.join(root, name))
.filter((pathname) => isAtomicPublishTemporaryPath(pathname, destination));
}
22 changes: 13 additions & 9 deletions src/daemon/__tests__/atomic-publish-ownership.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,18 @@ test('simple same-directory publishers use the shared atomic publish owner', ()
}
});

test('durable capture publication keeps its specialized fsync and destination checks', () => {
const source = fs.readFileSync(
test('durable publishers share the host-kit durable publication owner', () => {
const sourcePaths = [
new URL('../durable-capture-resource-store.ts', import.meta.url),
'utf8',
);
assert.match(source, /withAtomicPublishTempPathSync/);
assert.match(source, /fs\.openSync\([^\n]+['"]wx['"]/);
assert.match(source, /fs\.fsyncSync/);
assert.match(source, /fs\.renameSync/);
assert.match(source, /assertSafeDestination/);
new URL('../managed-device-allocation/store-filesystem.ts', import.meta.url),
];
for (const sourcePath of sourcePaths) {
const source = fs.readFileSync(sourcePath, 'utf8');
assert.match(source, /publishDurableFileSync/);
assert.doesNotMatch(
source,
/fs\.(?:openSync|writeFileSync|fsyncSync|renameSync|linkSync)\s*\(/,
);
assert.doesNotMatch(source, /assertSafeDestination/);
}
});
1 change: 1 addition & 0 deletions src/daemon/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ test('resolveDaemonPaths keeps explicit state directories authoritative', () =>
try {
const paths = resolveDaemonPaths('~/custom-daemon', { env: { HOME: home } });
assert.equal(paths.baseDir, path.join(home, 'custom-daemon'));
assert.equal(paths.allocationsDir, path.join(home, 'custom-daemon', 'allocations'));
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ function daemonPaths(baseDir: string): DaemonPaths {
infoPath: path.join(baseDir, 'daemon.json'),
lockPath: path.join(baseDir, 'daemon.lock'),
logPath: path.join(baseDir, 'daemon.log'),
allocationsDir: path.join(baseDir, 'allocations'),
sessionsDir: path.join(baseDir, 'sessions'),
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ function daemonPaths(): DaemonPaths {
infoPath: path.join(baseDir, 'daemon.json'),
lockPath: path.join(baseDir, 'daemon.lock'),
logPath: path.join(baseDir, 'daemon.log'),
allocationsDir: path.join(baseDir, 'allocations'),
sessionsDir: path.join(baseDir, 'sessions'),
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ function dummyStatePaths(): DaemonPaths {
infoPath: path.join(baseDir, 'daemon.json'),
lockPath: path.join(baseDir, 'daemon.lock'),
logPath: path.join(baseDir, 'daemon.log'),
allocationsDir: path.join(baseDir, 'allocations'),
sessionsDir: path.join(baseDir, 'sessions'),
};
}
Expand Down
2 changes: 2 additions & 0 deletions src/daemon/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export type DaemonPaths = {
infoPath: string;
lockPath: string;
logPath: string;
allocationsDir: string;
sessionsDir: string;
};

Expand All @@ -35,6 +36,7 @@ export function resolveDaemonPaths(
infoPath: path.join(baseDir, 'daemon.json'),
lockPath: path.join(baseDir, 'daemon.lock'),
logPath: path.join(baseDir, 'daemon.log'),
allocationsDir: path.join(baseDir, 'allocations'),
sessionsDir: path.join(baseDir, 'sessions'),
};
}
Expand Down
Loading
Loading