Skip to content

Commit 30869cd

Browse files
committed
feat: US-108 - Cap active handle map size
1 parent 0453db0 commit 30869cd

7 files changed

Lines changed: 103 additions & 1 deletion

File tree

packages/secure-exec-core/isolate-runtime/src/common/runtime-globals.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ declare global {
9595
var _childProcessSpawnSync: ChildProcessSpawnSyncBridgeRef;
9696
var _log: ProcessLogBridgeRef;
9797
var _error: ProcessErrorBridgeRef;
98+
var _maxHandles: number | undefined;
9899
var _registerHandle: RegisterHandleBridgeFn;
99100
var _unregisterHandle: UnregisterHandleBridgeFn;
100101
var require: ((request: string) => unknown) | undefined;

packages/secure-exec-core/src/bridge/active-handles.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ import { exposeCustomGlobal } from "../shared/global-exposure.js";
1111
* See: docs-internal/node/ACTIVE_HANDLES.md
1212
*/
1313

14+
// _maxHandles is injected by the host when resourceBudgets.maxHandles is set.
15+
declare const _maxHandles: number | undefined;
16+
1417
// Map of active handles: id -> description (for debugging)
1518
const _activeHandles = new Map<string, string>();
1619

@@ -19,10 +22,15 @@ let _waitResolvers: Array<() => void> = [];
1922

2023
/**
2124
* Register an active handle that keeps the sandbox alive.
25+
* Throws if the handle cap (_maxHandles) would be exceeded.
2226
* @param id Unique identifier for the handle
2327
* @param description Human-readable description for debugging
2428
*/
2529
export function _registerHandle(id: string, description: string): void {
30+
// Enforce handle cap (skip check for re-registration of existing handle)
31+
if (typeof _maxHandles !== "undefined" && !_activeHandles.has(id) && _activeHandles.size >= _maxHandles) {
32+
throw new Error("ERR_RESOURCE_BUDGET_EXCEEDED: maximum active handles exceeded");
33+
}
2634
_activeHandles.set(id, description);
2735
}
2836

packages/secure-exec-core/src/runtime-driver.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ export interface ResourceBudgets {
3030
maxTimers?: number;
3131
/** Maximum child_process.spawn() invocations per execution. */
3232
maxChildProcesses?: number;
33+
/** Maximum concurrent active handles (child processes, timers, servers) in the bridge handle map. */
34+
maxHandles?: number;
3335
}
3436

3537
export interface RuntimeDriverOptions {

packages/secure-exec-node/src/bridge-setup.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ type BridgeDeps = Pick<
7979
| "maxOutputBytes"
8080
| "maxTimers"
8181
| "maxChildProcesses"
82+
| "maxHandles"
8283
| "bridgeBase64TransferLimitBytes"
8384
| "isolateJsonPayloadLimitBytes"
8485
| "activeHttpServerIds"
@@ -240,6 +241,11 @@ export async function setupRequire(
240241
await jail.set("_maxTimers", deps.maxTimers, { copy: true });
241242
}
242243

244+
// Inject maxHandles limit for bridge-side active handle cap
245+
if (deps.maxHandles !== undefined) {
246+
await jail.set("_maxHandles", deps.maxHandles, { copy: true });
247+
}
248+
243249
// Set up host crypto references for secure randomness.
244250
// Cap matches Web Crypto API spec (65536 bytes) to prevent host OOM.
245251
const cryptoRandomFillRef = new ivm.Reference((byteLength: number) => {

packages/secure-exec-node/src/execution-driver.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { createCommandExecutorStub, createFsStub, createNetworkStub, filterEnv,
55
import { executeWithRuntime } from "./execution.js";
66
import type { NetworkAdapter, RuntimeDriver } from "@secure-exec/core";
77
import type { StdioHook, ExecOptions, ExecResult, RunResult, TimingMitigation } from "@secure-exec/core/internal/shared/api-types";
8-
import { type DriverDeps, type NodeExecutionDriverOptions, createBudgetState, clearActiveHostTimers, killActiveChildProcesses, normalizePayloadLimit, getExecutionTimeoutMs, getTimingMitigation, DEFAULT_BRIDGE_BASE64_TRANSFER_BYTES, DEFAULT_ISOLATE_JSON_PAYLOAD_BYTES, DEFAULT_MAX_TIMERS, DEFAULT_SANDBOX_CWD, DEFAULT_SANDBOX_HOME, DEFAULT_SANDBOX_TMPDIR } from "./isolate-bootstrap.js";
8+
import { type DriverDeps, type NodeExecutionDriverOptions, createBudgetState, clearActiveHostTimers, killActiveChildProcesses, normalizePayloadLimit, getExecutionTimeoutMs, getTimingMitigation, DEFAULT_BRIDGE_BASE64_TRANSFER_BYTES, DEFAULT_ISOLATE_JSON_PAYLOAD_BYTES, DEFAULT_MAX_TIMERS, DEFAULT_MAX_HANDLES, DEFAULT_SANDBOX_CWD, DEFAULT_SANDBOX_HOME, DEFAULT_SANDBOX_TMPDIR } from "./isolate-bootstrap.js";
99
import { shouldRunAsESM } from "./module-resolver.js";
1010
import { precompileDynamicImports, runESM, setupDynamicImport } from "./esm-compiler.js";
1111
import { setupConsole, setupRequire, setupESMGlobals } from "./bridge-setup.js";
@@ -78,6 +78,7 @@ export class NodeExecutionDriver implements RuntimeDriver {
7878
maxBridgeCalls: budgets?.maxBridgeCalls,
7979
maxTimers: budgets?.maxTimers ?? DEFAULT_MAX_TIMERS,
8080
maxChildProcesses: budgets?.maxChildProcesses,
81+
maxHandles: budgets?.maxHandles ?? DEFAULT_MAX_HANDLES,
8182
budgetState: createBudgetState(),
8283
activeHttpServerIds: new Set(),
8384
activeChildProcesses: new Map(),

packages/secure-exec-node/src/isolate-bootstrap.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export interface DriverDeps {
4545
maxBridgeCalls?: number;
4646
maxTimers?: number;
4747
maxChildProcesses?: number;
48+
maxHandles?: number;
4849
budgetState: BudgetState;
4950
activeHttpServerIds: Set<number>;
5051
activeChildProcesses: Map<number, SpawnedProcess>;
@@ -66,6 +67,7 @@ export const MAX_CONFIGURED_PAYLOAD_BYTES = 64 * 1024 * 1024;
6667
export const PAYLOAD_LIMIT_ERROR_CODE = "ERR_SANDBOX_PAYLOAD_TOO_LARGE";
6768
export const RESOURCE_BUDGET_ERROR_CODE = "ERR_RESOURCE_BUDGET_EXCEEDED";
6869
export const DEFAULT_MAX_TIMERS = 10_000;
70+
export const DEFAULT_MAX_HANDLES = 10_000;
6971
export const DEFAULT_SANDBOX_CWD = "/root";
7072
export const DEFAULT_SANDBOX_HOME = "/root";
7173
export const DEFAULT_SANDBOX_TMPDIR = "/tmp";

packages/secure-exec/tests/runtime-driver/node/resource-budgets.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -558,6 +558,88 @@ describe("NodeRuntime resource budgets", () => {
558558
});
559559
});
560560

561+
// -----------------------------------------------------------------------
562+
// maxHandles
563+
// -----------------------------------------------------------------------
564+
565+
describe("maxHandles", () => {
566+
it("register maxHandles+1 handles — last one throws", async () => {
567+
const capture = createConsoleCapture();
568+
proc = createTestNodeRuntime({
569+
onStdio: capture.onStdio,
570+
resourceBudgets: { maxHandles: 3 },
571+
});
572+
573+
const result = await proc.exec(`
574+
let succeeded = 0;
575+
let errors = 0;
576+
for (let i = 0; i < 5; i++) {
577+
try {
578+
_registerHandle('test:' + i, 'test handle ' + i);
579+
succeeded++;
580+
} catch (e) {
581+
if (e.message.includes('ERR_RESOURCE_BUDGET_EXCEEDED')) errors++;
582+
}
583+
}
584+
// Cleanup registered handles so sandbox can exit
585+
for (let i = 0; i < 3; i++) _unregisterHandle('test:' + i);
586+
console.log('succeeded:' + succeeded);
587+
console.log('errors:' + errors);
588+
`);
589+
590+
expect(result.code).toBe(0);
591+
const out = capture.stdout();
592+
expect(out).toContain("succeeded:3");
593+
expect(out).toContain("errors:2");
594+
});
595+
596+
it("register handles, remove some, register more — works up to cap", async () => {
597+
const capture = createConsoleCapture();
598+
proc = createTestNodeRuntime({
599+
onStdio: capture.onStdio,
600+
resourceBudgets: { maxHandles: 3 },
601+
});
602+
603+
const result = await proc.exec(`
604+
let firstBatch = 0;
605+
let secondBatch = 0;
606+
let errors = 0;
607+
608+
// First batch: register 3 handles (fills cap)
609+
for (let i = 0; i < 3; i++) {
610+
try {
611+
_registerHandle('batch1:' + i, 'first batch ' + i);
612+
firstBatch++;
613+
} catch (e) { errors++; }
614+
}
615+
616+
// Remove first batch handles (frees slots)
617+
for (let i = 0; i < 3; i++) _unregisterHandle('batch1:' + i);
618+
619+
// Second batch: register 3 more (slots freed)
620+
for (let i = 0; i < 3; i++) {
621+
try {
622+
_registerHandle('batch2:' + i, 'second batch ' + i);
623+
secondBatch++;
624+
} catch (e) { errors++; }
625+
}
626+
627+
// Cleanup
628+
for (let i = 0; i < 3; i++) _unregisterHandle('batch2:' + i);
629+
630+
console.log('firstBatch:' + firstBatch);
631+
console.log('secondBatch:' + secondBatch);
632+
console.log('errors:' + errors);
633+
`);
634+
635+
expect(result.code).toBe(0);
636+
const out = capture.stdout();
637+
expect(out).toContain("firstBatch:3");
638+
expect(out).toContain("secondBatch:3");
639+
expect(out).toContain("errors:0");
640+
});
641+
});
642+
561643
describe("host timer cleanup", () => {
562644
it("clears host timers on dispose after normal execution", async () => {
563645
proc = createTestNodeRuntime({});

0 commit comments

Comments
 (0)