|
| 1 | +import assert from 'node:assert/strict'; |
| 2 | +import fs from 'node:fs'; |
| 3 | +import { afterEach, test, vi } from 'vitest'; |
| 4 | +import { readCurrentOwnerIdentity, isProcessAlive } from '@agent-device/host-kit/process'; |
| 5 | +import { |
| 6 | + tryAcquireDaemonRegistration, |
| 7 | + stopAndRetireDaemon, |
| 8 | +} from '@agent-device/daemon-contracts/daemon-registration-owner'; |
| 9 | +import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; |
| 10 | +import { |
| 11 | + readDaemonShutdownCompletion, |
| 12 | + resolveDaemonShutdownCompletionPath, |
| 13 | +} from '@agent-device/daemon-contracts/daemon-shutdown-report'; |
| 14 | +import { mkdtempForTestSync } from '@agent-device/test-kit'; |
| 15 | + |
| 16 | +// What the registration owner may do to a record a host process owns: prove the lifetime is gone |
| 17 | +// before removing anything, and never signal the process the record names. |
| 18 | + |
| 19 | +const fields = { |
| 20 | + socketPort: 4210, |
| 21 | + token: 'token', |
| 22 | + version: '0.0.0-test', |
| 23 | + codeOrigin: 'checkout' as const, |
| 24 | + codeSignature: 'signature', |
| 25 | +}; |
| 26 | +const ownIdentity = readCurrentOwnerIdentity(); |
| 27 | +const deadIdentity = { pid: 999_999_999, startTime: 'dead-start' }; |
| 28 | + |
| 29 | +afterEach(() => vi.restoreAllMocks()); |
| 30 | + |
| 31 | +/** Writes a record that claims `ownership` for a live process, unreadable variants included. */ |
| 32 | +function writeRecord( |
| 33 | + paths: ReturnType<typeof resolveDaemonPaths>, |
| 34 | + identity: unknown, |
| 35 | + ownership: unknown = 'embedded', |
| 36 | +) { |
| 37 | + const { pid, startTime } = identity as { pid: number; startTime: string }; |
| 38 | + fs.writeFileSync( |
| 39 | + paths.infoPath, |
| 40 | + JSON.stringify({ |
| 41 | + pid, |
| 42 | + processStartTime: startTime, |
| 43 | + token: 'token', |
| 44 | + port: 4210, |
| 45 | + ...(ownership === 'corrupt' ? {} : { ownership }), |
| 46 | + }), |
| 47 | + ); |
| 48 | + if (ownership === 'corrupt') fs.writeFileSync(paths.infoPath, '{not json'); |
| 49 | +} |
| 50 | + |
| 51 | +function writeEmbeddedRecord(paths: ReturnType<typeof resolveDaemonPaths>, identity: unknown) { |
| 52 | + writeRecord(paths, identity); |
| 53 | +} |
| 54 | + |
| 55 | +/** A live acquisition of a state directory, so the caller holds the claim it judges. */ |
| 56 | +async function acquire( |
| 57 | + paths = resolveDaemonPaths(mkdtempForTestSync('agent-device-embedded-claim-')), |
| 58 | +) { |
| 59 | + const attempt = await tryAcquireDaemonRegistration(paths); |
| 60 | + assert.equal(attempt.status, 'acquired'); |
| 61 | + if (attempt.status !== 'acquired') throw new Error('registration refused'); |
| 62 | + return { paths, owner: attempt.owner }; |
| 63 | +} |
| 64 | + |
| 65 | +// A host owns an embedded runtime and ends it with its own close, so retiring that record from outside |
| 66 | +// would take the state directory out from under a process that is still serving. |
| 67 | +test('retirement of an embedded record proves the lifetime is gone and signals nothing', async () => { |
| 68 | + const paths = resolveDaemonPaths(mkdtempForTestSync('agent-device-embedded-retirement-')); |
| 69 | + writeEmbeddedRecord(paths, ownIdentity); |
| 70 | + const before = fs.readFileSync(paths.infoPath, 'utf8'); |
| 71 | + const signals = vi.spyOn(process, 'kill'); |
| 72 | + |
| 73 | + for (const mode of ['graceful', 'force'] as const) { |
| 74 | + const result = await stopAndRetireDaemon({ |
| 75 | + paths, |
| 76 | + observed: ownIdentity, |
| 77 | + mode, |
| 78 | + lockTimeoutMs: 0, |
| 79 | + }); |
| 80 | + assert.equal(result.status, 'retained', JSON.stringify(result)); |
| 81 | + if (result.status !== 'retained') assert.fail('an embedded owner was retired'); |
| 82 | + assert.equal(result.reason, 'embedded-owner'); |
| 83 | + assert.equal(result.termination?.status, 'retained'); |
| 84 | + } |
| 85 | + |
| 86 | + assert.deepEqual( |
| 87 | + signals.mock.calls.filter(([, requested]) => requested !== 0), |
| 88 | + [], |
| 89 | + 'retirement must not reach into a host process', |
| 90 | + ); |
| 91 | + assert.equal(fs.readFileSync(paths.infoPath, 'utf8'), before); |
| 92 | + assert.equal(isProcessAlive(ownIdentity.pid), true); |
| 93 | +}); |
| 94 | + |
| 95 | +test('an embedded record whose lifetime is provably gone retires like any other abandoned one', async () => { |
| 96 | + const paths = resolveDaemonPaths(mkdtempForTestSync('agent-device-embedded-dead-')); |
| 97 | + writeEmbeddedRecord(paths, deadIdentity); |
| 98 | + |
| 99 | + // A host that died without closing left the record behind; keeping it would wedge the state directory |
| 100 | + // for every later client with nothing left to close. |
| 101 | + const result = await stopAndRetireDaemon({ |
| 102 | + paths, |
| 103 | + observed: deadIdentity, |
| 104 | + mode: 'graceful', |
| 105 | + lockTimeoutMs: 0, |
| 106 | + }); |
| 107 | + assert.equal(result.status, 'retired', JSON.stringify(result)); |
| 108 | + assert.equal(result.removedInfo, true); |
| 109 | + assert.equal(fs.existsSync(paths.infoPath), false); |
| 110 | +}); |
| 111 | + |
| 112 | +// A record this build cannot read is not a licence to signal what it names: `detached` is the one |
| 113 | +// ownership that authorizes a signal, and an unreadable or unrecognized record proves nothing about |
| 114 | +// who owns the process. This is the fail-closed side of the same rule the embedded row above proves. |
| 115 | +test('retirement refuses to signal a record whose owner it cannot recognize', async () => { |
| 116 | + for (const ownership of ['sideways', 'corrupt'] as const) { |
| 117 | + const paths = resolveDaemonPaths(mkdtempForTestSync('agent-device-owner-unknown-')); |
| 118 | + writeRecord(paths, ownIdentity, ownership); |
| 119 | + const before = fs.readFileSync(paths.infoPath, 'utf8'); |
| 120 | + const signals = vi.spyOn(process, 'kill'); |
| 121 | + |
| 122 | + const result = await stopAndRetireDaemon({ |
| 123 | + paths, |
| 124 | + observed: ownIdentity, |
| 125 | + mode: 'graceful', |
| 126 | + lockTimeoutMs: 0, |
| 127 | + }); |
| 128 | + assert.equal(result.status, 'retained', ownership); |
| 129 | + if (result.status !== 'retained') |
| 130 | + assert.fail(`${ownership}: an unrecognized owner was retired`); |
| 131 | + // The reason differs from the embedded one: nothing here proves the pid is a daemon of its own, so |
| 132 | + // an operator fixes this by inspecting or updating the record, not by closing a host. |
| 133 | + assert.equal(result.reason, 'owner-unproven', ownership); |
| 134 | + assert.deepEqual( |
| 135 | + signals.mock.calls.filter(([, requested]) => requested !== 0), |
| 136 | + [], |
| 137 | + `${ownership}: an unrecognized owner is never signaled`, |
| 138 | + ); |
| 139 | + assert.equal(fs.readFileSync(paths.infoPath, 'utf8'), before, ownership); |
| 140 | + vi.mocked(process.kill).mockClear(); |
| 141 | + } |
| 142 | +}); |
| 143 | + |
| 144 | +test('a close answers its stop through the release, and an acquisition clears the answer', async () => { |
| 145 | + const { paths, owner } = await acquire(); |
| 146 | + owner.publish(fields); |
| 147 | + |
| 148 | + await owner.finish(undefined, { unfinished: ['1 provider release left pending'] }); |
| 149 | + const record = readDaemonShutdownCompletion(paths.baseDir); |
| 150 | + // The disposition is the removal this call performed, not the caller's guess about it. |
| 151 | + assert.equal(record?.outcome, 'released'); |
| 152 | + assert.deepEqual(record?.unfinished, ['1 provider release left pending']); |
| 153 | + assert.equal(record?.pid, process.pid, 'the record names the instance a stop is fenced to'); |
| 154 | + // This is the production write, so it is where the file mode is a fact rather than a fixture's |
| 155 | + // choice: a state-directory file a stranger could read leaks what a daemon said about its own close. |
| 156 | + assert.equal( |
| 157 | + fs.statSync(resolveDaemonShutdownCompletionPath(paths.baseDir)).mode & 0o777, |
| 158 | + 0o600, |
| 159 | + 'the completion record is published at 0600', |
| 160 | + ); |
| 161 | + |
| 162 | + // Releasing the claim ends this process's authority to speak about the state directory. |
| 163 | + assert.equal( |
| 164 | + owner.recordShutdownRetention(['late']), |
| 165 | + false, |
| 166 | + 'a process that no longer holds the claim writes nothing', |
| 167 | + ); |
| 168 | + |
| 169 | + const next = await acquire(paths); |
| 170 | + assert.equal( |
| 171 | + readDaemonShutdownCompletion(paths.baseDir), |
| 172 | + null, |
| 173 | + "a new instance must not inherit an answer to somebody else's stop", |
| 174 | + ); |
| 175 | + await next.owner.finish(); |
| 176 | +}); |
0 commit comments