Skip to content
Closed
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
32 changes: 29 additions & 3 deletions docs/adr/0030-process-lock-exclusion.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,32 @@ guard cannot constrain legacy code after its final check. The support boundary t
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.
refuse legacy files rather than automatically reclaiming them. A legacy daemon creates an
exclusive file; a hardened daemon creates a directory at that same path. The old acquisition
cannot unlink a directory, and the new acquisition retains an existing file.

The [registration tests](../../src/__tests__/daemon-registration-owner.test.ts) exercise real
children using the c237027737 legacy acquisition and the current owner. They cover an older
daemon already running, a hardened owner waiting to publish metadata, and concurrent startup.
The client refuses an older registration before signaling or changing it. This proves the daemon
cutover; it does not expand the host-kit mixed-protocol support contract. Older clients still
require the deployment controls above.

## Registration operations

[Shared retirement](../../src/daemon-registration-owner.ts) owns verified termination, protected
metadata inspection and removal, and release. Takeover, failed startup, replay cleanup, timeout
reset and manual stop await its result. Abandoned recovery uses the same protected retirement
sequence without signaling a live process. Daemon publication and shutdown use functions bound
to their acquired claim.

```mermaid
flowchart LR
C[Client lifecycle and timeout] --> R[Shared retirement]
M[Manual stop] --> R
P[Abandoned recovery and pruning] --> R
R --> L[Acquired registration claim]
D[Daemon startup and shutdown] --> O[Functions bound to own claim]
O --> L
L --> F[Protected metadata and reports]
```
162 changes: 161 additions & 1 deletion src/__tests__/daemon-registration-owner.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { afterEach, test, vi } from 'vitest';
import { readCurrentOwnerIdentity, isProcessAlive } from '@agent-device/host-kit/process';
import {
Expand All @@ -15,9 +16,18 @@ import { resolveDaemonPaths, type DaemonPaths } from '../daemon-resolution.ts';
import { readRegisteredDaemonOwnership } from '../daemon-registration.ts';
import { readDaemonShutdownReport } from '../daemon-shutdown-report.ts';
import { mkdtempForTestSync } from './test-utils/tmp-dir.ts';
import { registeredDaemonFixtureArgs } from './test-utils/registered-daemon-fixture.ts';
import {
registeredDaemonFixtureArgs,
spawnRegisteredDaemonFixture,
finishRegisteredDaemonFixture,
} from './test-utils/registered-daemon-fixture.ts';
import { spawnLegacyDaemonFixture } from './test-utils/legacy-daemon-fixture.ts';
import { ensureDaemon, resolveClientSettings } from '../daemon-client/daemon-client-lifecycle.ts';
import { inspectProcessLock } from '@agent-device/host-kit/file';
import { AppError } from '@agent-device/kernel/errors';
import { sleep } from '@agent-device/host-kit/retry';
import { stopDaemonProcess } from '../daemon-process.ts';
import { stopDaemon } from '../daemon/daemon-stop.ts';

const fields = {
socketPort: 4210,
Expand Down Expand Up @@ -465,3 +475,153 @@ async function finishPrivateTestDaemons(
}
fs.rmSync(paths.baseDir, { recursive: true, force: true });
}

async function waitForCutoverFixture(ready: () => boolean): Promise<void> {
for (let attempt = 0; attempt < 200; attempt += 1) {
if (ready()) return;
await sleep(10);
}
assert.fail('cutover fixture did not reach its barrier');
}

function legacyDisposition(paths: DaemonPaths): boolean {
return (
JSON.parse(fs.readFileSync(path.join(paths.baseDir, 'legacy-disposition.json'), 'utf8')) as {
acquired: boolean;
}
).acquired;
}

test('cutover refuses an already-running legacy daemon before signaling or changing registration', async () => {
const paths = resolveDaemonPaths(mkdtempForTestSync('agent-device-old-daemon-'));
const legacy = spawnLegacyDaemonFixture(paths);
try {
await waitForCutoverFixture(() => fs.existsSync(paths.infoPath));
const metadata = fs.readFileSync(paths.infoPath, 'utf8');
const lock = fs.readFileSync(paths.lockPath, 'utf8');
const contender = spawnRegisteredDaemonFixture(paths, fields, undefined);
let disposition: Awaited<typeof contender.exited> | undefined;
void contender.exited.then((result) => {
disposition = result;
});
await waitForCutoverFixture(
() => Boolean(disposition) || fs.existsSync(path.join(paths.baseDir, 'registration-held')),
);
assert.ok(disposition, 'a new daemon must refuse an occupied legacy file');
assert.equal(disposition.exitCode, DAEMON_STARTUP_EXIT_CODES.unproven);
await assert.rejects(
ensureDaemon(
resolveClientSettings({
session: 'default',
command: 'devices',
positionals: [],
flags: { stateDir: paths.baseDir },
}),
),
(error: unknown) => {
assert.ok(error instanceof AppError);
assert.equal(error.details?.reason, 'daemon_registration_unproven');
return true;
},
);
assert.equal(process.kill(legacy.pid, 0), true);
assert.equal(fs.readFileSync(paths.infoPath, 'utf8'), metadata);
assert.equal(fs.readFileSync(paths.lockPath, 'utf8'), lock);
} finally {
await legacy.stop();
await finishRegisteredDaemonFixture(paths.baseDir);
}
});

test('a legacy contender cannot unlink a hardened owner while it delays metadata publication', async () => {
const paths = resolveDaemonPaths(mkdtempForTestSync('agent-device-new-daemon-'));
const deferred = path.join(paths.baseDir, 'defer-publication');
fs.writeFileSync(deferred, 'wait');
const current = spawnRegisteredDaemonFixture(paths, fields, undefined);
let legacy: ReturnType<typeof spawnLegacyDaemonFixture> | undefined;
try {
await waitForCutoverFixture(() => fs.existsSync(path.join(paths.baseDir, 'registration-held')));
legacy = spawnLegacyDaemonFixture(paths);
await waitForCutoverFixture(() =>
fs.existsSync(path.join(paths.baseDir, 'legacy-disposition.json')),
Comment thread
thymikee marked this conversation as resolved.
);
assert.equal(legacyDisposition(paths), false);
await legacy.exited;
const claim = inspectProcessLock(paths.lockPath);
assert.equal(claim.state, 'held');
if (claim.state !== 'held') throw new Error('current owner lost its claim');
assert.equal(claim.owner.pid, current.pid);
assert.equal(process.kill(current.pid, 0), true);
assert.equal(fs.existsSync(paths.infoPath), false);
fs.unlinkSync(deferred);
await waitForCutoverFixture(() => fs.existsSync(paths.infoPath));
assert.equal(JSON.parse(fs.readFileSync(paths.infoPath, 'utf8')).pid, current.pid);
} finally {
await legacy?.stop();
await finishRegisteredDaemonFixture(paths.baseDir);
}
});

test('concurrent old and new daemon startup has one owner at the shared lock path', async () => {
const paths = resolveDaemonPaths(mkdtempForTestSync('agent-device-cutover-race-'));
const barrier = path.join(paths.baseDir, 'start');
const legacy = spawnLegacyDaemonFixture(paths, barrier);
const current = spawnRegisteredDaemonFixture(paths, fields, undefined, barrier);
let currentExited = false;
void current.exited.then(() => {
currentExited = true;
});
try {
await waitForCutoverFixture(
() =>
fs.existsSync(`${barrier}.ready-${legacy.pid}`) &&
fs.existsSync(`${barrier}.ready-${current.pid}`),
);
fs.writeFileSync(barrier, 'start');
await waitForCutoverFixture(
() =>
fs.existsSync(path.join(paths.baseDir, 'legacy-disposition.json')) &&
(currentExited || fs.existsSync(path.join(paths.baseDir, 'registration-held'))),
);
const oldAcquired = legacyDisposition(paths);
const claim = inspectProcessLock(paths.lockPath);
const newAcquired = fs.existsSync(path.join(paths.baseDir, 'registration-held'));
assert.equal(Number(oldAcquired) + Number(newAcquired), 1);
if (newAcquired) {
assert.ok(claim.state === 'held');
assert.equal(claim.owner.pid, current.pid);
}
if (oldAcquired)
assert.equal((await current.exited).exitCode, DAEMON_STARTUP_EXIT_CODES.unproven);
else await legacy.exited;
await waitForCutoverFixture(() => fs.existsSync(paths.infoPath));
assert.equal(
JSON.parse(fs.readFileSync(paths.infoPath, 'utf8')).pid,
newAcquired ? current.pid : legacy.pid,
);
} finally {
await legacy.stop();
await finishRegisteredDaemonFixture(paths.baseDir);
}
});

for (const mode of ['graceful', 'forced'] as const) {
test(`manual ${mode} stop awaits actual child exit and protected registration retirement`, async () => {
const paths = resolveDaemonPaths(mkdtempForTestSync('agent-device-manual-stop-'));
if (mode === 'forced') fs.writeFileSync(path.join(paths.baseDir, 'ignore-sigterm'), 'hold');
const child = spawnRegisteredDaemonFixture(paths, fields, undefined);
try {
await waitForFixtureFile(paths.infoPath);
const result = await stopDaemon({ paths, graceTimeoutMs: 30, killTimeoutMs: 1_000 });
assert.equal(result.stopped, true);
assert.equal(result.mode, mode);
assert.equal(result.cleanupConfidence, mode === 'forced' ? 'unknown' : 'known');
await child.exited;
assert.equal(fs.existsSync(paths.infoPath), false);
assert.equal(fs.existsSync(paths.lockPath), false);
assert.equal(fs.existsSync(paths.baseDir), true);
} finally {
await finishRegisteredDaemonFixture(paths.baseDir);
}
});
}
92 changes: 92 additions & 0 deletions src/__tests__/test-utils/legacy-daemon-fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { runCmdDetachedMonitored } from '@agent-device/host-kit/command';
import { readProcessStartTime } from '@agent-device/host-kit/process';
import { stopDaemonProcess } from '../../daemon-process.ts';
import type { DaemonPaths } from '../../daemon-resolution.ts';

// c237027737: server-lifecycle.ts readLockInfo/acquireDaemonLock/releaseDaemonLock.
const legacyLockProtocol = `
function readLockInfo(lockPath) {
if (!fs.existsSync(lockPath)) return null;
try {
const parsed = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
if (!Number.isInteger(parsed.pid) || parsed.pid <= 0) return null;
return parsed;
} catch {
return null;
}
}
function acquireDaemonLock(baseDir, lockPath, lockData) {
if (!fs.existsSync(baseDir)) fs.mkdirSync(baseDir, { recursive: true });
const payload = JSON.stringify(lockData, null, 2);
const tryWriteLock = () => {
try {
fs.writeFileSync(lockPath, payload, { flag: 'wx', mode: 0o600 });
return true;
} catch (error) {
if (error.code === 'EEXIST') return false;
throw error;
}
};
if (tryWriteLock()) return true;
const existing = readLockInfo(lockPath);
if (existing?.pid && existing.pid !== process.pid &&
isAgentDeviceDaemonProcess(existing.pid, existing.processStartTime)) return false;
try { fs.unlinkSync(lockPath); } catch {}
return tryWriteLock();
}
function releaseDaemonLock(lockPath) {
const existing = readLockInfo(lockPath);
if (existing && existing.pid !== process.pid) return;
try { if (fs.existsSync(lockPath)) fs.unlinkSync(lockPath); } catch {}
}
`;

export function spawnLegacyDaemonFixture(paths: DaemonPaths, acquisitionBarrier?: string) {
const codeDir = path.join(paths.baseDir, 'legacy');
const entry = path.join(codeDir, 'dist', 'src', 'internal', 'daemon.js');
fs.mkdirSync(path.dirname(entry), { recursive: true });
fs.writeFileSync(path.join(codeDir, 'package.json'), '{"type":"module"}');
const processUrl = new URL('../../daemon-process.ts', import.meta.url).href;
const hostProcessUrl = new URL('../../../packages/host-kit/src/process.ts', import.meta.url).href;
fs.writeFileSync(
entry,
`
import fs from 'node:fs';
import { isAgentDeviceDaemonProcess } from ${JSON.stringify(processUrl)};
import { readProcessStartTime } from ${JSON.stringify(hostProcessUrl)};
${legacyLockProtocol}
const paths = ${JSON.stringify(paths)};
const barrier = ${JSON.stringify(acquisitionBarrier)};
if (barrier) {
fs.writeFileSync(barrier + '.ready-' + process.pid, 'ready');
while (!fs.existsSync(barrier)) await new Promise(resolve => setTimeout(resolve, 10));
}
const identity = { pid: process.pid, processStartTime: readProcessStartTime(process.pid) };
const acquired = acquireDaemonLock(paths.baseDir, paths.lockPath, {
...identity, version: '0.21.20', startedAt: Date.now(),
});
fs.writeFileSync(paths.baseDir + '/legacy-disposition.json', JSON.stringify({ acquired }));
if (!acquired) process.exit(0);
fs.writeFileSync(paths.infoPath, JSON.stringify({ ...identity, port: 4210, token: 'legacy-token', version: '0.21.20' }));
process.on('SIGTERM', () => { releaseDaemonLock(paths.lockPath); process.exit(0); });
setInterval(() => {}, 1000);
`,
);
const child = runCmdDetachedMonitored(process.execPath, ['--experimental-strip-types', entry]);
const startTime = readProcessStartTime(child.pid);
return {
pid: child.pid,
exited: child.exited,
async stop() {
const result = await stopDaemonProcess(
{ pid: child.pid, startTime },
{ mode: 'force', termTimeoutMs: 0, killTimeoutMs: 2_000 },
);
assert.notEqual(result.status, 'retained', JSON.stringify(result));
await child.exited;
},
};
}
10 changes: 9 additions & 1 deletion src/__tests__/test-utils/registered-daemon-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const children = new Map<
export function registeredDaemonFixtureArgs(
paths: DaemonPaths,
fields: DaemonRegistrationFields,
acquisitionBarrier?: string,
): string[] {
const entry = path.join(paths.baseDir, 'dist', 'src', 'internal', 'daemon.js');
fs.mkdirSync(path.dirname(entry), { recursive: true });
Expand All @@ -31,9 +32,15 @@ export function registeredDaemonFixtureArgs(
import path from 'node:path';
import { DAEMON_STARTUP_EXIT_CODES, tryAcquireDaemonRegistration } from ${JSON.stringify(registrationUrl)};
const paths = ${JSON.stringify(paths)};
const barrier = ${JSON.stringify(acquisitionBarrier)};
if (barrier) {
fs.writeFileSync(barrier + '.ready-' + process.pid, 'ready');
while (!fs.existsSync(barrier)) await new Promise(resolve => setTimeout(resolve, 10));
}
const acquired = await tryAcquireDaemonRegistration(paths);
if (acquired.status !== 'acquired') process.exit(DAEMON_STARTUP_EXIT_CODES[acquired.status]);
process.on('SIGTERM', async () => {
if (fs.existsSync(path.join(paths.baseDir, 'ignore-sigterm'))) return;
const deferred = path.join(paths.baseDir, 'repair-on-shutdown.json');
if (fs.existsSync(deferred)) {
const dir = path.join(paths.sessionsDir, 'default');
Expand All @@ -56,10 +63,11 @@ export function spawnRegisteredDaemonFixture(
paths: DaemonPaths,
fields: DaemonRegistrationFields,
options: Parameters<typeof runCmdDetachedMonitored>[2],
acquisitionBarrier?: string,
): ReturnType<typeof runCmdDetachedMonitored> {
const child = actualCommand.runCmdDetachedMonitored(
process.execPath,
registeredDaemonFixtureArgs(paths, fields),
registeredDaemonFixtureArgs(paths, fields, acquisitionBarrier),
options,
);
const owned = children.get(paths.baseDir) ?? [];
Expand Down
Loading
Loading