Skip to content

Commit ba4a5c2

Browse files
committed
fix: share manual daemon retirement and verify lock cutover
1 parent 2afb5ec commit ba4a5c2

6 files changed

Lines changed: 400 additions & 78 deletions

File tree

‎docs/adr/0030-process-lock-exclusion.md‎

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,32 @@ guard cannot constrain legacy code after its final check. The support boundary t
3636
requires deployment control; host-kit does not claim to detect or evict every legacy user.
3737

3838
Daemon registration has a separate cutover boundary: keep the same `daemon.lock` path and
39-
refuse legacy files rather than automatically reclaiming them. Its startup tests must cover
40-
an already-running older daemon and concurrent old/new startup. This does not expand the
41-
host-kit mixed-protocol support contract.
39+
refuse legacy files rather than automatically reclaiming them. A legacy daemon creates an
40+
exclusive file; a hardened daemon creates a directory at that same path. The old acquisition
41+
cannot unlink a directory, and the new acquisition retains an existing file.
42+
43+
The [registration tests](../../src/__tests__/daemon-registration-owner.test.ts) exercise real
44+
children using the c237027737 legacy acquisition and the current owner. They cover an older
45+
daemon already running, a hardened owner waiting to publish metadata, and concurrent startup.
46+
The client refuses an older registration before signaling or changing it. This proves the daemon
47+
cutover; it does not expand the host-kit mixed-protocol support contract. Older clients still
48+
require the deployment controls above.
49+
50+
## Registration operations
51+
52+
[Shared retirement](../../src/daemon-registration-owner.ts) owns verified termination, protected
53+
metadata inspection and removal, and release. Takeover, failed startup, replay cleanup, timeout
54+
reset and manual stop await its result. Abandoned recovery uses the same protected retirement
55+
sequence without signaling a live process. Daemon publication and shutdown use functions bound
56+
to their acquired claim.
57+
58+
```mermaid
59+
flowchart LR
60+
C[Client lifecycle and timeout] --> R[Shared retirement]
61+
M[Manual stop] --> R
62+
P[Abandoned recovery and pruning] --> R
63+
R --> L[Acquired registration claim]
64+
D[Daemon startup and shutdown] --> O[Functions bound to own claim]
65+
O --> L
66+
L --> F[Protected metadata and reports]
67+
```

‎src/__tests__/daemon-registration-owner.test.ts‎

Lines changed: 158 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import assert from 'node:assert/strict';
22
import fs from 'node:fs';
3+
import path from 'node:path';
34
import { afterEach, test, vi } from 'vitest';
45
import { readCurrentOwnerIdentity, isProcessAlive } from '@agent-device/host-kit/process';
56
import {
@@ -15,9 +16,18 @@ import { resolveDaemonPaths, type DaemonPaths } from '../daemon-resolution.ts';
1516
import { readRegisteredDaemonOwnership } from '../daemon-registration.ts';
1617
import { readDaemonShutdownReport } from '../daemon-shutdown-report.ts';
1718
import { mkdtempForTestSync } from './test-utils/tmp-dir.ts';
18-
import { registeredDaemonFixtureArgs } from './test-utils/registered-daemon-fixture.ts';
19+
import {
20+
registeredDaemonFixtureArgs,
21+
spawnRegisteredDaemonFixture,
22+
finishRegisteredDaemonFixture,
23+
} from './test-utils/registered-daemon-fixture.ts';
24+
import { spawnLegacyDaemonFixture } from './test-utils/legacy-daemon-fixture.ts';
25+
import { ensureDaemon, resolveClientSettings } from '../daemon-client/daemon-client-lifecycle.ts';
26+
import { inspectProcessLock } from '@agent-device/host-kit/file';
27+
import { AppError } from '@agent-device/kernel/errors';
1928
import { sleep } from '@agent-device/host-kit/retry';
2029
import { stopDaemonProcess } from '../daemon-process.ts';
30+
import { stopDaemon } from '../daemon/daemon-stop.ts';
2131

2232
const fields = {
2333
socketPort: 4210,
@@ -451,3 +461,150 @@ async function finishPrivateTestDaemons(
451461
}
452462
fs.rmSync(paths.baseDir, { recursive: true, force: true });
453463
}
464+
465+
async function waitForCutoverFixture(ready: () => boolean): Promise<void> {
466+
for (let attempt = 0; attempt < 200; attempt += 1) {
467+
if (ready()) return;
468+
await sleep(10);
469+
}
470+
assert.fail('cutover fixture did not reach its barrier');
471+
}
472+
473+
function legacyDisposition(paths: DaemonPaths): boolean {
474+
return (
475+
JSON.parse(fs.readFileSync(path.join(paths.baseDir, 'legacy-disposition.json'), 'utf8')) as {
476+
acquired: boolean;
477+
}
478+
).acquired;
479+
}
480+
481+
test('cutover refuses an already-running legacy daemon before signaling or changing registration', async () => {
482+
const paths = resolveDaemonPaths(mkdtempForTestSync('agent-device-old-daemon-'));
483+
const legacy = spawnLegacyDaemonFixture(paths);
484+
try {
485+
await waitForCutoverFixture(() => fs.existsSync(paths.infoPath));
486+
const metadata = fs.readFileSync(paths.infoPath, 'utf8');
487+
const lock = fs.readFileSync(paths.lockPath, 'utf8');
488+
const contender = spawnRegisteredDaemonFixture(paths, fields, undefined);
489+
let disposition: Awaited<typeof contender.exited> | undefined;
490+
void contender.exited.then((result) => {
491+
disposition = result;
492+
});
493+
await waitForCutoverFixture(
494+
() => Boolean(disposition) || fs.existsSync(path.join(paths.baseDir, 'registration-held')),
495+
);
496+
assert.ok(disposition, 'a new daemon must refuse an occupied legacy file');
497+
assert.equal(disposition.exitCode, DAEMON_STARTUP_EXIT_CODES.unproven);
498+
await assert.rejects(
499+
ensureDaemon(
500+
resolveClientSettings({
501+
session: 'default',
502+
command: 'devices',
503+
positionals: [],
504+
flags: { stateDir: paths.baseDir },
505+
}),
506+
),
507+
(error: unknown) => {
508+
assert.ok(error instanceof AppError);
509+
assert.equal(error.details?.reason, 'daemon_registration_unproven');
510+
return true;
511+
},
512+
);
513+
assert.equal(process.kill(legacy.pid, 0), true);
514+
assert.equal(fs.readFileSync(paths.infoPath, 'utf8'), metadata);
515+
assert.equal(fs.readFileSync(paths.lockPath, 'utf8'), lock);
516+
} finally {
517+
await legacy.stop();
518+
await finishRegisteredDaemonFixture(paths.baseDir);
519+
}
520+
});
521+
522+
test('a legacy contender cannot unlink a hardened owner while it delays metadata publication', async () => {
523+
const paths = resolveDaemonPaths(mkdtempForTestSync('agent-device-new-daemon-'));
524+
const deferred = path.join(paths.baseDir, 'defer-publication');
525+
fs.writeFileSync(deferred, 'wait');
526+
const current = spawnRegisteredDaemonFixture(paths, fields, undefined);
527+
let legacy: ReturnType<typeof spawnLegacyDaemonFixture> | undefined;
528+
try {
529+
await waitForCutoverFixture(() => fs.existsSync(path.join(paths.baseDir, 'registration-held')));
530+
legacy = spawnLegacyDaemonFixture(paths);
531+
await legacy.exited;
532+
assert.equal(legacyDisposition(paths), false);
533+
const claim = inspectProcessLock(paths.lockPath);
534+
assert.equal(claim.state, 'held');
535+
if (claim.state !== 'held') throw new Error('current owner lost its claim');
536+
assert.equal(claim.owner.pid, current.pid);
537+
assert.equal(process.kill(current.pid, 0), true);
538+
assert.equal(fs.existsSync(paths.infoPath), false);
539+
fs.unlinkSync(deferred);
540+
await waitForCutoverFixture(() => fs.existsSync(paths.infoPath));
541+
assert.equal(JSON.parse(fs.readFileSync(paths.infoPath, 'utf8')).pid, current.pid);
542+
} finally {
543+
await legacy?.stop();
544+
await finishRegisteredDaemonFixture(paths.baseDir);
545+
}
546+
});
547+
548+
test('concurrent old and new daemon startup has one owner at the shared lock path', async () => {
549+
const paths = resolveDaemonPaths(mkdtempForTestSync('agent-device-cutover-race-'));
550+
const barrier = path.join(paths.baseDir, 'start');
551+
const legacy = spawnLegacyDaemonFixture(paths, barrier);
552+
const current = spawnRegisteredDaemonFixture(paths, fields, undefined, barrier);
553+
let currentExited = false;
554+
void current.exited.then(() => {
555+
currentExited = true;
556+
});
557+
try {
558+
await waitForCutoverFixture(
559+
() =>
560+
fs.existsSync(`${barrier}.ready-${legacy.pid}`) &&
561+
fs.existsSync(`${barrier}.ready-${current.pid}`),
562+
);
563+
fs.writeFileSync(barrier, 'start');
564+
await waitForCutoverFixture(
565+
() =>
566+
fs.existsSync(path.join(paths.baseDir, 'legacy-disposition.json')) &&
567+
(currentExited || fs.existsSync(path.join(paths.baseDir, 'registration-held'))),
568+
);
569+
const oldAcquired = legacyDisposition(paths);
570+
const claim = inspectProcessLock(paths.lockPath);
571+
const newAcquired = fs.existsSync(path.join(paths.baseDir, 'registration-held'));
572+
assert.equal(Number(oldAcquired) + Number(newAcquired), 1);
573+
if (newAcquired) {
574+
assert.ok(claim.state === 'held');
575+
assert.equal(claim.owner.pid, current.pid);
576+
}
577+
if (oldAcquired)
578+
assert.equal((await current.exited).exitCode, DAEMON_STARTUP_EXIT_CODES.unproven);
579+
else await legacy.exited;
580+
await waitForCutoverFixture(() => fs.existsSync(paths.infoPath));
581+
assert.equal(
582+
JSON.parse(fs.readFileSync(paths.infoPath, 'utf8')).pid,
583+
newAcquired ? current.pid : legacy.pid,
584+
);
585+
} finally {
586+
await legacy.stop();
587+
await finishRegisteredDaemonFixture(paths.baseDir);
588+
}
589+
});
590+
591+
for (const mode of ['graceful', 'forced'] as const) {
592+
test(`manual ${mode} stop awaits actual child exit and protected registration retirement`, async () => {
593+
const paths = resolveDaemonPaths(mkdtempForTestSync('agent-device-manual-stop-'));
594+
if (mode === 'forced') fs.writeFileSync(path.join(paths.baseDir, 'ignore-sigterm'), 'hold');
595+
const child = spawnRegisteredDaemonFixture(paths, fields, undefined);
596+
try {
597+
await waitForFixtureFile(paths.infoPath);
598+
const result = await stopDaemon({ paths, graceTimeoutMs: 30, killTimeoutMs: 1_000 });
599+
assert.equal(result.stopped, true);
600+
assert.equal(result.mode, mode);
601+
assert.equal(result.cleanupConfidence, mode === 'forced' ? 'unknown' : 'known');
602+
await child.exited;
603+
assert.equal(fs.existsSync(paths.infoPath), false);
604+
assert.equal(fs.existsSync(paths.lockPath), false);
605+
assert.equal(fs.existsSync(paths.baseDir), true);
606+
} finally {
607+
await finishRegisteredDaemonFixture(paths.baseDir);
608+
}
609+
});
610+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import assert from 'node:assert/strict';
2+
import fs from 'node:fs';
3+
import path from 'node:path';
4+
import { runCmdDetachedMonitored } from '@agent-device/host-kit/command';
5+
import { readProcessStartTime } from '@agent-device/host-kit/process';
6+
import { stopDaemonProcess } from '../../daemon-process.ts';
7+
import type { DaemonPaths } from '../../daemon-resolution.ts';
8+
9+
// c237027737: server-lifecycle.ts readLockInfo/acquireDaemonLock/releaseDaemonLock.
10+
const legacyLockProtocol = `
11+
function readLockInfo(lockPath) {
12+
if (!fs.existsSync(lockPath)) return null;
13+
try {
14+
const parsed = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
15+
if (!Number.isInteger(parsed.pid) || parsed.pid <= 0) return null;
16+
return parsed;
17+
} catch {
18+
return null;
19+
}
20+
}
21+
function acquireDaemonLock(baseDir, lockPath, lockData) {
22+
if (!fs.existsSync(baseDir)) fs.mkdirSync(baseDir, { recursive: true });
23+
const payload = JSON.stringify(lockData, null, 2);
24+
const tryWriteLock = () => {
25+
try {
26+
fs.writeFileSync(lockPath, payload, { flag: 'wx', mode: 0o600 });
27+
return true;
28+
} catch (error) {
29+
if (error.code === 'EEXIST') return false;
30+
throw error;
31+
}
32+
};
33+
if (tryWriteLock()) return true;
34+
const existing = readLockInfo(lockPath);
35+
if (existing?.pid && existing.pid !== process.pid &&
36+
isAgentDeviceDaemonProcess(existing.pid, existing.processStartTime)) return false;
37+
try { fs.unlinkSync(lockPath); } catch {}
38+
return tryWriteLock();
39+
}
40+
function releaseDaemonLock(lockPath) {
41+
const existing = readLockInfo(lockPath);
42+
if (existing && existing.pid !== process.pid) return;
43+
try { if (fs.existsSync(lockPath)) fs.unlinkSync(lockPath); } catch {}
44+
}
45+
`;
46+
47+
export function spawnLegacyDaemonFixture(paths: DaemonPaths, acquisitionBarrier?: string) {
48+
const codeDir = path.join(paths.baseDir, 'legacy');
49+
const entry = path.join(codeDir, 'dist', 'src', 'internal', 'daemon.js');
50+
fs.mkdirSync(path.dirname(entry), { recursive: true });
51+
fs.writeFileSync(path.join(codeDir, 'package.json'), '{"type":"module"}');
52+
const processUrl = new URL('../../daemon-process.ts', import.meta.url).href;
53+
const hostProcessUrl = new URL('../../../packages/host-kit/src/process.ts', import.meta.url).href;
54+
fs.writeFileSync(
55+
entry,
56+
`
57+
import fs from 'node:fs';
58+
import { isAgentDeviceDaemonProcess } from ${JSON.stringify(processUrl)};
59+
import { readProcessStartTime } from ${JSON.stringify(hostProcessUrl)};
60+
${legacyLockProtocol}
61+
const paths = ${JSON.stringify(paths)};
62+
const barrier = ${JSON.stringify(acquisitionBarrier)};
63+
if (barrier) {
64+
fs.writeFileSync(barrier + '.ready-' + process.pid, 'ready');
65+
while (!fs.existsSync(barrier)) await new Promise(resolve => setTimeout(resolve, 10));
66+
}
67+
const identity = { pid: process.pid, processStartTime: readProcessStartTime(process.pid) };
68+
const acquired = acquireDaemonLock(paths.baseDir, paths.lockPath, {
69+
...identity, version: '0.21.20', startedAt: Date.now(),
70+
});
71+
fs.writeFileSync(paths.baseDir + '/legacy-disposition.json', JSON.stringify({ acquired }));
72+
if (!acquired) process.exit(0);
73+
fs.writeFileSync(paths.infoPath, JSON.stringify({ ...identity, port: 4210, token: 'legacy-token', version: '0.21.20' }));
74+
process.on('SIGTERM', () => { releaseDaemonLock(paths.lockPath); process.exit(0); });
75+
setInterval(() => {}, 1000);
76+
`,
77+
);
78+
const child = runCmdDetachedMonitored(process.execPath, ['--experimental-strip-types', entry]);
79+
const startTime = readProcessStartTime(child.pid);
80+
return {
81+
pid: child.pid,
82+
exited: child.exited,
83+
async stop() {
84+
const result = await stopDaemonProcess(
85+
{ pid: child.pid, startTime },
86+
{ mode: 'force', termTimeoutMs: 0, killTimeoutMs: 2_000 },
87+
);
88+
assert.notEqual(result.status, 'retained', JSON.stringify(result));
89+
await child.exited;
90+
},
91+
};
92+
}

‎src/__tests__/test-utils/registered-daemon-fixture.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const children = new Map<
2020
export function registeredDaemonFixtureArgs(
2121
paths: DaemonPaths,
2222
fields: DaemonRegistrationFields,
23+
acquisitionBarrier?: string,
2324
): string[] {
2425
const entry = path.join(paths.baseDir, 'dist', 'src', 'internal', 'daemon.js');
2526
fs.mkdirSync(path.dirname(entry), { recursive: true });
@@ -31,9 +32,15 @@ export function registeredDaemonFixtureArgs(
3132
import path from 'node:path';
3233
import { DAEMON_STARTUP_EXIT_CODES, tryAcquireDaemonRegistration } from ${JSON.stringify(registrationUrl)};
3334
const paths = ${JSON.stringify(paths)};
35+
const barrier = ${JSON.stringify(acquisitionBarrier)};
36+
if (barrier) {
37+
fs.writeFileSync(barrier + '.ready-' + process.pid, 'ready');
38+
while (!fs.existsSync(barrier)) await new Promise(resolve => setTimeout(resolve, 10));
39+
}
3440
const acquired = await tryAcquireDaemonRegistration(paths);
3541
if (acquired.status !== 'acquired') process.exit(DAEMON_STARTUP_EXIT_CODES[acquired.status]);
3642
process.on('SIGTERM', async () => {
43+
if (fs.existsSync(path.join(paths.baseDir, 'ignore-sigterm'))) return;
3744
const deferred = path.join(paths.baseDir, 'repair-on-shutdown.json');
3845
if (fs.existsSync(deferred)) {
3946
const dir = path.join(paths.sessionsDir, 'default');
@@ -56,10 +63,11 @@ export function spawnRegisteredDaemonFixture(
5663
paths: DaemonPaths,
5764
fields: DaemonRegistrationFields,
5865
options: Parameters<typeof runCmdDetachedMonitored>[2],
66+
acquisitionBarrier?: string,
5967
): ReturnType<typeof runCmdDetachedMonitored> {
6068
const child = actualCommand.runCmdDetachedMonitored(
6169
process.execPath,
62-
registeredDaemonFixtureArgs(paths, fields),
70+
registeredDaemonFixtureArgs(paths, fields, acquisitionBarrier),
6371
options,
6472
);
6573
const owned = children.get(paths.baseDir) ?? [];

0 commit comments

Comments
 (0)