Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-executor-mutex.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@cloudflare/sandbox': patch
---

Fix executor mutex race condition and memory leak in code interpreter
17 changes: 12 additions & 5 deletions packages/sandbox-container/src/runtime/process-pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,10 +151,9 @@ export class ProcessPoolManager {
}

private getExecutorLock(executorId: string): Mutex {
let mutex = this.executorLocks.get(executorId);
const mutex = this.executorLocks.get(executorId);
if (!mutex) {
mutex = new Mutex();
this.executorLocks.set(executorId, mutex);
throw new Error(`No mutex found for executor ${executorId}`);
}
return mutex;
}
Expand Down Expand Up @@ -332,7 +331,9 @@ export class ProcessPoolManager {
lastUsed: new Date()
};

// Register exit handler for cleanup (prevents memory leaks)
this.executorLocks.set(id, new Mutex());

// Register exit handler for cleanup
const exitHandler = (
code: number | null,
signal: NodeJS.Signals | null
Expand Down Expand Up @@ -361,6 +362,8 @@ export class ProcessPoolManager {
const index = available.indexOf(interpreterProcess);
if (index > -1) available.splice(index, 1);
}

this.executorLocks.delete(id);
};

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

// Remove exit handler to prevent memory leak
// Remove exit handler since we're doing manual cleanup
if (executor.exitHandler) {
executor.process.removeListener('exit', executor.exitHandler);
}
Expand Down Expand Up @@ -668,6 +671,9 @@ export class ProcessPoolManager {
process.process.kill();
available.splice(i, 1);

// Clean up executor lock
this.executorLocks.delete(process.id);

// Also remove from main pool
const pool = this.pools.get(language);
if (pool) {
Expand Down Expand Up @@ -754,6 +760,7 @@ export class ProcessPoolManager {
}

this.pools.clear();
this.executorLocks.clear();
}
}

Expand Down
41 changes: 36 additions & 5 deletions tests/e2e/code-interpreter-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -583,7 +583,8 @@ console.log('Sum:', sum);
currentSandboxId = createSandboxId();
const headers = createTestHeaders(currentSandboxId);

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

expect(ctxResponse.status).toBe(200);
const context = (await ctxResponse.json()) as CodeContext;
contexts.push(context);
}

// Set unique state in each context using mutable global
for (let i = 0; i < contexts.length; i++) {
const execResponse = await fetch(`${workerUrl}/api/code/execute`, {
method: 'POST',
headers,
body: JSON.stringify({
code: 'const value = 2;',
options: { context }
code: `globalThis.contextValue = ${i}; contextValue;`,
options: { context: contexts[i] }
})
});

expect(execResponse.status).toBe(200);
const execution = (await execResponse.json()) as ExecutionResult;
expect(
execution.error,
`Context ${i + 1} should not have error`
`Context ${i} should not have error setting value`
).toBeUndefined();
expect(execution.results![0].text).toContain(String(i));
}

// Clean up immediately
// Verify each context still has its isolated state
for (let i = 0; i < contexts.length; i++) {
const execResponse = await fetch(`${workerUrl}/api/code/execute`, {
method: 'POST',
headers,
body: JSON.stringify({
code: 'contextValue;',
options: { context: contexts[i] }
})
});

expect(execResponse.status).toBe(200);
const execution = (await execResponse.json()) as ExecutionResult;
expect(
execution.error,
`Context ${i} should not have error reading value`
).toBeUndefined();
expect(
execution.results![0].text,
`Context ${i} should have its unique value ${i}`
).toContain(String(i));
}

// Clean up all contexts
for (const context of contexts) {
await fetch(`${workerUrl}/api/code/context/${context.id}`, {
method: 'DELETE',
headers
Expand Down
Loading