Skip to content

Commit 4633f06

Browse files
Bill Leoutsakoscursoragent
authored andcommitted
fix(e2e): serialize cleanup lock ownership
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 54c6d95 commit 4633f06

8 files changed

Lines changed: 140 additions & 77 deletions

File tree

apps/sim/e2e/fixtures/scenario-billing.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export function sameBillingReference(
88
left: ScenarioSubscription,
99
right: ScenarioSubscription
1010
): boolean {
11-
return billingReferenceIdentity(left) === billingReferenceIdentity(right)
11+
return billingReferenceKey(left) === billingReferenceKey(right)
1212
}
1313

1414
export function initialSubscriptionStatus(
@@ -51,7 +51,7 @@ export function expectedUsageLimit(scenario: ResolvedScenario, userKey: string):
5151
return '5'
5252
}
5353

54-
function billingReferenceIdentity(subscription: ScenarioSubscription): string {
54+
export function billingReferenceKey(subscription: ScenarioSubscription): string {
5555
return subscription.billingReference.kind === 'user'
5656
? `user/${subscription.billingReference.userKey}`
5757
: `organization/${subscription.billingReference.organizationKey}`

apps/sim/e2e/fixtures/validate-scenario.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type {
1313
ScenarioWorkspaceGrant,
1414
} from './scenario'
1515
import { SCENARIO_VERSION } from './scenario'
16+
import { billingReferenceKey, isEntitledSubscription } from './scenario-billing'
1617

1718
export class ScenarioValidationError extends Error {
1819
readonly issues: readonly string[]
@@ -412,7 +413,7 @@ function validateSubscriptions(
412413
}
413414
}
414415

415-
if (subscription.status === 'active' || subscription.status === 'past_due') {
416+
if (isEntitledSubscription(subscription)) {
416417
if (entitledBillingReferences.has(reference)) {
417418
issues.push(`billing reference "${reference}" has duplicate entitled subscriptions`)
418419
}
@@ -461,7 +462,7 @@ function validateWorkspaces(
461462
}
462463
const entitledSubscription = definition.subscriptions.find(
463464
(candidate) =>
464-
(candidate.status === 'active' || candidate.status === 'past_due') &&
465+
isEntitledSubscription(candidate) &&
465466
billingReferenceKey(candidate) === payerReferenceKey(workspace)
466467
)
467468
if (entitledSubscription && subscription?.key !== entitledSubscription.key) {
@@ -685,7 +686,7 @@ function validatePersonaWorkspaceExpectation(
685686
: undefined
686687
const entitledSubscription = [...subscriptionsByKey.values()].find(
687688
(candidate) =>
688-
(candidate.status === 'active' || candidate.status === 'past_due') &&
689+
isEntitledSubscription(candidate) &&
689690
billingReferenceKey(candidate) === payerReferenceKey(workspace)
690691
)
691692
const actualPlan = entitledSubscription?.plan ?? 'free'
@@ -754,12 +755,6 @@ function membershipFor(
754755
)
755756
}
756757

757-
function billingReferenceKey(subscription: ScenarioSubscription): string {
758-
return subscription.billingReference.kind === 'user'
759-
? `user/${subscription.billingReference.userKey}`
760-
: `organization/${subscription.billingReference.organizationKey}`
761-
}
762-
763758
function payerReferenceKey(workspace: ScenarioWorkspace): string {
764759
return workspace.payer.kind === 'user'
765760
? `user/${workspace.payer.userKey}`

apps/sim/e2e/foundation/build-manifest.spec.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,43 @@ test('orchestrator lock rejects live ownership and recovers stale descriptors',
166166
}
167167
})
168168

169+
test('transferred run lock rejects mutations from its former owner', async () => {
170+
const directory = mkdtempSync(path.join(os.tmpdir(), 'sim-e2e-transferred-run-lock-'))
171+
const lockPath = path.join(directory, 'orchestrator.lock')
172+
const supervisor = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1_000)'], {
173+
stdio: 'ignore',
174+
})
175+
try {
176+
await once(supervisor, 'spawn')
177+
if (!supervisor.pid) throw new Error('Expected cleanup supervisor PID')
178+
const lock = acquireE2eRunLock(lockPath)
179+
expect(lock.transfer(supervisor.pid)).toBe(true)
180+
expect(lock.transfer(process.pid)).toBe(false)
181+
182+
lock.setProcessGroupIds([supervisor.pid])
183+
lock.retain('former owner must not retain')
184+
lock.release()
185+
186+
const descriptor = JSON.parse(
187+
readFileSync(path.join(lockPath, 'owner.json'), 'utf8')
188+
) as Record<string, unknown>
189+
expect(descriptor.pid).toBe(supervisor.pid)
190+
expect(descriptor.processGroupIds).toEqual([])
191+
expect(descriptor.retainedFailure).toBeUndefined()
192+
expect(existsSync(lockPath)).toBe(true)
193+
194+
supervisor.kill('SIGKILL')
195+
await once(supervisor, 'exit')
196+
const recovered = acquireE2eRunLock(lockPath)
197+
recovered.release()
198+
} finally {
199+
if (supervisor.exitCode === null && supervisor.signalCode === null) {
200+
supervisor.kill('SIGKILL')
201+
}
202+
rmSync(directory, { recursive: true, force: true })
203+
}
204+
})
205+
169206
test('stale orchestrator recovery terminates its persisted process groups', async () => {
170207
test.skip(process.platform === 'win32', 'POSIX process-group cleanup is tested on Unix')
171208
const directory = mkdtempSync(path.join(os.tmpdir(), 'sim-e2e-stale-process-group-'))

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,12 +257,26 @@ test.describe('foundation safety guards', () => {
257257
expect(cleanup.start('SIGINT')).toBe(first)
258258
expect(cleanup.start('SIGTERM')).toBe(first)
259259
expect(cleanup.isStarted()).toBe(true)
260+
expect(cleanup.claimNormalFinalization()).toBe(false)
260261
await Promise.resolve()
261262
expect(handledSignals).toEqual(['SIGINT'])
262263
finishCleanup()
263264
await first
264265
})
265266

267+
test('normal finalization atomically prevents later signal cleanup', async () => {
268+
let signalCleanupCalls = 0
269+
const cleanup = createSingleFlightSignalCleanup(async () => {
270+
signalCleanupCalls += 1
271+
})
272+
273+
expect(cleanup.claimNormalFinalization()).toBe(true)
274+
expect(cleanup.claimNormalFinalization()).toBe(false)
275+
await cleanup.start('SIGTERM')
276+
expect(cleanup.isStarted()).toBe(false)
277+
expect(signalCleanupCalls).toBe(0)
278+
})
279+
266280
test('port preflight rejects an existing listener', async () => {
267281
const server = createServer()
268282
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))

apps/sim/e2e/scripts/run.ts

Lines changed: 58 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,8 @@ async function main(): Promise<void> {
164164
}
165165

166166
const signalCleanup = createSingleFlightSignalCleanup(async (signal) => {
167+
const cleanupProcessGroupIds = getActiveManagedProcessGroupIds()
168+
setManagedProcessGroupObserver(null)
167169
failed = true
168170
const exitCode = signal === 'SIGINT' ? 130 : 143
169171
process.exitCode = exitCode
@@ -197,7 +199,7 @@ async function main(): Promise<void> {
197199
E2E_PG_ADMIN_URL: adminDatabaseUrl,
198200
E2E_DATABASE_NAME: runDatabase.name,
199201
E2E_DATABASE_CREATION_COMPLETE: String(databaseCreationComplete),
200-
E2E_CLEANUP_PROCESS_GROUPS: getActiveManagedProcessGroupIds().join(','),
202+
E2E_CLEANUP_PROCESS_GROUPS: cleanupProcessGroupIds.join(','),
201203
E2E_CLEANUP_DIRECTORIES: JSON.stringify([
202204
storageStateDirectory,
203205
privateDirectory,
@@ -368,79 +370,80 @@ async function main(): Promise<void> {
368370
console.error(error)
369371
process.exitCode = 1
370372
} finally {
371-
if (runDatabase && existsSync(manifestPath)) {
373+
if (signalCleanup.claimNormalFinalization()) {
374+
process.off('SIGINT', handleSigint)
375+
process.off('SIGTERM', handleSigterm)
376+
377+
if (runDatabase && existsSync(manifestPath)) {
378+
try {
379+
await assertManifestWorkspaceIdentities(runDatabase.url, manifestPath)
380+
} catch (error) {
381+
failed = true
382+
process.exitCode = 1
383+
console.error(error)
384+
}
385+
}
372386
try {
373-
await assertManifestWorkspaceIdentities(runDatabase.url, manifestPath)
387+
leakCanarySecrets = loadSyntheticSecretCanaryForScan(leakCanarySecrets, canarySecretsPath)
374388
} catch (error) {
389+
canaryCoverageComplete = false
375390
failed = true
376391
process.exitCode = 1
377392
console.error(error)
393+
} finally {
394+
rmSync(credentialsPath, { force: true })
395+
rmSync(canarySecretsPath, { force: true })
378396
}
379-
}
380-
try {
381-
leakCanarySecrets = loadSyntheticSecretCanaryForScan(leakCanarySecrets, canarySecretsPath)
382-
} catch (error) {
383-
canaryCoverageComplete = false
384-
failed = true
385-
process.exitCode = 1
386-
console.error(error)
387-
} finally {
388-
rmSync(credentialsPath, { force: true })
389-
rmSync(canarySecretsPath, { force: true })
390-
}
391-
let cleanupSucceeded = false
392-
try {
393-
await cleanup()
394-
cleanupSucceeded = true
395-
} catch (error) {
396-
failed = true
397-
process.exitCode = 1
398-
console.error(error)
399-
}
400-
try {
401-
assertNoForbiddenProviderTraffic(
402-
[app?.logPath, realtime?.logPath].filter((value): value is string => Boolean(value))
403-
)
404-
} catch (error) {
405-
failed = true
406-
process.exitCode = 1
407-
console.error(error)
408-
}
409-
if (canaryCoverageComplete && leakCanarySecrets.length > 0) {
397+
let cleanupSucceeded = false
410398
try {
411-
await assertNoSyntheticSecretLeaks({
412-
secrets: leakCanarySecrets,
413-
roots: diagnosticRoots,
414-
excludedPaths: [privateDirectory, storageStateDirectory],
415-
})
416-
writeFileSync(
417-
path.join(markerDirectory, 'leak-scan-complete.json'),
418-
`${JSON.stringify({ runId, completedAt: new Date().toISOString() })}\n`,
419-
{ mode: 0o600 }
399+
await cleanup()
400+
cleanupSucceeded = true
401+
} catch (error) {
402+
failed = true
403+
process.exitCode = 1
404+
console.error(error)
405+
}
406+
try {
407+
assertNoForbiddenProviderTraffic(
408+
[app?.logPath, realtime?.logPath].filter((value): value is string => Boolean(value))
420409
)
421410
} catch (error) {
422411
failed = true
423412
process.exitCode = 1
424413
console.error(error)
414+
}
415+
if (canaryCoverageComplete && leakCanarySecrets.length > 0) {
416+
try {
417+
await assertNoSyntheticSecretLeaks({
418+
secrets: leakCanarySecrets,
419+
roots: diagnosticRoots,
420+
excludedPaths: [privateDirectory, storageStateDirectory],
421+
})
422+
writeFileSync(
423+
path.join(markerDirectory, 'leak-scan-complete.json'),
424+
`${JSON.stringify({ runId, completedAt: new Date().toISOString() })}\n`,
425+
{ mode: 0o600 }
426+
)
427+
} catch (error) {
428+
failed = true
429+
process.exitCode = 1
430+
console.error(error)
431+
diagnosticsRetained = scrubDiagnostics(diagnosticRoots)
432+
if (diagnosticsRetained) cleanupSucceeded = false
433+
}
434+
} else if (!canaryCoverageComplete) {
425435
diagnosticsRetained = scrubDiagnostics(diagnosticRoots)
426436
if (diagnosticsRetained) cleanupSucceeded = false
427437
}
428-
} else if (!canaryCoverageComplete) {
429-
diagnosticsRetained = scrubDiagnostics(diagnosticRoots)
430-
if (diagnosticsRetained) cleanupSucceeded = false
431-
}
432-
if (!signalCleanup.isStarted()) {
433-
process.off('SIGINT', handleSigint)
434-
process.off('SIGTERM', handleSigterm)
435438
setManagedProcessGroupObserver(null)
436439
if (cleanupSucceeded) runLock.release()
437440
else runLock.retain('normal cleanup failed; inspect diagnostics and clean resources manually')
441+
console.info(
442+
diagnosticsRetained
443+
? `E2E ${failed ? 'failed' : 'completed'}; diagnostics: ${runDirectory}`
444+
: 'E2E failed; diagnostics were scrubbed because complete secret scanning was impossible'
445+
)
438446
}
439-
console.info(
440-
diagnosticsRetained
441-
? `E2E ${failed ? 'failed' : 'completed'}; diagnostics: ${runDirectory}`
442-
: 'E2E failed; diagnostics were scrubbed because complete secret scanning was impossible'
443-
)
444447
}
445448
}
446449

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,13 +55,13 @@ process.exit(failures.length > 0 ? 1 : 0)
5555

5656
function releaseRunLock(): void {
5757
if (runLockPath && runLockToken) {
58-
releaseE2eRunLock(runLockPath, runLockToken)
58+
releaseE2eRunLock(runLockPath, runLockToken, process.pid)
5959
}
6060
}
6161

6262
function retainRunLock(reason: string): void {
6363
if (runLockPath && runLockToken) {
64-
retainE2eRunLock(runLockPath, runLockToken, reason)
64+
retainE2eRunLock(runLockPath, runLockToken, process.pid, reason)
6565
}
6666
}
6767

apps/sim/e2e/support/run-lock.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export function acquireE2eRunLock(
5959
token: descriptor.token,
6060
setProcessGroupIds(processGroupIds: number[]): void {
6161
const current = readDescriptor(lockPath)
62-
if (current?.token !== descriptor.token) return
62+
if (current?.token !== descriptor.token || current.pid !== descriptor.pid) return
6363
const normalized = [...new Set(processGroupIds)].filter(
6464
(processGroupId) => Number.isInteger(processGroupId) && processGroupId > 0
6565
)
@@ -76,7 +76,7 @@ export function acquireE2eRunLock(
7676
},
7777
transfer(pid: number): boolean {
7878
const current = readDescriptor(lockPath)
79-
if (current?.token !== descriptor.token) return false
79+
if (current?.token !== descriptor.token || current.pid !== descriptor.pid) return false
8080
writeDescriptor(lockPath, {
8181
...current,
8282
pid,
@@ -86,10 +86,10 @@ export function acquireE2eRunLock(
8686
return transferred?.token === descriptor.token && transferred.pid === pid
8787
},
8888
retain(reason: string): void {
89-
retainE2eRunLock(lockPath, descriptor.token, reason)
89+
retainE2eRunLock(lockPath, descriptor.token, descriptor.pid, reason)
9090
},
9191
release(): void {
92-
releaseE2eRunLock(lockPath, descriptor.token)
92+
releaseE2eRunLock(lockPath, descriptor.token, descriptor.pid)
9393
},
9494
}
9595
} catch (error) {
@@ -119,17 +119,22 @@ export function acquireE2eRunLock(
119119
throw new Error(`Unable to acquire E2E orchestrator lock: ${lockPath}`)
120120
}
121121

122-
export function releaseE2eRunLock(lockPath: string, token: string): void {
122+
export function releaseE2eRunLock(lockPath: string, token: string, expectedOwnerPid: number): void {
123123
if (!existsSync(lockPath)) return
124124
const current = readDescriptor(lockPath)
125-
if (current?.token === token) {
125+
if (current?.token === token && current.pid === expectedOwnerPid) {
126126
rmSync(lockPath, { recursive: true, force: true })
127127
}
128128
}
129129

130-
export function retainE2eRunLock(lockPath: string, token: string, reason: string): void {
130+
export function retainE2eRunLock(
131+
lockPath: string,
132+
token: string,
133+
expectedOwnerPid: number,
134+
reason: string
135+
): void {
131136
const current = readDescriptor(lockPath)
132-
if (current?.token !== token) return
137+
if (current?.token !== token || current.pid !== expectedOwnerPid) return
133138
writeDescriptor(lockPath, { ...current, retainedFailure: reason })
134139
}
135140

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,25 @@ export function parseProcessGroupIds(rawValue: string | undefined): number[] {
99

1010
export interface SingleFlightSignalCleanup {
1111
isStarted(): boolean
12+
claimNormalFinalization(): boolean
1213
start(signal: NodeJS.Signals): Promise<void>
1314
}
1415

1516
export function createSingleFlightSignalCleanup(
1617
cleanup: (signal: NodeJS.Signals) => Promise<void>
1718
): SingleFlightSignalCleanup {
19+
let finalizationOwner: 'none' | 'normal' | 'signal' = 'none'
1820
let cleanupPromise: Promise<void> | null = null
1921
return {
20-
isStarted: () => cleanupPromise !== null,
22+
isStarted: () => finalizationOwner === 'signal',
23+
claimNormalFinalization() {
24+
if (finalizationOwner !== 'none') return false
25+
finalizationOwner = 'normal'
26+
return true
27+
},
2128
start(signal) {
29+
if (finalizationOwner === 'normal') return Promise.resolve()
30+
finalizationOwner = 'signal'
2231
cleanupPromise ??= Promise.resolve().then(() => cleanup(signal))
2332
return cleanupPromise
2433
},

0 commit comments

Comments
 (0)