From e4e2c329987fbea1a406db05278c5281fcd71530 Mon Sep 17 00:00:00 2001 From: chick Date: Sat, 18 Jul 2026 17:51:40 +0800 Subject: [PATCH] fix(cutover): track direct detached listener pid --- apps/api/src/cutover-orchestrator.ts | 26 ++++++++++++++++--------- apps/api/src/production-gateway.test.ts | 8 ++++---- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/apps/api/src/cutover-orchestrator.ts b/apps/api/src/cutover-orchestrator.ts index 1b55f97..6c69b53 100644 --- a/apps/api/src/cutover-orchestrator.ts +++ b/apps/api/src/cutover-orchestrator.ts @@ -1,4 +1,4 @@ -import { spawn, execFile } from 'node:child_process'; +import { execFile, execFileSync } from 'node:child_process'; import { constants } from 'node:fs'; import { access, @@ -159,14 +159,22 @@ export const nodeCutoverSystem: CutoverSystem = { throw new Error(`PID ${pid} did not exit after SIGTERM`); }, start(argv, environment) { - const child = spawn(argv[0], argv.slice(1), { - detached: true, - stdio: 'ignore', - env: environment ?? process.env, - }); - child.unref(); - if (!child.pid) throw new Error('Failed to start command'); - return child.pid; + const output = execFileSync( + '/usr/bin/python3', + [ + '-c', + 'import os,sys; pid=os.fork(); print(pid, flush=True); pid and sys.exit(0); os.setsid(); os.execve(sys.argv[1], sys.argv[1:], dict(os.environ))', + ...argv, + ], + { + encoding: 'utf8', + env: environment ?? process.env, + stdio: ['ignore', 'pipe', 'ignore'], + }, + ); + const pid = Number(output.trim()); + if (!Number.isSafeInteger(pid) || pid <= 1) throw new Error('Failed to start command'); + return pid; }, delay: (milliseconds) => new Promise((resolveWait) => setTimeout(resolveWait, milliseconds)), }; diff --git a/apps/api/src/production-gateway.test.ts b/apps/api/src/production-gateway.test.ts index 7badb97..e289022 100644 --- a/apps/api/src/production-gateway.test.ts +++ b/apps/api/src/production-gateway.test.ts @@ -45,9 +45,9 @@ async function fixture(name = 'cutover'): Promise { drillEvidence: drill, drillEvidenceSha256: createHash('sha256').update(drillContent).digest('hex'), legacyPid: 41, - legacyCommand: '/usr/bin/true legacy', + legacyCommand: '/usr/local/bin/node /absolute/legacy/server.js', legacyIdentity: 'legacy-start-1', - legacyStart: ['/usr/bin/true', 'legacy'], + legacyStart: ['/usr/local/bin/node', '/absolute/legacy/server.js'], stateDir: join(root, 'state'), }; } @@ -134,7 +134,7 @@ describe('cutover validation and reversible orchestration', () => { expect(await readFile(statePath, 'utf8')).not.toContain(token); expect(await rollback(plan, system)).toBe(43); expect(signals).toEqual([41, 42]); - expect(starts[1]?.argv).toEqual(['/usr/bin/true', 'legacy']); + expect(starts[1]?.argv).toEqual(['/usr/local/bin/node', '/absolute/legacy/server.js']); }); it('automatically restarts legacy if the gateway fails to own authenticated 8788', async () => { @@ -149,7 +149,7 @@ describe('cutover validation and reversible orchestration', () => { resolve(process.cwd(), 'node_modules/.pnpm/tsx@4.22.4/node_modules/tsx/dist/loader.mjs'), resolve(process.cwd(), 'apps/api/src/production-gateway-cli.ts'), ], - ['/usr/bin/true', 'legacy'], + ['/usr/local/bin/node', '/absolute/legacy/server.js'], ]); const state = JSON.parse(await readFile(join(plan.stateDir, 'cutover-state.json'), 'utf8')) as { phase: string;