@@ -271,9 +271,9 @@ function posixInstallerEnv(base, harness, publicKeyPath, version = '') {
271271 } ;
272272}
273273
274- function runPosixInstaller ( env ) {
274+ function runPosixInstaller ( env , installer = sh ) {
275275 const quotedPath = env . PATH . replace ( / ' / g, `'"'"'` ) ;
276- const scriptPath = toPosixPath ( sh ) . replace ( / ' / g, `'"'"'` ) ;
276+ const scriptPath = toPosixPath ( installer ) . replace ( / ' / g, `'"'"'` ) ;
277277 return run ( gitSh , [ '-c' , `PATH='${ quotedPath } '; export PATH; exec '${ scriptPath } '` ] , env ) ;
278278}
279279
@@ -610,8 +610,8 @@ test('PowerShell installer recovers every persisted process-termination state wi
610610 }
611611 }
612612} ) ;
613- test ( 'PowerShell installer automatically recovers after an actual process termination at DOWNLOADED ' ,
614- { skip : process . platform !== 'win32' , timeout : 300000 } , async ( t ) => {
613+ test ( 'PowerShell installer recovers after actual process termination at every durable replacement state ' ,
614+ { skip : process . platform !== 'win32' , timeout : 900000 } , async ( t ) => {
615615 const temp = fs . mkdtempSync ( path . join ( os . tmpdir ( ) , 'contexa-installer-kill-win-' ) ) ;
616616 t . after ( ( ) => fs . rmSync ( temp , { recursive : true , force : true } ) ) ;
617617 const keys = crypto . generateKeyPairSync ( 'rsa' , { modulusLength : 3072 } ) ;
@@ -620,54 +620,70 @@ test('PowerShell installer automatically recovers after an actual process termin
620620 const oldBytes = buildWindowsCli ( path . join ( temp , 'old.exe' ) , '8.0.0-old' ) ;
621621 const newBytes = buildWindowsCli ( path . join ( temp , 'new.exe' ) , version ) ;
622622 const files = createRelease ( keys , version , 'windows' , newBytes ) ;
623+ const installerSource = fs . readFileSync ( ps1 , 'utf8' ) ;
624+ const states = [ 'DOWNLOADED' , 'VERIFIED' , 'OLD_MOVED' , 'NEW_MOVED' , 'SMOKE_PASSED' ] ;
625+
626+ for ( const state of states ) {
627+ const stateLines = installerSource . split ( / \r ? \n / )
628+ . filter ( ( line ) => line . includes ( `Write-InstallerTransaction $markerPath '${ state } '` ) ) ;
629+ assert . equal ( stateLines . length , 1 , `${ state } must have one durable transaction write` ) ;
630+ const stateLine = stateLines [ 0 ] ;
631+ const pausedInstaller = path . join ( temp , `install-${ state . toLowerCase ( ) } .ps1` ) ;
632+ fs . writeFileSync ( pausedInstaller , installerSource . replace ( stateLine ,
633+ `${ stateLine } \n if ($env:CONTEXA_TEST_PAUSE_AFTER_STATE -eq '${ state } ') { Start-Sleep -Seconds 30 }` ) ) ;
623634
624- for ( let iteration = 1 ; iteration <= faultRepeats ; iteration += 1 ) {
625- const installDir = path . join ( temp , `bin-${ iteration } ` ) ;
626- fs . mkdirSync ( installDir ) ;
627- const finalPath = path . join ( installDir , 'contexa.exe' ) ;
628- const markerPath = `${ finalPath } .install-transaction.json` ;
629- fs . writeFileSync ( finalPath , oldBytes ) ;
635+ for ( let iteration = 1 ; iteration <= faultRepeats ; iteration += 1 ) {
636+ const installDir = path . join ( temp , `${ state . toLowerCase ( ) } -${ iteration } ` ) ;
637+ fs . mkdirSync ( installDir ) ;
638+ const finalPath = path . join ( installDir , 'contexa.exe' ) ;
639+ const backupPath = `${ finalPath } .previous` ;
640+ const markerPath = `${ finalPath } .install-transaction.json` ;
641+ fs . writeFileSync ( finalPath , oldBytes ) ;
630642
631- await withServer ( ( req , res ) => {
632- const pathname = new URL ( req . url , 'http://localhost' ) . pathname ;
633- if ( pathname . endsWith ( '.sha256' ) ) return ;
634- const value = files . get ( pathname ) ;
635- if ( ! value ) { res . statusCode = 404 ; res . end ( 'not found' ) ; return ; }
636- res . statusCode = 200 ;
637- res . end ( value ) ;
638- } , async ( base ) => {
639- const child = spawn ( powershell , [ '-NoProfile' , '-NonInteractive' , '-ExecutionPolicy' , 'Bypass' , '-File' , ps1 ] , {
640- env : {
641- ...process . env ,
642- ...windowsInstallerEnv ( base , installDir , version , xml ) ,
643- CONTEXA_HTTP_TOTAL_TIMEOUT_SEC : '30' ,
644- } ,
645- windowsHide : true ,
646- } ) ;
647- let observed = false ;
648- for ( let attempt = 0 ; attempt < 200 ; attempt += 1 ) {
649- if ( fs . existsSync ( markerPath ) ) {
650- const marker = JSON . parse ( fs . readFileSync ( markerPath , 'utf8' ) ) ;
651- if ( marker . state === 'DOWNLOADED' ) { observed = true ; break ; }
643+ await withServer ( releaseHandler ( files ) , async ( base ) => {
644+ const child = spawn ( powershell ,
645+ [ '-NoProfile' , '-NonInteractive' , '-ExecutionPolicy' , 'Bypass' , '-File' , pausedInstaller ] , {
646+ env : {
647+ ...process . env ,
648+ ...windowsInstallerEnv ( base , installDir , version , xml ) ,
649+ CONTEXA_TEST_PAUSE_AFTER_STATE : state ,
650+ } ,
651+ windowsHide : true ,
652+ } ) ;
653+ let output = '' ;
654+ child . stdout . on ( 'data' , ( chunk ) => { output += chunk ; } ) ;
655+ child . stderr . on ( 'data' , ( chunk ) => { output += chunk ; } ) ;
656+ const exited = new Promise ( ( resolve ) => child . once ( 'exit' , resolve ) ) ;
657+ let observed = false ;
658+ for ( let attempt = 0 ; attempt < 1000 ; attempt += 1 ) {
659+ if ( fs . existsSync ( markerPath ) ) {
660+ try {
661+ const marker = JSON . parse ( fs . readFileSync ( markerPath , 'utf8' ) ) ;
662+ if ( marker . state === state ) { observed = true ; break ; }
663+ } catch {
664+ // Atomic marker replacement can briefly race the reader; retry the exact state.
665+ }
666+ }
667+ if ( child . exitCode !== null ) break ;
668+ await new Promise ( ( resolve ) => setTimeout ( resolve , 10 ) ) ;
652669 }
653- await new Promise ( ( resolve ) => setTimeout ( resolve , 20 ) ) ;
654- }
655- assert . equal ( observed , true ,
656- `DOWNLOADED marker must be durable before termination, iteration ${ iteration } ` ) ;
657- child . kill ( ) ;
658- await new Promise ( ( resolve ) => child . once ( 'exit' , resolve ) ) ;
659- } ) ;
670+ if ( child . exitCode === null ) child . kill ( ) ;
671+ await exited ;
672+ assert . equal ( observed , true ,
673+ `${ state } /${ iteration } marker must be durable before actual termination. ${ output } ` ) ;
660674
661- assert . equal ( sha256 ( fs . readFileSync ( finalPath ) ) , sha256 ( oldBytes ) ) ;
662- assert . equal ( fs . existsSync ( markerPath ) , true ) ;
663- await withServer ( releaseHandler ( files ) , async ( base ) => {
664- const recovered = await runWindowsInstaller ( windowsInstallerEnv ( base , installDir , version , xml ) ) ;
665- assert . equal ( recovered . code , 0 , recovered . stderr || recovered . stdout ) ;
666- } ) ;
667- assert . equal ( spawnSync ( finalPath , [ '--version' ] , { encoding : 'utf8' } ) . stdout . trim ( ) , version ) ;
668- assert . equal ( fs . existsSync ( markerPath ) , false ) ;
675+ const recovered = await runWindowsInstaller ( windowsInstallerEnv ( base , installDir , version , xml ) ) ;
676+ assert . equal ( recovered . code , 0 , recovered . stderr || recovered . stdout ) ;
677+ } ) ;
678+
679+ assert . equal ( spawnSync ( finalPath , [ '--version' ] , { encoding : 'utf8' } ) . stdout . trim ( ) , version ) ;
680+ assert . equal ( fs . existsSync ( markerPath ) , false , `${ state } /${ iteration } marker must be cleared` ) ;
681+ assert . equal ( sha256 ( fs . readFileSync ( backupPath ) ) , sha256 ( oldBytes ) ,
682+ `${ state } /${ iteration } must preserve the rollback binary` ) ;
683+ }
669684 }
670685} ) ;
686+
671687test ( 'PowerShell installer handles an update while the existing CLI is running without losing a healthy binary' ,
672688 { skip : process . platform !== 'win32' , timeout : 300000 } , async ( t ) => {
673689 const temp = fs . mkdtempSync ( path . join ( os . tmpdir ( ) , 'contexa-installer-running-win-' ) ) ;
@@ -852,6 +868,75 @@ test('POSIX installer recovers every persisted process-termination state without
852868 }
853869 }
854870} ) ;
871+ test ( 'POSIX installer recovers after actual process termination at every durable replacement state' ,
872+ { skip : process . platform === 'win32' , timeout : 900000 } , async ( t ) => {
873+ assert . notEqual ( process . getuid ?. ( ) , 0 , 'POSIX acceptance must run as a non-root user' ) ;
874+ const temp = fs . mkdtempSync ( path . join ( os . tmpdir ( ) , 'contexa-installer-kill-posix-' ) ) ;
875+ t . after ( ( ) => fs . rmSync ( temp , { recursive : true , force : true } ) ) ;
876+ const keys = crypto . generateKeyPairSync ( 'rsa' , { modulusLength : 3072 } ) ;
877+ const publicKeyPath = path . join ( temp , 'public.pem' ) ;
878+ fs . writeFileSync ( publicKeyPath , keys . publicKey . export ( { type : 'spki' , format : 'pem' } ) ) ;
879+ const version = '9.9.27-new' ;
880+ const oldBytes = buildPosixCli ( '8.0.0-old' ) ;
881+ const newBytes = buildPosixCli ( version ) ;
882+ const files = createRelease ( keys , version , posixReleasePlatform , newBytes ) ;
883+ const installerSource = fs . readFileSync ( sh , 'utf8' ) ;
884+ const states = [ 'DOWNLOADED' , 'VERIFIED' , 'OLD_MOVED' , 'NEW_MOVED' , 'SMOKE_PASSED' ] ;
885+
886+ for ( const state of states ) {
887+ const stateNeedle = `write_installer_transaction ${ state } ` ;
888+ assert . equal ( installerSource . split ( stateNeedle ) . length - 1 , 1 ,
889+ `${ state } must have one durable transaction write` ) ;
890+ const pausedInstaller = path . join ( temp , `install-${ state . toLowerCase ( ) } .sh` ) ;
891+ fs . writeFileSync ( pausedInstaller , installerSource . replace ( stateNeedle ,
892+ `${ stateNeedle } \n[ "\${CONTEXA_TEST_PAUSE_AFTER_STATE:-}" = ${ state } ] && sleep 30` ) , { mode : 0o755 } ) ;
893+
894+ for ( let iteration = 1 ; iteration <= faultRepeats ; iteration += 1 ) {
895+ const harness = createPosixHarness ( path . join ( temp , `${ state . toLowerCase ( ) } -${ iteration } ` ) ) ;
896+ const finalPath = path . join ( harness . installDir , 'contexa' ) ;
897+ const backupPath = `${ finalPath } .previous` ;
898+ const markerPath = `${ finalPath } .install-transaction` ;
899+ fs . writeFileSync ( finalPath , oldBytes , { mode : 0o755 } ) ;
900+
901+ await withServer ( releaseHandler ( files ) , async ( base ) => {
902+ const env = posixInstallerEnv ( base , harness , publicKeyPath , version ) ;
903+ const quotedPath = env . PATH . replace ( / ' / g, `'"'"'` ) ;
904+ const scriptPath = toPosixPath ( pausedInstaller ) . replace ( / ' / g, `'"'"'` ) ;
905+ const child = spawn ( gitSh , [ '-c' , `PATH='${ quotedPath } '; export PATH; exec '${ scriptPath } '` ] , {
906+ env : { ...process . env , ...env , CONTEXA_TEST_PAUSE_AFTER_STATE : state } ,
907+ windowsHide : true ,
908+ } ) ;
909+ let output = '' ;
910+ child . stdout . on ( 'data' , ( chunk ) => { output += chunk ; } ) ;
911+ child . stderr . on ( 'data' , ( chunk ) => { output += chunk ; } ) ;
912+ const exited = new Promise ( ( resolve ) => child . once ( 'exit' , resolve ) ) ;
913+ let observed = false ;
914+ for ( let attempt = 0 ; attempt < 1000 ; attempt += 1 ) {
915+ if ( fs . existsSync ( markerPath ) ) {
916+ const markerState = fs . readFileSync ( markerPath , 'utf8' ) . split ( / \r ? \n / )
917+ . find ( ( line ) => line . startsWith ( 'STATE=' ) ) ;
918+ if ( markerState === `STATE=${ state } ` ) { observed = true ; break ; }
919+ }
920+ if ( child . exitCode !== null ) break ;
921+ await new Promise ( ( resolve ) => setTimeout ( resolve , 10 ) ) ;
922+ }
923+ if ( child . exitCode === null ) child . kill ( 'SIGKILL' ) ;
924+ await exited ;
925+ assert . equal ( observed , true ,
926+ `${ state } /${ iteration } marker must be durable before actual termination. ${ output } ` ) ;
927+
928+ const recovered = await runPosixInstaller ( posixInstallerEnv ( base , harness , publicKeyPath , version ) ) ;
929+ assert . equal ( recovered . code , 0 , recovered . stderr || recovered . stdout ) ;
930+ } ) ;
931+
932+ assert . equal ( spawnSync ( gitSh , [ toPosixPath ( finalPath ) , '--version' ] , { encoding : 'utf8' } ) . stdout . trim ( ) , version ) ;
933+ assert . equal ( fs . existsSync ( markerPath ) , false , `${ state } /${ iteration } marker must be cleared` ) ;
934+ assert . equal ( sha256 ( fs . readFileSync ( backupPath ) ) , sha256 ( oldBytes ) ,
935+ `${ state } /${ iteration } must preserve the rollback binary` ) ;
936+ }
937+ }
938+ } ) ;
939+
855940test ( 'POSIX installer performs lifecycle and preserves the existing binary for the full fault matrix' ,
856941 { skip : process . platform === 'win32' , timeout : 600000 } , async ( t ) => {
857942 const temp = fs . mkdtempSync ( path . join ( os . tmpdir ( ) , 'contexa-installer-posix-matrix-' ) ) ;
0 commit comments