11import assert from 'node:assert/strict' ;
22import fs from 'node:fs' ;
3+ import path from 'node:path' ;
34import { afterEach , test , vi } from 'vitest' ;
45import { readCurrentOwnerIdentity , isProcessAlive } from '@agent-device/host-kit/process' ;
56import {
@@ -15,9 +16,18 @@ import { resolveDaemonPaths, type DaemonPaths } from '../daemon-resolution.ts';
1516import { readRegisteredDaemonOwnership } from '../daemon-registration.ts' ;
1617import { readDaemonShutdownReport } from '../daemon-shutdown-report.ts' ;
1718import { 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' ;
1928import { sleep } from '@agent-device/host-kit/retry' ;
2029import { stopDaemonProcess } from '../daemon-process.ts' ;
30+ import { stopDaemon } from '../daemon/daemon-stop.ts' ;
2131
2232const 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+ }
0 commit comments