Skip to content

Commit 5784234

Browse files
committed
fix(daemon): fence timeout recovery at ownership and transport seams
Review findings on #3193: - The reset deleted daemon.json and daemon.lock unconditionally after a probe window during which a replacement daemon can publish. Re-read the registration through readRegisteredDaemonOwnership and clear it only on `match`; a replacement's record survives. The protocol lock is no longer touched at all: ADR 0030 gives reclaim to the acquirer under its mutation guard, so an out-of-band delete is the legacy-reclaimer pattern. - The probe's detached HTTP build folded a malformed port into a negative answer instead of an unhandled rejection. - The HTTP error listener returns when the timeout already claimed the outcome, so a destroyed request no longer also diagnoses a transport failure. The claim stays with the guarded reject, so a genuine socket death still settles. - The reset hint stops claiming runner children stopped with a daemon killed by pid alone. - Route tests count TCP connections instead of requests, seed ownership shaped registrations and a protocol lock dir, and assert no transport failure rides along with a timeout. Probe tests pin the /health path, gate the socket answer on the HTTP leg's receipt, and cover the malformed port.
1 parent 14912a9 commit 5784234

7 files changed

Lines changed: 288 additions & 92 deletions

‎src/daemon-client/__tests__/daemon-client-lifecycle.test.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -613,10 +613,15 @@ test('sendRequest timeout cleanup uses resolved daemon paths instead of request
613613
const daemonPaths = resolveDaemonPaths(daemonStateDir);
614614
const requestFlagPaths = resolveDaemonPaths(requestFlagStateDir);
615615
const daemon = await startHangingHttpDaemonFixture();
616+
// The cleanup fence only clears a registration that still MATCHES the timed-out daemon on pid
617+
// and start time (#3125), so this fixture names one identity on both sides: the record it
618+
// publishes and the info `sendRequest` times out on.
619+
const timedOutDaemonStartTime = 'timeout-daemon-start';
616620
writeDaemonInfo(daemonPaths, {
617621
httpPort: daemon.port,
618622
transport: 'http',
619623
pid: 999_999,
624+
processStartTime: timedOutDaemonStartTime,
620625
});
621626
writeDaemonLock(daemonPaths, { pid: 999_999 });
622627
writeDaemonInfo(requestFlagPaths, {
@@ -644,6 +649,7 @@ test('sendRequest timeout cleanup uses resolved daemon paths instead of request
644649
pid: 999_999,
645650
httpPort: daemon.port,
646651
transport: 'http',
652+
processStartTime: timedOutDaemonStartTime,
647653
},
648654
request,
649655
'http',
@@ -660,7 +666,9 @@ test('sendRequest timeout cleanup uses resolved daemon paths instead of request
660666
// reset. The fixture refused that probe, which is what authorized the reset below.
661667
assert.deepEqual(daemon.seenPaths, ['POST /rpc', 'GET /health']);
662668
assert.equal(fs.existsSync(daemonPaths.infoPath), false);
663-
assert.equal(fs.existsSync(daemonPaths.lockPath), false);
669+
// The reset owns its registration, not the protocol lock: reclaim belongs to the acquirer
670+
// under ADR 0030's mutation guard, so neither state dir's lock is ever swept.
671+
assert.equal(fs.existsSync(daemonPaths.lockPath), true);
664672
assert.equal(fs.existsSync(requestFlagPaths.infoPath), true);
665673
assert.equal(fs.existsSync(requestFlagPaths.lockPath), true);
666674
} finally {

‎src/daemon-client/__tests__/daemon-client-liveness-probe.test.ts‎

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,18 @@ test('the probe budget stays an order of magnitude under the narrowest reset-eli
6161

6262
test('a daemon answering /health is responsive', async (t) => {
6363
if (await skipWhenLoopbackUnavailable(t)) return;
64-
const server = http.createServer((_req, res) => {
64+
// The url is asserted, not just "an answer": a probe that drifted to any other route would
65+
// still be answered by a server that answers everything, and the finding would then describe
66+
// some other endpoint's liveness rather than the health route the transport also asks.
67+
const requestedUrls: string[] = [];
68+
const server = http.createServer((req, res) => {
69+
requestedUrls.push(String(req.url));
6570
res.statusCode = 200;
6671
res.end('{}');
6772
});
6873
await withLoopback(server, async (port) => {
6974
assert.equal(await probeDaemonResponsive(daemonInfo({ httpPort: port })), true);
75+
assert.deepEqual(requestedUrls, ['/health']);
7076
});
7177
});
7278

@@ -96,13 +102,27 @@ test('a silent HTTP leg cannot veto a live socket leg', async (t) => {
96102
// second endpoint no window — and the all-negative verdict SIGKILLs a daemon the other
97103
// transport would have proven alive. The wrong verdict here kills every session on the host,
98104
// which is the bug #3177 is about.
99-
const rpcHangingServer = http.createServer(() => {
105+
// The HTTP leg must be OBSERVED, not assumed: a verdict reached with no /health request ever
106+
// sent would also pass if the probe simply skipped the leg. So the socket waits for the receipt
107+
// before it answers — if the HTTP leg never asks, the socket never answers, the probe spends
108+
// its window, and the `true` assertion below fails for the right reason.
109+
const healthRequested: { value: boolean } = { value: false };
110+
const rpcHangingServer = http.createServer((req, res) => {
111+
if (req.url === '/health') healthRequested.value = true;
100112
// Accepts and never answers: this leg will spend the full budget.
113+
res.on('error', () => {});
101114
});
102115
const liveSocketServer = net.createServer((socket) => {
103116
socket.on('error', () => {});
104117
socket.on('data', () => {
105-
socket.write(`${JSON.stringify({ jsonrpc: '2.0', id: 'probe', result: { ok: true } })}\n`);
118+
const answer = () => {
119+
socket.write(`${JSON.stringify({ jsonrpc: '2.0', id: 'probe', result: { ok: true } })}\n`);
120+
};
121+
const waitForHttpLeg = (): void => {
122+
if (healthRequested.value) answer();
123+
else setTimeout(waitForHttpLeg, 5);
124+
};
125+
waitForHttpLeg();
106126
});
107127
});
108128
const httpPort = await listenOnLoopback(rpcHangingServer);
@@ -114,6 +134,7 @@ test('a silent HTTP leg cannot veto a live socket leg', async (t) => {
114134
true,
115135
'the socket answer alone is the finding; the silent HTTP leg must not outweigh it',
116136
);
137+
assert.ok(healthRequested.value, 'a verdict with no request on the HTTP leg skipped the leg');
117138
assert.ok(
118139
Date.now() - startedAt < LIVENESS_PROBE_BUDGET_MS,
119140
'the affirmative short-circuits instead of waiting the silent leg out',
@@ -124,6 +145,28 @@ test('a silent HTTP leg cannot veto a live socket leg', async (t) => {
124145
}
125146
});
126147

148+
test('a malformed port makes the HTTP leg an endpoint that did not answer, not a crash', async () => {
149+
// `readDaemonInfo` accepts any positive integer port, and `transport.request` THROWS
150+
// synchronously on one out of range. Inside the leg's detached async build that throw would
151+
// surface as an unhandled rejection — from a recovery path, killing the CLI that is still
152+
// holding a timeout error. The leg must fold it into a negative answer instead.
153+
const probe = probeDaemonResponsive(daemonInfo({ httpPort: 70_000 }));
154+
const rejection = probe.then(
155+
() => null,
156+
(error: unknown) => error,
157+
);
158+
process.on('unhandledRejection', failFastOnUnhandledRejection);
159+
try {
160+
assert.equal(await probe, false);
161+
assert.equal(await rejection, null, 'the probe never rejects on a malformed record');
162+
} finally {
163+
process.off('unhandledRejection', failFastOnUnhandledRejection);
164+
}
165+
});
166+
function failFastOnUnhandledRejection(error: unknown): never {
167+
throw error;
168+
}
169+
127170
test('a slow-trickle endpoint is cut off by the absolute deadline, not extended by its dribble', async (t) => {
128171
if (await skipWhenLoopbackUnavailable(t)) return;
129172
// `socket.setTimeout`/`http.request({timeout})` are IDLE timeouts: a daemon that dribbles bytes

‎src/daemon-client/__tests__/daemon-client-timeout-route.test.ts‎

Lines changed: 140 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,31 @@ import path from 'node:path';
1919
import assert from 'node:assert/strict';
2020
import { beforeEach, afterEach, test, vi } from 'vitest';
2121

22-
const { mockRunCmdSync, mockIsDaemon, mockStop } = vi.hoisted(() => ({
22+
const { mockRunCmdSync, mockIsDaemon, mockStop, mockEmitDiagnostic } = vi.hoisted(() => ({
2323
mockRunCmdSync: vi.fn(),
2424
mockIsDaemon: vi.fn(),
2525
mockStop: vi.fn(),
26+
mockEmitDiagnostic: vi.fn(),
2627
}));
2728
vi.mock('../../daemon-process.ts', async (importOriginal) => ({
2829
...(await importOriginal<typeof import('../../daemon-process.ts')>()),
2930
isAgentDeviceDaemonProcess: mockIsDaemon,
3031
stopDaemonProcess: mockStop,
3132
}));
3233

34+
// Records what the route reported while still emitting for real: the timeout's own diagnostic is
35+
// expected, a transport-failure diagnostic for the same request is not.
36+
vi.mock('@agent-device/host-kit/diagnostics', async (importOriginal) => {
37+
const actual = await importOriginal<typeof import('@agent-device/host-kit/diagnostics')>();
38+
return {
39+
...actual,
40+
emitDiagnostic: (...args: Parameters<typeof actual.emitDiagnostic>) => {
41+
mockEmitDiagnostic(...args);
42+
actual.emitDiagnostic(...args);
43+
},
44+
};
45+
});
46+
3347
vi.mock('@agent-device/host-kit/command', async () => {
3448
const actual = await vi.importActual<typeof import('@agent-device/host-kit/command')>(
3549
'@agent-device/host-kit/command',
@@ -75,12 +89,33 @@ function dummyStatePaths(): DaemonPaths {
7589
return paths;
7690
}
7791

78-
function seedResettableMetadata(paths: DaemonPaths): void {
79-
// A reset clears the metadata it can no longer trust; seeding it lets the reset-path row
80-
// observe removal instead of asserting an absence that was never a presence.
92+
// The start time the seeded registration and the request's DaemonInfo agree on. The ownership
93+
// fence only deletes a record that MATCHES the timed-out daemon on pid and start time, so a row
94+
// that expects removal has to name both. The pid is the test process, which fails the identity
95+
// gate (`isAgentDeviceDaemonProcess`) on its real start time: these tests prove WHICH branch the
96+
// route takes and never signal a process.
97+
const TEST_DAEMON_START_TIME = 'test-start-time';
98+
// A registration whose pid and start time BOTH differ from the timed-out daemon: proof of a
99+
// replacement (#3125), which the ownership fence must refuse to delete.
100+
const REPLACEMENT_DAEMON_PID = 99_999;
101+
102+
function seedRegistration(paths: DaemonPaths, owner: { pid: number; startTime: string }): void {
81103
fs.mkdirSync(paths.baseDir, { recursive: true });
82-
fs.writeFileSync(paths.infoPath, JSON.stringify({ pid: process.pid }));
83-
fs.writeFileSync(paths.lockPath, JSON.stringify({ pid: process.pid }));
104+
fs.writeFileSync(
105+
paths.infoPath,
106+
JSON.stringify({ pid: owner.pid, processStartTime: owner.startTime }),
107+
);
108+
}
109+
110+
function seedProtocolLockDir(paths: DaemonPaths, owner: { pid: number; startTime: string }): void {
111+
// `daemon.lock` is the ADR 0030 directory, not a file: seeding it shaped like production means
112+
// a reset that still deleted it out-of-band would be RECLAIMING someone else's lock, and the
113+
// survival assertion below catches that. (The pre-fix `unlinkSync` even failed on this shape.)
114+
fs.mkdirSync(path.join(paths.lockPath), { recursive: true });
115+
fs.writeFileSync(
116+
path.join(paths.lockPath, 'owner.json'),
117+
JSON.stringify({ pid: owner.pid, startTime: owner.startTime, acquiredAtMs: Date.now() }),
118+
);
84119
}
85120

86121
function buildRequest(command: string, platform: 'android' | 'ios' | undefined): DaemonRequest {
@@ -99,17 +134,27 @@ function buildRequest(command: string, platform: 'android' | 'ios' | undefined):
99134
* client's own envelope cuts the round trip off, and whose LATER connections — the timeout
100135
* handler's fresh probe — either answer like a live daemon (`answer`) or are destroyed
101136
* unanswered (`refuse`, the wedged daemon the reset path is built for: fails the probe fast
102-
* instead of spending its window). `connections` proves whether the route asked at all.
137+
* instead of spending its window).
138+
*
139+
* `connections` counts accepted TCP connections, NOT requests: the probe's whole claim is that it
140+
* asks on a FRESH connection, so an HTTP request counter — which an RPC kept alive by keep-alive
141+
* would also advance — would let the probe pass by reusing the timed-out socket. The stand-in only
142+
* answers `/health` on a connection after the first, so a row that reaches its kept-alive hint is
143+
* simultaneously proving a fresh connection carried a health request.
103144
*/
104145
async function startStandIn(
105146
transport: 'http' | 'socket',
106147
afterFirst: 'answer' | 'refuse',
107148
): Promise<{ server: LoopbackServer; port: number; connections: () => number }> {
108149
let connections = 0;
150+
// The TCP ordinal lives on the socket the request arrived on, so two requests sharing one
151+
// socket (keep-alive) count as one connection: the probe's claim is a FRESH connection, and a
152+
// request counter would advance for an RPC kept alive on the timed-out socket too.
153+
type OrdinalSocket = { __connectionOrdinal?: number };
109154
const server: LoopbackServer =
110155
transport === 'http'
111156
? http.createServer((req, res) => {
112-
const connection = ++connections;
157+
const connection = (req.socket as OrdinalSocket).__connectionOrdinal ?? 0;
113158
if (connection > 1 && afterFirst === 'answer' && req.url === '/health') {
114159
res.statusCode = 200;
115160
res.end('{}');
@@ -137,6 +182,9 @@ async function startStandIn(
137182
});
138183
if (transport === 'http') {
139184
(server as http.Server).on('clientError', (_err, socket) => socket.destroy());
185+
(server as http.Server).on('connection', (socket) => {
186+
(socket as OrdinalSocket).__connectionOrdinal = ++connections;
187+
});
140188
}
141189
const port = await listenOnLoopback(server);
142190
return { server, port, connections: () => connections };
@@ -154,12 +202,25 @@ async function expectRouteError(run: Promise<unknown>, hintPattern: RegExp): Pro
154202
// The regression this suite exists to catch: no host-wide process sweep, whatever the request
155203
// declared.
156204
assert.equal(mockRunCmdSync.mock.calls.length, 0);
205+
// The timeout settles the request and then destroys it, so the transport's `error` event lands
206+
// AFTER the rejection. A timed-out request must not also be diagnosed as a transport FAILURE —
207+
// that describes a canceled request as a broken host and buries the timeout's own reason. The
208+
// destroy surfaces on a later tick, so give it one before reading the record.
209+
await new Promise((resolve) => setImmediate(() => setImmediate(resolve)));
210+
assert.deepEqual(
211+
mockEmitDiagnostic.mock.calls
212+
.map(([entry]) => (entry as { phase?: string })?.phase)
213+
.filter((phase) => phase === 'daemon_request_socket_error'),
214+
[],
215+
'a timeout must not also diagnose a transport failure',
216+
);
157217
}
158218

159219
beforeEach(() => {
160220
mockRunCmdSync.mockReset();
161221
mockIsDaemon.mockReset();
162222
mockStop.mockReset();
223+
mockEmitDiagnostic.mockReset();
163224
});
164225
afterEach(() => vi.restoreAllMocks());
165226

@@ -244,17 +305,37 @@ for (const row of ROUTE_ROWS) {
244305
row.transport === 'remote' ? 'http' : row.transport,
245306
row.afterFirst,
246307
);
247-
// Seeded for every row: a preserved daemon must still OWN its metadata, so the assertion is
248-
// two-sided instead of an absence that was never a presence.
308+
// Seeded for every row with an ownership-MATCHING record (pid and start time agree with the
309+
// request's DaemonInfo): a preserved daemon must still OWN its metadata, and a row expecting
310+
// removal must have earned it past the fence — not observed an absence that was never a
311+
// presence. The protocol lock dir is seeded too and must survive every verdict: a reset
312+
// reclaims nothing (#3122, ADR 0030).
249313
const statePaths = dummyStatePaths();
250-
seedResettableMetadata(statePaths);
314+
const owned = { pid: process.pid, startTime: TEST_DAEMON_START_TIME };
315+
seedRegistration(statePaths, owned);
316+
seedProtocolLockDir(statePaths, owned);
251317
try {
252318
const info: DaemonInfo =
253319
row.transport === 'remote'
254-
? { baseUrl: `http://127.0.0.1:${daemon.port}`, token: 'test-token', pid: process.pid }
320+
? {
321+
baseUrl: `http://127.0.0.1:${daemon.port}`,
322+
token: 'test-token',
323+
pid: process.pid,
324+
processStartTime: TEST_DAEMON_START_TIME,
325+
}
255326
: row.transport === 'http'
256-
? { httpPort: daemon.port, token: 'test-token', pid: process.pid }
257-
: { port: daemon.port, token: 'test-token', pid: process.pid };
327+
? {
328+
httpPort: daemon.port,
329+
token: 'test-token',
330+
pid: process.pid,
331+
processStartTime: TEST_DAEMON_START_TIME,
332+
}
333+
: {
334+
port: daemon.port,
335+
token: 'test-token',
336+
pid: process.pid,
337+
processStartTime: TEST_DAEMON_START_TIME,
338+
};
258339
await expectRouteError(
259340
sendRequest(
260341
info,
@@ -266,21 +347,61 @@ for (const row of ROUTE_ROWS) {
266347
row.hintPattern,
267348
);
268349
assert.equal(daemon.connections(), row.connections, 'probe-vs-skip is a route decision');
269-
// A preserved daemon keeps the metadata a reset would clear; a proved-unresponsive one
270-
// loses both files (its pid fails the identity gate, so nothing is signaled — only the
271-
// bookkeeping a dead daemon cannot release changes).
350+
// A preserved daemon keeps the registration a reset would clear; a proved-unresponsive one
351+
// loses only what the ownership fence proves the killed daemon still owned (its pid fails
352+
// the identity gate, so nothing is signaled — only the bookkeeping changes).
272353
assert.equal(
273354
fs.existsSync(statePaths.infoPath),
274355
!row.resets,
275-
row.resets ? 'the reset clears the stale registration' : 'no reset touches metadata',
356+
row.resets ? 'the reset clears the owned registration' : 'no reset touches metadata',
276357
);
277-
assert.equal(fs.existsSync(statePaths.lockPath), !row.resets);
358+
assert.ok(fs.existsSync(statePaths.lockPath), 'the protocol lock is never swept');
278359
} finally {
279360
await closeLoopbackServer(daemon.server);
280361
}
281362
});
282363
}
283364

365+
test('request-timeout route: a reset never deletes a registration a replacement daemon published', async (t) => {
366+
if (await skipWhenLoopbackUnavailable(t)) return;
367+
// The probe window is exactly when another client can start a replacement daemon and publish
368+
// ITS record (#3177 review, on top of #3125's fence). The timed-out request's info still names
369+
// the wedged pid, so the reset must read the record before clearing it: here it names a
370+
// different owner, and deleting it would orphan the live replacement — the same class of
371+
// host-scoped damage this PR removes everywhere else.
372+
const daemon = await startStandIn('socket', 'refuse');
373+
const statePaths = dummyStatePaths();
374+
seedRegistration(statePaths, { pid: REPLACEMENT_DAEMON_PID, startTime: 'replacement-start' });
375+
seedProtocolLockDir(statePaths, { pid: process.pid, startTime: TEST_DAEMON_START_TIME });
376+
try {
377+
await expectRouteError(
378+
sendRequest(
379+
{
380+
port: daemon.port,
381+
token: 'test-token',
382+
pid: process.pid,
383+
processStartTime: TEST_DAEMON_START_TIME,
384+
},
385+
buildRequest(RESET_POLICY_COMMAND, undefined),
386+
'socket',
387+
statePaths,
388+
TIMEOUT_MS,
389+
),
390+
/The daemon did not answer the liveness probe and was reset after the timeout/,
391+
);
392+
assert.ok(
393+
fs.existsSync(statePaths.infoPath),
394+
'the replacement keeps the registration it published',
395+
);
396+
assert.equal(
397+
JSON.parse(fs.readFileSync(statePaths.infoPath, 'utf8')).pid,
398+
REPLACEMENT_DAEMON_PID,
399+
);
400+
} finally {
401+
await closeLoopbackServer(daemon.server);
402+
}
403+
});
404+
284405
test('a refused timeout fallback preserves the timeout without an unhandled rejection', async (t) => {
285406
if (await skipWhenLoopbackUnavailable(t)) return;
286407
// The reset branch's fallback: a SIGKILL the kernel refuses goes through the confirmed-retirement

0 commit comments

Comments
 (0)