11import assert from 'node:assert/strict' ;
22import fs from 'node:fs' ;
3+ import path from 'node:path' ;
34import { afterEach , test , vi } from 'vitest' ;
45import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts' ;
56
@@ -11,13 +12,19 @@ vi.mock('@agent-device/host-kit/retry', async (importOriginal) => ({
1112 ...( await importOriginal < typeof import ( '@agent-device/host-kit/retry' ) > ( ) ) ,
1213 sleep : vi . fn ( async ( ) => { } ) ,
1314} ) ) ;
15+ const winner = vi . hoisted ( ( ) => ( { pid : 43_300 , alive : true } ) ) ;
1416vi . mock ( '../../daemon-process.ts' , async ( importOriginal ) => {
1517 const actual = await importOriginal < typeof import ( '../../daemon-process.ts' ) > ( ) ;
1618 return {
1719 ...actual ,
18- isAgentDeviceDaemonProcess : vi . fn (
19- ( pid : number , startTime : string | undefined ) =>
20- pid === WINNER_PID || actual . isAgentDeviceDaemonProcess ( pid , startTime ) ,
20+ isAgentDeviceDaemonProcess : vi . fn ( ( pid : number , startTime : string | undefined ) =>
21+ pid === winner . pid ? winner . alive : actual . isAgentDeviceDaemonProcess ( pid , startTime ) ,
22+ ) ,
23+ stopProcessForTakeover : vi . fn (
24+ async ( pid : number , options : Parameters < typeof actual . stopProcessForTakeover > [ 1 ] ) => {
25+ if ( pid !== winner . pid ) return await actual . stopProcessForTakeover ( pid , options ) ;
26+ winner . alive = false ;
27+ } ,
2128 ) ,
2229 } ;
2330} ) ;
@@ -26,7 +33,8 @@ import { resolveDaemonPaths, type DaemonPaths } from '../../daemon-resolution.ts
2633import { sendToDaemon } from '../daemon-client.ts' ;
2734import { runCmdDetachedMonitored , type ExecDetachedExit } from '@agent-device/host-kit/command' ;
2835import { sleep } from '@agent-device/host-kit/retry' ;
29- import { readVersion } from '@agent-device/host-kit/version' ;
36+ import { findProjectRoot , readVersion } from '@agent-device/host-kit/version' ;
37+ import { computeDaemonCodeSignature } from '@agent-device/host-kit/code-signature' ;
3038import {
3139 startHttpDaemonFixture ,
3240 type HttpDaemonFixture ,
@@ -36,21 +44,38 @@ import { closeLoopbackServer, supportsLoopbackBind } from '../../__tests__/test-
3644// Two clients that find no daemon both launch one; the daemon that loses the startup lock exits
3745// cleanly. These pin that the losing client adopts the winner instead of tearing it down.
3846
39- const WINNER_PID = 43_300 ;
47+ const WINNER_PID = winner . pid ;
4048const LOSER_PID = 43_301 ;
4149
4250const mockRunCmdDetached = vi . mocked ( runCmdDetachedMonitored ) ;
4351const mockSleep = vi . mocked ( sleep ) ;
4452
4553afterEach ( ( ) => {
54+ winner . alive = true ;
4655 mockRunCmdDetached . mockReset ( ) ;
4756 mockSleep . mockReset ( ) ;
4857 mockSleep . mockImplementation ( async ( ) => { } ) ;
4958 vi . unstubAllEnvs ( ) ;
5059} ) ;
5160
61+ /** The code signature this client expects of a daemon it may reuse. */
62+ function currentDaemonCodeSignature ( ) : string {
63+ const root = findProjectRoot ( ) ;
64+ const distPath = path . join ( root , 'dist' , 'src' , 'internal' , 'daemon.js' ) ;
65+ const entryPath =
66+ process . execArgv . includes ( '--experimental-strip-types' ) || ! fs . existsSync ( distPath )
67+ ? path . join ( root , 'src' , 'daemon.ts' )
68+ : distPath ;
69+ return computeDaemonCodeSignature ( entryPath , root ) ;
70+ }
71+
5272/** Records the winning daemon the way it would: the startup lock, then its reachable metadata. */
53- function writeWinner ( paths : DaemonPaths , fixture : HttpDaemonFixture , parts : 'lock' | 'all' ) : void {
73+ function writeWinner (
74+ paths : DaemonPaths ,
75+ fixture : HttpDaemonFixture ,
76+ parts : 'lock' | 'all' ,
77+ version = readVersion ( ) ,
78+ ) : void {
5479 fs . mkdirSync ( paths . baseDir , { recursive : true } ) ;
5580 fs . writeFileSync (
5681 paths . lockPath ,
@@ -62,7 +87,8 @@ function writeWinner(paths: DaemonPaths, fixture: HttpDaemonFixture, parts: 'loc
6287 JSON . stringify ( {
6388 token : 'winner-secret' ,
6489 pid : WINNER_PID ,
65- version : readVersion ( ) ,
90+ version,
91+ codeSignature : currentDaemonCodeSignature ( ) ,
6692 processStartTime : 'winner' ,
6793 httpPort : fixture . port ,
6894 transport : 'http' ,
@@ -140,3 +166,44 @@ test('a one-shot test run leaves a daemon another client started running', async
140166 fs . rmSync ( stateDir , { recursive : true , force : true } ) ;
141167 }
142168} ) ;
169+
170+ test ( 'a start race won by an older daemon replaces it instead of adopting it' , async ( t ) => {
171+ if ( ! ( await supportsLoopbackBind ( ) ) ) {
172+ t . skip ( 'loopback listeners are not permitted in this environment' ) ;
173+ return ;
174+ }
175+ const stateDir = mkdtempForTestSync ( 'agent-device-daemon-start-race-older-' ) ;
176+ const paths = resolveDaemonPaths ( stateDir ) ;
177+ vi . stubEnv ( 'AGENT_DEVICE_STATE_DIR' , stateDir ) ;
178+ const fixture = await startHttpDaemonFixture ( { devices : [ ] } ) ;
179+ let launches = 0 ;
180+ mockRunCmdDetached . mockImplementation ( ( ) => {
181+ launches += 1 ;
182+ if ( launches === 1 ) writeWinner ( paths , fixture , 'all' , '0.0.1' ) ;
183+ const exit : ExecDetachedExit = { pid : LOSER_PID , exitCode : 0 } ;
184+ return { pid : LOSER_PID , exited : Promise . resolve ( exit ) } ;
185+ } ) ;
186+ const stderr = vi . spyOn ( process . stderr , 'write' ) . mockImplementation ( ( ) => true ) ;
187+
188+ try {
189+ await assert . rejects (
190+ sendToDaemon ( {
191+ session : 'default' ,
192+ command : 'devices' ,
193+ positionals : [ ] ,
194+ flags : { stateDir } ,
195+ meta : { requestId : 'req-start-race-older' } ,
196+ } ) ,
197+ ) ;
198+ assert . equal ( fixture . rpcRequests . length , 0 ) ;
199+ assert . equal ( launches , 2 ) ;
200+ assert . match (
201+ String ( stderr . mock . calls . flat ( ) . join ( '' ) ) ,
202+ / R e p l a c i n g d a e m o n \( p i d 4 3 3 0 0 , v 0 \. 0 \. 1 \) / ,
203+ ) ;
204+ } finally {
205+ stderr . mockRestore ( ) ;
206+ await closeLoopbackServer ( fixture . server ) ;
207+ fs . rmSync ( stateDir , { recursive : true , force : true } ) ;
208+ }
209+ } ) ;
0 commit comments