Skip to content

Commit bf0f345

Browse files
committed
fix(execution): address review on the dedicated worker host
- Host: reject an unparseable upgrade target instead of throwing; bound unauthenticated connections separately so they never hold a worker slot. - Pool: re-enqueue an execution whose selected worker was retired before dispatch; forward TZ/LANG/LC_ALL with the limits so remote and local workers produce the same Date/Intl results. - Keep the child_process import in its original form so the bundler does not trace the worker spawn; give ffmpeg's env NODE_ENV for ProcessEnv. - E2E: stop the suite when a check cannot settle; assert the exact truncation bound; cover the unparseable target.
1 parent 858ebe5 commit bf0f345

7 files changed

Lines changed: 83 additions & 43 deletions

File tree

‎.claude/rules/sim-sandbox.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,9 @@ payload or `ivm.Reference` wrapper in the worker:
7878
allowlist in `isolated-vm.ts` **and** to the allowlist regression test in
7979
`isolated-vm.test.ts` — the worker does not inherit the app environment,
8080
so an un-allowlisted var is simply absent in the child. If the app owns
81-
its value, also add it to `workerLimitsEnv()` and to
82-
`CLIENT_WORKER_ENV_KEYS` in `isolated-vm-host.cjs`, so a remote worker
83-
enforces the same value.
81+
its value, put it in `appWorkerEnv()` and add it with its accepted shape
82+
to `CLIENT_WORKER_ENV` in `isolated-vm-host.cjs`, so a remote worker
83+
gets the same value.
8484
- [ ] Does the host send the worker a new message type? Add it to
8585
`ALLOWED_INBOUND_TYPES` in `isolated-vm-host.cjs`, or remote workers
8686
never receive it.

‎.cursor/rules/sim-sandbox.mdc‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,9 @@ payload or `ivm.Reference` wrapper in the worker:
7474
allowlist in `isolated-vm.ts` **and** to the allowlist regression test in
7575
`isolated-vm.test.ts` — the worker does not inherit the app environment,
7676
so an un-allowlisted var is simply absent in the child. If the app owns
77-
its value, also add it to `workerLimitsEnv()` and to
78-
`CLIENT_WORKER_ENV_KEYS` in `isolated-vm-host.cjs`, so a remote worker
79-
enforces the same value.
77+
its value, put it in `appWorkerEnv()` and add it with its accepted shape
78+
to `CLIENT_WORKER_ENV` in `isolated-vm-host.cjs`, so a remote worker
79+
gets the same value.
8080
- [ ] Does the host send the worker a new message type? Add it to
8181
`ALLOWED_INBOUND_TYPES` in `isolated-vm-host.cjs`, or remote workers
8282
never receive it.

‎apps/sim/lib/execution/isolated-vm-host.cjs‎

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
*
1111
* Wire contract (`/v1/worker`): one JSON object per text frame. The first
1212
* client frame is `{ type: 'auth', token, env }`, where `env` carries the
13-
* worker limits the app enforces; after it, frames are the objects the worker
13+
* worker settings the app owns (limits, timezone, locale); after it, frames
14+
* are the objects the worker
1415
* exchanges over IPC. Host-originated frames are `{ type: 'host:heartbeat' }`
1516
* every 30s and `{ type: 'host:draining' }` on SIGTERM, after which the app
1617
* retires the connection once its executions finish. A breaking change to the
@@ -40,8 +41,14 @@ const MAX_STARTUP_STDERR_CHARS = 64 * 1024
4041
const MIN_TOKEN_LENGTH = 32
4142
const UNAUTHORIZED_CLOSE_CODE = 4401
4243
const ALLOWED_INBOUND_TYPES = new Set(['execute', 'cancel', 'fetchResponse', 'brokerResponse'])
43-
/** Worker limits a client may set, each a decimal integer. */
44-
const CLIENT_WORKER_ENV_KEYS = new Set(['IVM_MAX_STDOUT_CHARS', 'IVM_MAX_FETCH_OPTIONS_JSON_CHARS'])
44+
/** Worker settings a client may set, each with the only shape it accepts. */
45+
const CLIENT_WORKER_ENV = {
46+
IVM_MAX_STDOUT_CHARS: /^\d{1,12}$/,
47+
IVM_MAX_FETCH_OPTIONS_JSON_CHARS: /^\d{1,12}$/,
48+
TZ: /^:?[A-Za-z0-9_+\-/]{1,64}$/,
49+
LANG: /^[A-Za-z0-9_.@-]{1,64}$/,
50+
LC_ALL: /^[A-Za-z0-9_.@-]{1,64}$/,
51+
}
4552

4653
function readPositiveInt(name, fallback) {
4754
const parsed = Number.parseInt(process.env[name] || '', 10)
@@ -62,6 +69,8 @@ if (process.versions.bun) {
6269

6370
const PORT = readPositiveInt('IVM_HOST_PORT', 3100)
6471
const MAX_WORKERS = readPositiveInt('IVM_HOST_MAX_WORKERS', 16)
72+
/** Unauthenticated connections are bounded on their own, so they never hold a worker slot. */
73+
const MAX_PENDING_AUTH = MAX_WORKERS * 4
6574
const MAX_MESSAGE_BYTES = readPositiveInt('IVM_HOST_MAX_MESSAGE_BYTES', 64 * 1024 * 1024)
6675
const DRAIN_TIMEOUT_MS = readPositiveInt('IVM_HOST_DRAIN_TIMEOUT_MS', 110000)
6776

@@ -87,22 +96,18 @@ function isAuthorized(presented) {
8796
/**
8897
* Mirrors `buildWorkerEnv()` in `isolated-vm.ts`: the worker runs untrusted
8998
* code and receives an explicit allowlist, never this process's environment.
90-
* The limits come from the client so a remote worker enforces the app's values.
99+
* Settings the app owns come from the client, so a remote worker behaves like
100+
* a local one.
91101
*/
92102
function buildWorkerEnv(clientEnv) {
93103
const workerEnv = {}
94-
for (const key of ['PATH', 'NODE_ENV', 'TZ', 'LANG', 'LC_ALL']) {
104+
for (const key of ['PATH', 'NODE_ENV']) {
95105
if (process.env[key] !== undefined) workerEnv[key] = process.env[key]
96106
}
97107
if (clientEnv && typeof clientEnv === 'object') {
98-
for (const [key, value] of Object.entries(clientEnv)) {
99-
if (
100-
CLIENT_WORKER_ENV_KEYS.has(key) &&
101-
typeof value === 'string' &&
102-
/^\d{1,12}$/.test(value)
103-
) {
104-
workerEnv[key] = value
105-
}
108+
for (const [key, pattern] of Object.entries(CLIENT_WORKER_ENV)) {
109+
const value = clientEnv[key]
110+
if (typeof value === 'string' && pattern.test(value)) workerEnv[key] = value
106111
}
107112
}
108113
return workerEnv
@@ -154,23 +159,30 @@ const wss = new WebSocketServer({
154159

155160
server.on('upgrade', (req, socket, head) => {
156161
socket.on('error', () => socket.destroy())
157-
if (new URL(req.url || '/', 'http://host').pathname !== WORKER_PATH) {
162+
let pathname
163+
try {
164+
pathname = new URL(req.url || '/', 'http://host').pathname
165+
} catch {
166+
rejectUpgrade(socket, 400, 'Bad Request')
167+
return
168+
}
169+
if (pathname !== WORKER_PATH) {
158170
rejectUpgrade(socket, 404, 'Not Found')
159171
return
160172
}
161173
if (draining) {
162174
rejectUpgrade(socket, 503, 'Service Unavailable')
163175
return
164176
}
165-
if (workers.size + pendingConnections >= MAX_WORKERS) {
177+
if (workers.size >= MAX_WORKERS || pendingConnections >= MAX_PENDING_AUTH) {
166178
log('warn', 'Rejected worker connection at capacity', { maxWorkers: MAX_WORKERS })
167179
rejectUpgrade(socket, 503, 'Service Unavailable')
168180
return
169181
}
170182
wss.handleUpgrade(req, socket, head, (ws) => authenticate(ws))
171183
})
172184

173-
/** Holds a capacity slot until the client's first frame proves it holds the token. */
185+
/** Admits the connection to a worker slot only once its first frame proves it holds the token. */
174186
function authenticate(ws) {
175187
pendingConnections++
176188
let settled = false
@@ -206,6 +218,11 @@ function authenticate(ws) {
206218
ws.close(1012, 'draining')
207219
return
208220
}
221+
if (workers.size >= MAX_WORKERS) {
222+
log('warn', 'Rejected worker connection at capacity', { maxWorkers: MAX_WORKERS })
223+
ws.close(1013, 'at capacity')
224+
return
225+
}
209226
ws.off('error', onPreAuthError)
210227
attachWorker(ws, buildWorkerEnv(message.env))
211228
})

‎apps/sim/lib/execution/isolated-vm-remote.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export interface IsolatedVMWorkerProcess {
2727
export interface RemoteWorkerOptions {
2828
url: string
2929
token: string
30-
/** Limits the worker reads from its environment, so a remote worker enforces the app's values. */
30+
/** Worker settings the app owns (limits, timezone, locale), so a remote worker matches a local one. */
3131
workerEnv: Record<string, string | undefined>
3232
}
3333

‎apps/sim/lib/execution/isolated-vm.ts‎

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1013,10 +1013,7 @@ function resetWorkerIdleTimeout(workerId: number) {
10131013
function buildWorkerEnv(): NodeJS.ProcessEnv {
10141014
const allowed: Record<string, string | undefined> = {
10151015
PATH: process.env.PATH,
1016-
...workerLimitsEnv(),
1017-
TZ: process.env.TZ,
1018-
LANG: process.env.LANG,
1019-
LC_ALL: process.env.LC_ALL,
1016+
...appWorkerEnv(),
10201017
SYSTEMROOT: process.env.SYSTEMROOT,
10211018
WINDIR: process.env.WINDIR,
10221019
COMSPEC: process.env.COMSPEC,
@@ -1027,11 +1024,18 @@ function buildWorkerEnv(): NodeJS.ProcessEnv {
10271024
return { ...filterUndefined(allowed), NODE_ENV: process.env.NODE_ENV }
10281025
}
10291026

1030-
/** The limits the worker reads from its environment; a remote worker receives them on connect. */
1031-
function workerLimitsEnv(): Record<string, string | undefined> {
1027+
/**
1028+
* Worker settings whose value the app owns: its limits, and the timezone and locale that shape
1029+
* `Date` and `Intl` inside isolates. A remote worker receives them on connect, so moving workers
1030+
* to the host changes no results.
1031+
*/
1032+
function appWorkerEnv(): Record<string, string | undefined> {
10321033
return {
10331034
IVM_MAX_STDOUT_CHARS: env.IVM_MAX_STDOUT_CHARS,
10341035
IVM_MAX_FETCH_OPTIONS_JSON_CHARS: env.IVM_MAX_FETCH_OPTIONS_JSON_CHARS,
1036+
TZ: process.env.TZ,
1037+
LANG: process.env.LANG,
1038+
LC_ALL: process.env.LC_ALL,
10351039
}
10361040
}
10371041

@@ -1047,7 +1051,7 @@ async function openWorkerProcess(target: WorkerTarget): Promise<IsolatedVMWorker
10471051
return connectRemoteWorker({
10481052
url: target.url,
10491053
token: target.token,
1050-
workerEnv: workerLimitsEnv(),
1054+
workerEnv: appWorkerEnv(),
10511055
})
10521056
}
10531057

@@ -1070,13 +1074,14 @@ async function openWorkerProcess(target: WorkerTarget): Promise<IsolatedVMWorker
10701074
throw new Error(`Worker file not found at any of: ${candidatePaths.join(', ')}`)
10711075
}
10721076

1073-
const { spawn } = await import('node:child_process')
1074-
// Required for isolated-vm on Node.js 20+ (issue #377)
1075-
return spawn('node', ['--no-node-snapshot', workerPath], {
1076-
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
1077-
serialization: 'json',
1078-
env: buildWorkerEnv(),
1079-
})
1077+
return import('node:child_process').then(({ spawn }) =>
1078+
// Required for isolated-vm on Node.js 20+ (issue #377)
1079+
spawn('node', ['--no-node-snapshot', workerPath], {
1080+
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
1081+
serialization: 'json',
1082+
env: buildWorkerEnv(),
1083+
})
1084+
)
10801085
}
10811086

10821087
function spawnWorker(): Promise<WorkerInfo> {
@@ -1259,6 +1264,12 @@ function dispatchToWorker(
12591264
return
12601265
}
12611266

1267+
// A target change can retire and remove the worker between its selection and this dispatch.
1268+
if (workers.get(workerInfo.id) !== workerInfo) {
1269+
enqueueExecution(ownerState, req, resolve, state, brokers)
1270+
return
1271+
}
1272+
12621273
const execId = ++executionIdCounter
12631274
state.workerId = workerInfo.id
12641275
state.execId = execId

‎apps/sim/lib/media/ffmpeg-process.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,13 +70,14 @@ export function resolveExecutable(binary: string): string | null {
7070
* and fonts, never the variables this process was configured with.
7171
*/
7272
function executableEnv(): NodeJS.ProcessEnv {
73-
return filterUndefined({
73+
const allowed = filterUndefined({
7474
PATH: process.env.PATH,
7575
HOME: process.env.HOME,
7676
FONTCONFIG_FILE: process.env.FONTCONFIG_FILE,
7777
FONTCONFIG_PATH: process.env.FONTCONFIG_PATH,
7878
SYSTEMROOT: process.env.SYSTEMROOT,
7979
})
80+
return { ...allowed, NODE_ENV: process.env.NODE_ENV }
8081
}
8182

8283
/**

‎apps/sim/scripts/test-isolated-vm-remote-e2e.ts‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -205,14 +205,15 @@ const CHECK_DEADLINE_MS = 30_000
205205
async function check(name: string, run: () => Promise<void>) {
206206
const started = performance.now()
207207
let deadline: ReturnType<typeof setTimeout> | undefined
208+
let timedOut = false
208209
try {
209210
await Promise.race([
210211
run(),
211212
new Promise<never>((_, reject) => {
212-
deadline = setTimeout(
213-
() => reject(new Error(`Did not settle within ${CHECK_DEADLINE_MS}ms`)),
214-
CHECK_DEADLINE_MS
215-
)
213+
deadline = setTimeout(() => {
214+
timedOut = true
215+
reject(new Error(`Did not settle within ${CHECK_DEADLINE_MS}ms`))
216+
}, CHECK_DEADLINE_MS)
216217
}),
217218
])
218219
checks.push({ name, status: 'passed', durationMs: Math.round(performance.now() - started) })
@@ -226,6 +227,9 @@ async function check(name: string, run: () => Promise<void>) {
226227
})
227228
logger.error(`FAIL ${name}`, { error: getErrorMessage(error) })
228229
process.exitCode = 1
230+
// A check that never settled may still be running against the shared hosts and pool, so
231+
// later checks would not be independent of it.
232+
if (timedOut) throw new Error(`Aborting: "${name}" did not settle`)
229233
} finally {
230234
clearTimeout(deadline)
231235
}
@@ -271,8 +275,9 @@ async function run() {
271275
await check("enforces the app's limits on the remote worker", async () => {
272276
const result = await execute("console.log('y'.repeat(5000)); return 1")
273277
assert.equal(result.result, 1, result.error?.message)
278+
assert(result.stdout.endsWith('[stdout truncated]\n'), 'stdout was not truncated')
274279
assert(
275-
result.stdout.length < 2 * STDOUT_LIMIT_CHARS,
280+
result.stdout.length <= STDOUT_LIMIT_CHARS + '\n[stdout truncated]\n'.length,
276281
`stdout was ${result.stdout.length} chars; the app limit is ${STDOUT_LIMIT_CHARS}`
277282
)
278283
})
@@ -340,6 +345,12 @@ async function run() {
340345
assert.equal(await closeCode(ws), 4401)
341346
})
342347

348+
await check('rejects an unparseable request target and keeps serving', async () => {
349+
assert.equal(await upgradeStatus(probeHost, { path: '//[' }), 400)
350+
const health = await fetch(`http://127.0.0.1:${probeHost.port}/health`)
351+
assert.equal(health.status, 200)
352+
})
353+
343354
await check('rejects paths other than the worker endpoint', async () => {
344355
assert.equal(await upgradeStatus(probeHost, { path: '/v0/worker' }), 404)
345356
})

0 commit comments

Comments
 (0)