@@ -19,17 +19,31 @@ import path from 'node:path';
1919import assert from 'node:assert/strict' ;
2020import { 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} ) ) ;
2728vi . 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+
3347vi . 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
86121function 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 */
104145async 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
159219beforeEach ( ( ) => {
160220 mockRunCmdSync . mockReset ( ) ;
161221 mockIsDaemon . mockReset ( ) ;
162222 mockStop . mockReset ( ) ;
223+ mockEmitDiagnostic . mockReset ( ) ;
163224} ) ;
164225afterEach ( ( ) => 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+ / T h e d a e m o n d i d n o t a n s w e r t h e l i v e n e s s p r o b e a n d w a s r e s e t a f t e r t h e t i m e o u t / ,
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+
284405test ( '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