11import assert from "node:assert/strict"
22import { spawn , type ChildProcessWithoutNullStreams } from "node:child_process"
33import { once } from "node:events"
4- import { existsSync , mkdtempSync , readFileSync , rmSync , writeFileSync } from "node:fs"
4+ import { existsSync , mkdirSync , mkdtempSync , readFileSync , readdirSync , rmSync , writeFileSync } from "node:fs"
55import { tmpdir } from "node:os"
66import { join , posix , win32 } from "node:path"
77import { fileURLToPath } from "node:url"
88import test from "node:test"
99import {
1010 CrossHostRegistration ,
11- CROSS_HOST_OWNER_FILENAME ,
11+ CROSS_HOST_OWNER_DIRECTORY ,
1212 resolveCrossHostElectionDirectory ,
1313 type CrossHostLeaseDependencies ,
1414} from "./client-state-cross-host"
@@ -20,102 +20,134 @@ function temp(t: test.TestContext): string {
2020 return path
2121}
2222
23- function owner ( pid : number , host : string , identity = `${ host } -start` ) : ProcessOwner {
24- return { pid, runToken : ` ${ host } -run` , processStartIdentity : identity }
23+ function owner ( pid : number , token : string , identity = `${ token } -start` ) : ProcessOwner {
24+ return { pid, runToken : token , processStartIdentity : identity }
2525}
2626
2727function dependencies ( alive : boolean , identity ?: string ) : CrossHostLeaseDependencies {
2828 return { pidAlive : ( ) => alive , processStartIdentity : ( ) => identity }
2929}
3030
31- test ( "both host orders elect only the first registration" , ( t ) => {
32- for ( const order of [ [ "electron" , "tauri" ] , [ "tauri" , "electron" ] ] as const ) {
33- const directory = temp ( t )
34- const firstOwner = owner ( 101 , order [ 0 ] ) , secondOwner = owner ( 102 , order [ 1 ] )
35- const identities = new Map ( [ [ 101 , firstOwner . processStartIdentity ! ] , [ 102 , secondOwner . processStartIdentity ! ] ] )
36- const deps = { pidAlive : ( ) => true , processStartIdentity : ( pid : number ) => identities . get ( pid ) }
37- const first = CrossHostRegistration . register ( directory , firstOwner , true , deps ) !
38- const second = CrossHostRegistration . register ( directory , secondOwner , true , deps ) !
39- assert . equal ( first . isPrimary , true )
40- assert . equal ( second . isPrimary , false )
41- second . release ( )
42- assert . equal ( first . release ( ) , true )
43- }
44- } )
31+ function ownerFile ( directory : string ) : string {
32+ return join ( directory , CROSS_HOST_OWNER_DIRECTORY , "owner.json" )
33+ }
34+
35+ interface Child {
36+ process : ChildProcessWithoutNullStreams
37+ result : Promise < boolean >
38+ }
39+
40+ function child ( directory : string , start : string , mode = "" ) : Child {
41+ const process = spawn ( globalThis . process . execPath , [
42+ "--import" , "tsx" , fileURLToPath ( new URL ( "./client-state-cross-host-child.ts" , import . meta. url ) ) , directory , start , "" , mode ,
43+ ] ) as ChildProcessWithoutNullStreams
44+ process . stdout . setEncoding ( "utf8" ) ; process . stderr . setEncoding ( "utf8" )
45+ const result = new Promise < boolean > ( ( resolve , reject ) => {
46+ let output = "" , errors = ""
47+ process . stdout . on ( "data" , ( chunk : string ) => {
48+ output += chunk
49+ if ( output . includes ( "\n" ) ) resolve ( JSON . parse ( output ) . acquired )
50+ } )
51+ process . stderr . on ( "data" , ( chunk : string ) => { errors += chunk } )
52+ process . once ( "error" , reject )
53+ process . once ( "exit" , ( code ) => { if ( ! output && code !== 91 ) reject ( new Error ( `child ${ code } : ${ errors } ` ) ) } )
54+ } )
55+ return { process, result }
56+ }
57+
58+ async function stop ( ...children : Child [ ] ) : Promise < void > {
59+ const running = children . filter ( ( { process } ) => process . exitCode === null && process . signalCode === null )
60+ const exits = running . map ( ( { process } ) => once ( process , "exit" ) )
61+ running . forEach ( ( { process } ) => process . stdin . end ( ) )
62+ await Promise . all ( exits )
63+ }
64+
65+ async function waitForExit ( child : Child ) : Promise < void > {
66+ if ( child . process . exitCode === null && child . process . signalCode === null ) await once ( child . process , "exit" )
67+ }
4568
4669test ( "simultaneous acquisition across processes yields one owner" , async ( t ) => {
4770 const directory = temp ( t ) , start = join ( directory , "start" )
48- const children = Array . from ( { length : 4 } , ( ) => spawn ( process . execPath , [
49- "--import" , "tsx" , fileURLToPath ( new URL ( "./client-state-cross-host-child.ts" , import . meta. url ) ) , directory , start ,
50- ] ) as ChildProcessWithoutNullStreams )
71+ const children = Array . from ( { length : 4 } , ( ) => child ( directory , start ) )
5172 try {
52- const results = children . map ( ( child ) => new Promise < boolean > ( ( resolve , reject ) => {
53- let output = "" , errors = ""
54- child . stdout . setEncoding ( "utf8" ) ; child . stderr . setEncoding ( "utf8" )
55- child . stdout . on ( "data" , ( chunk : string ) => { output += chunk ; if ( output . includes ( "\n" ) ) resolve ( JSON . parse ( output ) . acquired ) } )
56- child . stderr . on ( "data" , ( chunk : string ) => { errors += chunk } )
57- child . once ( "error" , reject )
58- child . once ( "exit" , ( code ) => { if ( ! output ) reject ( new Error ( `child ${ code } : ${ errors } ` ) ) } )
59- } ) )
6073 writeFileSync ( start , "" )
61- assert . equal ( ( await Promise . all ( results ) ) . filter ( Boolean ) . length , 1 )
62- } finally {
63- const exits = children . map ( ( child ) => once ( child , "exit" ) )
64- children . forEach ( ( child ) => child . stdin . end ( ) )
65- await Promise . all ( exits )
66- }
74+ assert . equal ( ( await Promise . all ( children . map ( ( { result } ) => result ) ) ) . filter ( Boolean ) . length , 1 )
75+ } finally { await stop ( ...children ) }
6776} )
6877
69- test ( "stale recovery is identity guarded and immutable claims protect successors" , ( t ) => {
78+ test ( "owner publication crash leaves no visible owner" , async ( t ) => {
79+ const directory = temp ( t ) , start = join ( directory , "start" )
80+ const crashed = child ( directory , start , "owner-crash" )
81+ writeFileSync ( start , "" )
82+ await waitForExit ( crashed )
83+ assert . equal ( existsSync ( join ( directory , CROSS_HOST_OWNER_DIRECTORY ) ) , false )
84+ const winner = CrossHostRegistration . register ( directory , owner ( 101 , "winner" ) , true , dependencies ( true , "winner-start" ) ) !
85+ assert . equal ( winner . isPrimary , true )
86+ } )
87+
88+ test ( "stale retirement crash cannot retire a successor" , async ( t ) => {
89+ const directory = temp ( t ) , staleDirectory = join ( directory , CROSS_HOST_OWNER_DIRECTORY )
90+ mkdirSync ( staleDirectory )
91+ writeFileSync ( join ( staleDirectory , "owner.json" ) , JSON . stringify ( owner ( 4_000_000_000 , "stale" ) ) )
92+ const start = join ( directory , "start" ) , crashed = child ( directory , start , "retire-crash" )
93+ writeFileSync ( start , "" )
94+ await waitForExit ( crashed )
95+ const winner = CrossHostRegistration . register ( directory , owner ( 102 , "successor" ) , true , dependencies ( true , "successor-start" ) ) !
96+ assert . equal ( winner . isPrimary , true )
97+ assert . equal ( JSON . parse ( readFileSync ( ownerFile ( directory ) , "utf8" ) ) . runToken , "successor" )
98+ } )
99+
100+ test ( "stale recovery is identity guarded and blocked by live or uncertain participants" , ( t ) => {
70101 for ( const value of [
71- { name : "dead PID" , deps : dependencies ( false ) , recover : true } ,
72- { name : "PID reuse" , deps : dependencies ( true , "new-start" ) , recover : true } ,
73- { name : "live owner" , deps : dependencies ( true , "old-start" ) , recover : false } ,
74- { name : "uncertain identity" , deps : dependencies ( true ) , recover : false } ,
102+ { alive : false , identity : undefined , recover : true } ,
103+ { alive : true , identity : "reused" , recover : true } ,
104+ { alive : true , identity : "old-start" , recover : false } ,
105+ { alive : true , identity : undefined , recover : false } ,
75106 ] ) {
76- const directory = temp ( t ) , path = join ( directory , CROSS_HOST_OWNER_FILENAME )
77- writeFileSync ( path , JSON . stringify ( owner ( 201 , "old" , "old-start" ) ) )
78- const registration = CrossHostRegistration . register ( directory , owner ( 202 , "new" ) , true , value . deps ) !
79- assert . equal ( registration . isPrimary , value . recover , value . name )
80- registration . release ( )
107+ const directory = temp ( t ) , staleDirectory = join ( directory , CROSS_HOST_OWNER_DIRECTORY )
108+ mkdirSync ( staleDirectory ) ; writeFileSync ( join ( staleDirectory , "owner.json" ) , JSON . stringify ( owner ( 201 , "old" , "old-start" ) ) )
109+ const registration = CrossHostRegistration . register ( directory , owner ( 202 , "new" ) , true , dependencies ( value . alive , value . identity ) ) !
110+ assert . equal ( registration . isPrimary , value . recover )
81111 }
82112
83- const directory = temp ( t ) , path = join ( directory , CROSS_HOST_OWNER_FILENAME )
84- const stale = JSON . stringify ( owner ( 301 , "stale" ) )
85- writeFileSync ( path , stale )
86- const first = CrossHostRegistration . register ( directory , owner ( 302 , "first" ) , true , dependencies ( false ) ) !
87- assert . equal ( first . isPrimary , true )
88- writeFileSync ( path , JSON . stringify ( owner ( 303 , "replacement" ) ) )
89- assert . equal ( first . release ( ) , false )
90- assert . equal ( JSON . parse ( readFileSync ( path , "utf8" ) ) . runToken , "replacement-run" )
113+ const directory = temp ( t ) , staleDirectory = join ( directory , CROSS_HOST_OWNER_DIRECTORY )
114+ mkdirSync ( staleDirectory ) ; writeFileSync ( join ( staleDirectory , "owner.json" ) , JSON . stringify ( owner ( 301 , "dead" ) ) )
115+ writeFileSync ( join ( directory , "participant.302.secondary.json" ) , JSON . stringify ( owner ( 302 , "secondary" ) ) )
116+ const identities = new Map ( [ [ 302 , "secondary-start" ] ] )
117+ const blocked = CrossHostRegistration . register ( directory , owner ( 303 , "next" ) , true , {
118+ pidAlive : ( pid ) => pid === 302 ,
119+ processStartIdentity : ( pid ) => identities . get ( pid ) ,
120+ } ) !
121+ assert . equal ( blocked . isPrimary , false )
91122} )
92123
93- test ( "a surviving opposite-host secondary preserves the cohort barrier " , ( t ) => {
124+ test ( "ordinary release removes only its participant " , ( t ) => {
94125 const directory = temp ( t )
95- const identities = new Map ( [ [ 401 , "primary-start" ] , [ 402 , "secondary-start" ] ] )
96- const deps = { pidAlive : ( ) => true , processStartIdentity : ( pid : number ) => identities . get ( pid ) }
97- const primary = CrossHostRegistration . register ( directory , owner ( 401 , "primary" , "primary-start" ) , true , deps ) !
98- const secondary = CrossHostRegistration . register ( directory , owner ( 402 , "secondary" , "secondary-start" ) , true , deps ) !
99- assert . equal ( secondary . isPrimary , false )
100- assert . equal ( primary . release ( ) , false )
101- assert . equal ( existsSync ( join ( directory , CROSS_HOST_OWNER_FILENAME ) ) , true )
102- const successor = CrossHostRegistration . register ( directory , owner ( 403 , "successor" , "successor-start" ) , true , deps ) !
103- assert . equal ( successor . isPrimary , false )
104- successor . release ( ) ; secondary . release ( )
126+ const registration = CrossHostRegistration . register ( directory , owner ( 401 , "primary" ) , true , dependencies ( true , "primary-start" ) ) !
127+ assert . equal ( registration . release ( ) , true )
128+ assert . equal ( existsSync ( join ( directory , CROSS_HOST_OWNER_DIRECTORY ) ) , true )
129+ assert . deepEqual ( readdirSync ( directory ) . filter ( ( name ) => name . startsWith ( "participant." ) || name . startsWith ( "retired." ) ) , [ ] )
105130} )
106131
107- test ( "malformed owners fail closed and release never removes another owner" , ( t ) => {
108- const directory = temp ( t ) , path = join ( directory , CROSS_HOST_OWNER_FILENAME )
109- writeFileSync ( path , "incomplete" )
110- const blocked = CrossHostRegistration . register ( directory , owner ( 501 , "new" ) , true , dependencies ( false ) ) !
132+ test ( "primary crash remains fenced by its live secondary cohort" , async ( t ) => {
133+ const directory = temp ( t ) , firstStart = join ( directory , "first-start" )
134+ const primary = child ( directory , firstStart ) , secondary = child ( directory , firstStart )
135+ writeFileSync ( firstStart , "" )
136+ const roles = await Promise . all ( [ primary . result , secondary . result ] )
137+ const winner = roles [ 0 ] ? primary : secondary , survivor = roles [ 0 ] ? secondary : primary
138+ winner . process . kill ( )
139+ await waitForExit ( winner )
140+ const blocked = CrossHostRegistration . register ( directory , owner ( 501 , "blocked" ) , true ) !
111141 assert . equal ( blocked . isPrimary , false )
112- assert . equal ( readFileSync ( path , "utf8" ) , "incomplete" )
113142 blocked . release ( )
143+ survivor . process . kill ( )
144+ await waitForExit ( survivor )
145+ const successor = CrossHostRegistration . register ( directory , owner ( 502 , "successor" ) , true ) !
146+ assert . equal ( successor . isPrimary , true )
114147} )
115148
116- test ( "Windows home fallback exactly matches the Rust contract" , ( ) => {
149+ test ( "platform paths match the Rust contract" , ( ) => {
117150 assert . equal ( resolveCrossHostElectionDirectory ( { HOME : "/Users/dev" } , "darwin" , "/fallback" ) , posix . join ( "/Users/dev" , ".codenomad" , "client-state" , "election" ) )
118151 assert . equal ( resolveCrossHostElectionDirectory ( { HOME : "/home/dev" } , "linux" , "/fallback" ) , posix . join ( "/home/dev" , ".codenomad" , "client-state" , "election" ) )
119152 assert . equal ( resolveCrossHostElectionDirectory ( { USERPROFILE : "" , HOME : "D:\\Home" } , "win32" , "C:\\Fallback" ) , win32 . join ( "D:\\Home" , ".codenomad" , "client-state" , "election" ) )
120- assert . equal ( resolveCrossHostElectionDirectory ( { USERPROFILE : "\\Users\\Dev" } , "win32" , "C:\\Fallback" ) , win32 . join ( "C:\\Fallback" , ".codenomad" , "client-state" , "election" ) )
121153} )
0 commit comments