Skip to content

Commit 8904420

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
Make E2E signal handoff resilient
Keep cleanup single-flight across repeated signals and retain lock ownership when supervisor logging or startup fails.
1 parent cf5849b commit 8904420

4 files changed

Lines changed: 131 additions & 73 deletions

File tree

apps/sim/e2e/README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,9 @@ new writer.
6565

6666
On interruption, the runner launches a detached cleanup supervisor before
6767
exiting. It terminates managed process groups, force-drops the guarded database,
68-
and removes temporary auth/cloud-config directories even if another Ctrl-C
69-
terminates the foreground package runner.
68+
and removes temporary auth/cloud-config directories. Repeated or opposite
69+
signals remain on the same single-flight path until the supervisor owns the
70+
lock, after which the foreground runner exits.
7071
Cleanup failures retain the lock and require the reported resources to be
7172
inspected and cleaned before manually removing `e2e/.cache/orchestrator.lock`.
7273
Failure to start the detached cleanup supervisor also retains the lock.

apps/sim/e2e/foundation/safety.spec.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@ import {
2121
import { createE2eRuntimeSecrets } from '../support/runtime-secrets'
2222
import { verifySandboxBundleIntegrity } from '../support/sandbox-bundles'
2323
import { assertSafeSeedEnvironment } from '../support/seed-safety'
24-
import { isProcessGroupAlive, parseProcessGroupIds } from '../support/signal-cleanup'
24+
import {
25+
createSingleFlightSignalCleanup,
26+
isProcessGroupAlive,
27+
parseProcessGroupIds,
28+
} from '../support/signal-cleanup'
2529

2630
test.describe('foundation safety guards', () => {
2731
test('discovers env keys without leaking values and shadows unknown keys', () => {
@@ -238,6 +242,27 @@ test.describe('foundation safety guards', () => {
238242
expect(parseProcessGroupIds(' 123, 0, -1, nope, 456 ')).toEqual([123, 456])
239243
})
240244

245+
test('repeated and opposite signals share one cleanup flight', async () => {
246+
const handledSignals: NodeJS.Signals[] = []
247+
let finishCleanup!: () => void
248+
const cleanupBlocked = new Promise<void>((resolve) => {
249+
finishCleanup = resolve
250+
})
251+
const cleanup = createSingleFlightSignalCleanup(async (signal) => {
252+
handledSignals.push(signal)
253+
await cleanupBlocked
254+
})
255+
256+
const first = cleanup.start('SIGINT')
257+
expect(cleanup.start('SIGINT')).toBe(first)
258+
expect(cleanup.start('SIGTERM')).toBe(first)
259+
expect(cleanup.isStarted()).toBe(true)
260+
await Promise.resolve()
261+
expect(handledSignals).toEqual(['SIGINT'])
262+
finishCleanup()
263+
await first
264+
})
265+
241266
test('port preflight rejects an existing listener', async () => {
242267
const server = createServer()
243268
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))

apps/sim/e2e/scripts/run.ts

Lines changed: 84 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import {
4646
} from '../support/process'
4747
import { acquireE2eRunLock } from '../support/run-lock'
4848
import { createE2eRuntimeSecrets, runtimeSecretValues } from '../support/runtime-secrets'
49+
import { createSingleFlightSignalCleanup } from '../support/signal-cleanup'
4950
import {
5051
buildApp,
5152
capturePersonaAuthStates,
@@ -111,7 +112,6 @@ async function main(): Promise<void> {
111112
let canaryCoverageComplete = true
112113
let diagnosticsRetained = true
113114
let failed = false
114-
let signalCleanupStarted = false
115115

116116
const cleanup = (): Promise<void> => {
117117
if (cleanupPromise) return cleanupPromise
@@ -163,81 +163,95 @@ async function main(): Promise<void> {
163163
return cleanupPromise
164164
}
165165

166-
const handleSignal = async (signal: NodeJS.Signals): Promise<void> => {
167-
if (signalCleanupStarted) return
168-
signalCleanupStarted = true
166+
const signalCleanup = createSingleFlightSignalCleanup(async (signal) => {
169167
failed = true
170168
const exitCode = signal === 'SIGINT' ? 130 : 143
171169
process.exitCode = exitCode
172170
console.error(`Received ${signal}; cleaning up the E2E run`)
173-
if (stripeFake) {
174-
try {
175-
writeFileSync(
176-
path.join(logsDirectory, 'stripe-requests.json'),
177-
JSON.stringify(stripeFake.requestLog, null, 2)
178-
)
179-
} catch {}
180-
}
181171
let lockTransferred = false
182-
if (runDatabase) {
183-
const cleanupLogFd = openSync(path.join(logsDirectory, 'signal-cleanup.log'), 'a')
184-
try {
185-
const cleanupProcess = spawn(
186-
bunExecutable,
187-
['--no-env-file', path.join(SIM_APP_DIR, 'e2e/scripts/signal-cleanup.ts')],
188-
{
189-
cwd: SIM_APP_DIR,
190-
detached: true,
191-
env: {
192-
NODE_ENV: 'test',
193-
PATH: process.env.PATH ?? '',
194-
HOME: setupHomeDirectory,
195-
E2E_PG_ADMIN_URL: adminDatabaseUrl,
196-
E2E_DATABASE_NAME: runDatabase.name,
197-
E2E_DATABASE_CREATION_COMPLETE: String(databaseCreationComplete),
198-
E2E_CLEANUP_PROCESS_GROUPS: getActiveManagedProcessGroupIds().join(','),
199-
E2E_CLEANUP_DIRECTORIES: JSON.stringify([
200-
storageStateDirectory,
201-
privateDirectory,
202-
homesDirectory,
203-
]),
204-
E2E_RUN_LOCK_PATH: runLock.path,
205-
E2E_RUN_LOCK_TOKEN: runLock.token,
206-
},
207-
stdio: ['ignore', cleanupLogFd, cleanupLogFd],
208-
}
209-
)
210-
await new Promise<void>((resolve, reject) => {
211-
cleanupProcess.once('spawn', resolve)
212-
cleanupProcess.once('error', reject)
213-
})
214-
if (!cleanupProcess.pid) {
215-
throw new Error('Detached E2E cleanup supervisor started without a process ID')
172+
try {
173+
if (stripeFake) {
174+
try {
175+
writeFileSync(
176+
path.join(logsDirectory, 'stripe-requests.json'),
177+
JSON.stringify(stripeFake.requestLog, null, 2)
178+
)
179+
} catch (error) {
180+
console.error(error)
216181
}
217-
if (!runLock.transfer(cleanupProcess.pid)) {
218-
throw new Error('Detached E2E cleanup supervisor could not acquire run-lock ownership')
182+
}
183+
if (runDatabase) {
184+
let cleanupLogFd: number | null = null
185+
try {
186+
cleanupLogFd = openSync(path.join(logsDirectory, 'signal-cleanup.log'), 'a')
187+
const cleanupProcess = spawn(
188+
bunExecutable,
189+
['--no-env-file', path.join(SIM_APP_DIR, 'e2e/scripts/signal-cleanup.ts')],
190+
{
191+
cwd: SIM_APP_DIR,
192+
detached: true,
193+
env: {
194+
NODE_ENV: 'test',
195+
PATH: process.env.PATH ?? '',
196+
HOME: setupHomeDirectory,
197+
E2E_PG_ADMIN_URL: adminDatabaseUrl,
198+
E2E_DATABASE_NAME: runDatabase.name,
199+
E2E_DATABASE_CREATION_COMPLETE: String(databaseCreationComplete),
200+
E2E_CLEANUP_PROCESS_GROUPS: getActiveManagedProcessGroupIds().join(','),
201+
E2E_CLEANUP_DIRECTORIES: JSON.stringify([
202+
storageStateDirectory,
203+
privateDirectory,
204+
homesDirectory,
205+
]),
206+
E2E_RUN_LOCK_PATH: runLock.path,
207+
E2E_RUN_LOCK_TOKEN: runLock.token,
208+
},
209+
stdio: ['ignore', cleanupLogFd, cleanupLogFd],
210+
}
211+
)
212+
await new Promise<void>((resolve, reject) => {
213+
cleanupProcess.once('spawn', resolve)
214+
cleanupProcess.once('error', reject)
215+
})
216+
if (!cleanupProcess.pid) {
217+
throw new Error('Detached E2E cleanup supervisor started without a process ID')
218+
}
219+
if (!runLock.transfer(cleanupProcess.pid)) {
220+
throw new Error('Detached E2E cleanup supervisor could not acquire run-lock ownership')
221+
}
222+
lockTransferred = true
223+
cleanupProcess.unref()
224+
console.error(`Detached E2E cleanup supervisor started as PID ${cleanupProcess.pid}`)
225+
} finally {
226+
if (cleanupLogFd !== null) {
227+
try {
228+
closeSync(cleanupLogFd)
229+
} catch (error) {
230+
console.error(error)
231+
}
232+
}
219233
}
220-
lockTransferred = true
221-
cleanupProcess.unref()
222-
console.error(`Detached E2E cleanup supervisor started as PID ${cleanupProcess.pid}`)
223-
} catch (error) {
224-
console.error(error)
225-
} finally {
226-
closeSync(cleanupLogFd)
227234
}
235+
} catch (error) {
236+
console.error(error)
228237
}
229-
if (!lockTransferred) {
230-
if (runDatabase) runLock.retain('signal cleanup supervisor failed to start')
231-
else runLock.release()
238+
try {
239+
if (!lockTransferred) {
240+
if (runDatabase) runLock.retain('signal cleanup supervisor failed to start')
241+
else runLock.release()
242+
}
243+
} catch (error) {
244+
console.error('Unable to update E2E run-lock after signal cleanup startup failure', error)
245+
} finally {
246+
process.exit(exitCode)
232247
}
233-
process.exit(exitCode)
234-
}
235-
// Both signal types share a synchronous single-flight guard so opposite signals cannot launch
236-
// competing supervisors while the first handler awaits child-process startup.
237-
const handleSigint = (): void => void handleSignal('SIGINT')
238-
const handleSigterm = (): void => void handleSignal('SIGTERM')
239-
process.once('SIGINT', handleSigint)
240-
process.once('SIGTERM', handleSigterm)
248+
})
249+
// Persistent handlers keep repeated or opposite signals from invoking the OS default while
250+
// the first handler awaits ownership transfer. The synchronous guard keeps cleanup single-flight.
251+
const handleSigint = (): void => void signalCleanup.start('SIGINT')
252+
const handleSigterm = (): void => void signalCleanup.start('SIGTERM')
253+
process.on('SIGINT', handleSigint)
254+
process.on('SIGTERM', handleSigterm)
241255

242256
try {
243257
const hostAddresses = await assertE2eHostResolvesToLoopback()
@@ -415,10 +429,10 @@ async function main(): Promise<void> {
415429
diagnosticsRetained = scrubDiagnostics(diagnosticRoots)
416430
if (diagnosticsRetained) cleanupSucceeded = false
417431
}
418-
process.off('SIGINT', handleSigint)
419-
process.off('SIGTERM', handleSigterm)
420-
setManagedProcessGroupObserver(null)
421-
if (!signalCleanupStarted) {
432+
if (!signalCleanup.isStarted()) {
433+
process.off('SIGINT', handleSigint)
434+
process.off('SIGTERM', handleSigterm)
435+
setManagedProcessGroupObserver(null)
422436
if (cleanupSucceeded) runLock.release()
423437
else runLock.retain('normal cleanup failed; inspect diagnostics and clean resources manually')
424438
}

apps/sim/e2e/support/signal-cleanup.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,24 @@ export function parseProcessGroupIds(rawValue: string | undefined): number[] {
77
.filter((value) => Number.isInteger(value) && value > 0)
88
}
99

10+
export interface SingleFlightSignalCleanup {
11+
isStarted(): boolean
12+
start(signal: NodeJS.Signals): Promise<void>
13+
}
14+
15+
export function createSingleFlightSignalCleanup(
16+
cleanup: (signal: NodeJS.Signals) => Promise<void>
17+
): SingleFlightSignalCleanup {
18+
let cleanupPromise: Promise<void> | null = null
19+
return {
20+
isStarted: () => cleanupPromise !== null,
21+
start(signal) {
22+
cleanupPromise ??= Promise.resolve().then(() => cleanup(signal))
23+
return cleanupPromise
24+
},
25+
}
26+
}
27+
1028
export function isProcessGroupAlive(groupId: number): boolean {
1129
if (!Number.isInteger(groupId) || groupId <= 0) return false
1230
try {

0 commit comments

Comments
 (0)