Skip to content

Commit 8da74fd

Browse files
committed
test: terminate installers at every durable state
1 parent b2256b6 commit 8da74fd

1 file changed

Lines changed: 131 additions & 46 deletions

File tree

test/installer-runtime.test.js

Lines changed: 131 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
671687
test('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+
855940
test('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

Comments
 (0)