Skip to content

Commit 34bfb81

Browse files
Fix executor mutex race condition and memory leak (#258)
* Fix executor mutex race condition and memory leak * Add changeset * Fix missing mutex cleanup in idle and shutdown paths Added executorLocks.delete() calls in cleanupIdleProcesses() and shutdown() to prevent memory leaks. Also clarified comment in releaseExecutorForContext(). Co-authored-by: Naresh <ghostwriternr@users.noreply.github.com> --------- Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Naresh <ghostwriternr@users.noreply.github.com>
1 parent 8728890 commit 34bfb81

File tree

3 files changed

+53
-10
lines changed

3 files changed

+53
-10
lines changed

.changeset/fix-executor-mutex.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@cloudflare/sandbox': patch
3+
---
4+
5+
Fix executor mutex race condition and memory leak in code interpreter

packages/sandbox-container/src/runtime/process-pool.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -151,10 +151,9 @@ export class ProcessPoolManager {
151151
}
152152

153153
private getExecutorLock(executorId: string): Mutex {
154-
let mutex = this.executorLocks.get(executorId);
154+
const mutex = this.executorLocks.get(executorId);
155155
if (!mutex) {
156-
mutex = new Mutex();
157-
this.executorLocks.set(executorId, mutex);
156+
throw new Error(`No mutex found for executor ${executorId}`);
158157
}
159158
return mutex;
160159
}
@@ -332,7 +331,9 @@ export class ProcessPoolManager {
332331
lastUsed: new Date()
333332
};
334333

335-
// Register exit handler for cleanup (prevents memory leaks)
334+
this.executorLocks.set(id, new Mutex());
335+
336+
// Register exit handler for cleanup
336337
const exitHandler = (
337338
code: number | null,
338339
signal: NodeJS.Signals | null
@@ -361,6 +362,8 @@ export class ProcessPoolManager {
361362
const index = available.indexOf(interpreterProcess);
362363
if (index > -1) available.splice(index, 1);
363364
}
365+
366+
this.executorLocks.delete(id);
364367
};
365368

366369
interpreterProcess.exitHandler = exitHandler;
@@ -557,7 +560,7 @@ export class ProcessPoolManager {
557560
// Remove from context ownership map
558561
this.contextExecutors.delete(contextId);
559562

560-
// Remove exit handler to prevent memory leak
563+
// Remove exit handler since we're doing manual cleanup
561564
if (executor.exitHandler) {
562565
executor.process.removeListener('exit', executor.exitHandler);
563566
}
@@ -668,6 +671,9 @@ export class ProcessPoolManager {
668671
process.process.kill();
669672
available.splice(i, 1);
670673

674+
// Clean up executor lock
675+
this.executorLocks.delete(process.id);
676+
671677
// Also remove from main pool
672678
const pool = this.pools.get(language);
673679
if (pool) {
@@ -754,6 +760,7 @@ export class ProcessPoolManager {
754760
}
755761

756762
this.pools.clear();
763+
this.executorLocks.clear();
757764
}
758765
}
759766

tests/e2e/code-interpreter-workflow.test.ts

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -583,7 +583,8 @@ console.log('Sum:', sum);
583583
currentSandboxId = createSandboxId();
584584
const headers = createTestHeaders(currentSandboxId);
585585

586-
// Create 12 contexts and execute same code that declares a variable
586+
// Create 12 contexts
587+
const contexts: CodeContext[] = [];
587588
for (let i = 0; i < 12; i++) {
588589
const ctxResponse = await fetch(`${workerUrl}/api/code/context/create`, {
589590
method: 'POST',
@@ -593,24 +594,54 @@ console.log('Sum:', sum);
593594

594595
expect(ctxResponse.status).toBe(200);
595596
const context = (await ctxResponse.json()) as CodeContext;
597+
contexts.push(context);
598+
}
596599

600+
// Set unique state in each context using mutable global
601+
for (let i = 0; i < contexts.length; i++) {
597602
const execResponse = await fetch(`${workerUrl}/api/code/execute`, {
598603
method: 'POST',
599604
headers,
600605
body: JSON.stringify({
601-
code: 'const value = 2;',
602-
options: { context }
606+
code: `globalThis.contextValue = ${i}; contextValue;`,
607+
options: { context: contexts[i] }
603608
})
604609
});
605610

606611
expect(execResponse.status).toBe(200);
607612
const execution = (await execResponse.json()) as ExecutionResult;
608613
expect(
609614
execution.error,
610-
`Context ${i + 1} should not have error`
615+
`Context ${i} should not have error setting value`
611616
).toBeUndefined();
617+
expect(execution.results![0].text).toContain(String(i));
618+
}
612619

613-
// Clean up immediately
620+
// Verify each context still has its isolated state
621+
for (let i = 0; i < contexts.length; i++) {
622+
const execResponse = await fetch(`${workerUrl}/api/code/execute`, {
623+
method: 'POST',
624+
headers,
625+
body: JSON.stringify({
626+
code: 'contextValue;',
627+
options: { context: contexts[i] }
628+
})
629+
});
630+
631+
expect(execResponse.status).toBe(200);
632+
const execution = (await execResponse.json()) as ExecutionResult;
633+
expect(
634+
execution.error,
635+
`Context ${i} should not have error reading value`
636+
).toBeUndefined();
637+
expect(
638+
execution.results![0].text,
639+
`Context ${i} should have its unique value ${i}`
640+
).toContain(String(i));
641+
}
642+
643+
// Clean up all contexts
644+
for (const context of contexts) {
614645
await fetch(`${workerUrl}/api/code/context/${context.id}`, {
615646
method: 'DELETE',
616647
headers

0 commit comments

Comments
 (0)