Files
simstudioai--sim/apps/sim/lib/execution/isolated-vm-worker.cjs
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

1186 lines
40 KiB
JavaScript

/**
* Node.js worker for isolated-vm execution.
* Runs in a separate Node.js process, communicates with parent via IPC.
*/
const ivm = require('isolated-vm')
const fs = require('node:fs')
const path = require('node:path')
const USER_CODE_START_LINE = 4
const pendingFetches = new Map()
let fetchIdCounter = 0
const pendingBrokerCalls = new Map()
let brokerIdCounter = 0
const FETCH_TIMEOUT_MS = 300000 // 5 minutes
const BROKER_TIMEOUT_MS = 300000
const MAX_STDOUT_CHARS = Number.parseInt(process.env.IVM_MAX_STDOUT_CHARS || '', 10) || 200000
const MAX_FETCH_OPTIONS_JSON_CHARS =
Number.parseInt(process.env.IVM_MAX_FETCH_OPTIONS_JSON_CHARS || '', 10) || 256 * 1024
const SANDBOX_BUNDLE_DIR = path.join(__dirname, 'sandbox', 'bundles')
const SANDBOX_BUNDLE_FILES = {
pptxgenjs: 'pptxgenjs.cjs',
docx: 'docx.cjs',
'pdf-lib': 'pdf-lib.cjs',
}
const bundleSourceCache = new Map()
const activeIsolates = new Map()
/**
* Sends an IPC request and reports only actual delivery failures.
* Node queues messages under backpressure, so the boolean return value is not
* a failure signal.
*/
function sendIpcRequest(message, onError) {
try {
process.send(message, (err) => {
if (err) onError(err)
})
} catch (error) {
onError(error instanceof Error ? error : new Error(String(error)))
}
}
function getBundleSource(bundleName) {
const cached = bundleSourceCache.get(bundleName)
if (cached) return cached
const fileName = SANDBOX_BUNDLE_FILES[bundleName]
if (!fileName) {
throw new Error(`Unknown sandbox bundle: ${bundleName}`)
}
const bundlePath = path.join(SANDBOX_BUNDLE_DIR, fileName)
if (!fs.existsSync(bundlePath)) {
throw new Error(
`Sandbox bundle not found at ${bundlePath}. Run \`bun run build:sandbox-bundles\`.`
)
}
const source = fs.readFileSync(bundlePath, 'utf-8')
bundleSourceCache.set(bundleName, { source, fileName })
return bundleSourceCache.get(bundleName)
}
function stringifyLogValue(value) {
if (typeof value !== 'object' || value === null) {
return String(value)
}
try {
return JSON.stringify(value)
} catch {
return '[unserializable]'
}
}
/**
* Extract line and column from error stack or message
*/
function extractLineInfo(errorMessage, stack) {
if (stack) {
const stackMatch = stack.match(/(?:<isolated-vm>|user-function\.js):(\d+):(\d+)/)
if (stackMatch) {
return {
line: Number.parseInt(stackMatch[1], 10),
column: Number.parseInt(stackMatch[2], 10),
}
}
const atMatch = stack.match(/at\s+(?:<isolated-vm>|user-function\.js):(\d+):(\d+)/)
if (atMatch) {
return {
line: Number.parseInt(atMatch[1], 10),
column: Number.parseInt(atMatch[2], 10),
}
}
}
const msgMatch = errorMessage.match(/:(\d+):(\d+)/)
if (msgMatch) {
return {
line: Number.parseInt(msgMatch[1], 10),
column: Number.parseInt(msgMatch[2], 10),
}
}
return {}
}
/**
* Convert isolated-vm error info to a format compatible with the route's error handling
*/
function convertToCompatibleError(errorInfo, userCode) {
const { name } = errorInfo
let { message, stack } = errorInfo
message = message
.replace(/\s*\[user-function\.js:\d+:\d+\]/g, '')
.replace(/\s*\[<isolated-vm>:\d+:\d+\]/g, '')
.replace(/\s*\(<isolated-vm>:\d+:\d+\)/g, '')
.trim()
const lineInfo = extractLineInfo(errorInfo.message, stack)
let userLine
let lineContent
if (lineInfo.line !== undefined) {
userLine = lineInfo.line - USER_CODE_START_LINE
const codeLines = userCode.split('\n')
if (userLine > 0 && userLine <= codeLines.length) {
lineContent = codeLines[userLine - 1]?.trim()
} else if (userLine <= 0) {
userLine = 1
lineContent = codeLines[0]?.trim()
} else {
userLine = codeLines.length
lineContent = codeLines[codeLines.length - 1]?.trim()
}
}
if (stack) {
stack = stack.replace(/<isolated-vm>:(\d+):(\d+)/g, (_, line, col) => {
const adjustedLine = Number.parseInt(line, 10) - USER_CODE_START_LINE
return `user-function.js:${Math.max(1, adjustedLine)}:${col}`
})
stack = stack.replace(/at <isolated-vm>:(\d+):(\d+)/g, (_, line, col) => {
const adjustedLine = Number.parseInt(line, 10) - USER_CODE_START_LINE
return `at user-function.js:${Math.max(1, adjustedLine)}:${col}`
})
}
return {
message,
name,
stack,
line: userLine,
column: lineInfo.column,
lineContent,
}
}
/**
* Execute code in isolated-vm
*/
async function executeCode(request, executionId) {
const { code, params, envVars, contextVariables, timeoutMs, requestId } = request
const stdoutChunks = []
let stdoutLength = 0
let stdoutTruncated = false
let isolate = null
const appendStdout = (line) => {
if (stdoutTruncated || !line) return
const remaining = MAX_STDOUT_CHARS - stdoutLength
if (remaining <= 0) {
stdoutTruncated = true
stdoutChunks.push('[stdout truncated]\n')
return
}
if (line.length <= remaining) {
stdoutChunks.push(line)
stdoutLength += line.length
return
}
stdoutChunks.push(line.slice(0, remaining))
stdoutChunks.push('\n[stdout truncated]\n')
stdoutLength = MAX_STDOUT_CHARS
stdoutTruncated = true
}
let context = null
let bootstrapScript = null
let userScript = null
let logCallback = null
let errorCallback = null
let fetchCallback = null
let brokerCallback = null
const externalCopies = []
try {
isolate = new ivm.Isolate({ memoryLimit: 128 })
if (executionId !== undefined) activeIsolates.set(executionId, isolate)
context = await isolate.createContext()
const jail = context.global
await jail.set('global', jail.derefInto())
logCallback = new ivm.Callback((...args) => {
const message = args.map((arg) => stringifyLogValue(arg)).join(' ')
appendStdout(`${message}\n`)
})
await jail.set('__log', logCallback)
errorCallback = new ivm.Callback((...args) => {
const message = args.map((arg) => stringifyLogValue(arg)).join(' ')
appendStdout(`ERROR: ${message}\n`)
})
await jail.set('__error', errorCallback)
const paramsCopy = new ivm.ExternalCopy(params)
externalCopies.push(paramsCopy)
await jail.set('params', paramsCopy.copyInto())
const envVarsCopy = new ivm.ExternalCopy(envVars)
externalCopies.push(envVarsCopy)
await jail.set('environmentVariables', envVarsCopy.copyInto())
for (const [key, value] of Object.entries(contextVariables)) {
if (value === undefined) {
await jail.set(key, undefined)
} else if (value === null) {
await jail.set(key, null)
} else {
const ctxCopy = new ivm.ExternalCopy(value)
externalCopies.push(ctxCopy)
await jail.set(key, ctxCopy.copyInto())
}
}
fetchCallback = new ivm.Reference(async (url, optionsJson) => {
return new Promise((resolve) => {
const fetchId = ++fetchIdCounter
const timeout = setTimeout(() => {
if (pendingFetches.has(fetchId)) {
pendingFetches.delete(fetchId)
resolve(JSON.stringify({ error: 'Fetch request timed out' }))
}
}, FETCH_TIMEOUT_MS)
pendingFetches.set(fetchId, { resolve, timeout })
if (!process.send || !process.connected) {
clearTimeout(timeout)
pendingFetches.delete(fetchId)
resolve(JSON.stringify({ error: 'Parent process disconnected' }))
return
}
sendIpcRequest({ type: 'fetch', fetchId, requestId, url, optionsJson }, (err) => {
const pending = pendingFetches.get(fetchId)
if (!pending) return
clearTimeout(pending.timeout)
pendingFetches.delete(fetchId)
pending.resolve(JSON.stringify({ error: `Fetch IPC send failed: ${err.message}` }))
})
})
})
await jail.set('__fetchRef', fetchCallback)
brokerCallback = new ivm.Reference(async (brokerName, argsJson) => {
return new Promise((resolve) => {
const brokerId = ++brokerIdCounter
const timeout = setTimeout(() => {
if (pendingBrokerCalls.has(brokerId)) {
pendingBrokerCalls.delete(brokerId)
resolve(JSON.stringify({ error: `Broker "${brokerName}" timed out` }))
}
}, BROKER_TIMEOUT_MS)
pendingBrokerCalls.set(brokerId, { resolve, timeout, executionId })
if (!process.send || !process.connected) {
clearTimeout(timeout)
pendingBrokerCalls.delete(brokerId)
resolve(JSON.stringify({ error: 'Parent process disconnected' }))
return
}
sendIpcRequest({ type: 'broker', brokerId, executionId, brokerName, argsJson }, (err) => {
const pending = pendingBrokerCalls.get(brokerId)
if (!pending) return
clearTimeout(pending.timeout)
pendingBrokerCalls.delete(brokerId)
pending.resolve(JSON.stringify({ error: `Broker IPC send failed: ${err.message}` }))
})
})
})
await jail.set('__brokerRef', brokerCallback)
const bootstrap = `
// Set up console object
const console = {
log: (...args) => __log(...args),
error: (...args) => __error(...args),
warn: (...args) => __log('WARN:', ...args),
info: (...args) => __log(...args),
};
// Set up fetch function that uses the host's secure fetch
async function fetch(url, options) {
let optionsJson;
if (options) {
try {
optionsJson = JSON.stringify(options);
} catch {
throw new Error('fetch options must be JSON-serializable');
}
if (optionsJson.length > ${MAX_FETCH_OPTIONS_JSON_CHARS}) {
throw new Error('fetch options exceed maximum payload size');
}
}
const resultJson = await __fetchRef.apply(undefined, [url, optionsJson], { result: { promise: true } });
let result;
try {
result = JSON.parse(resultJson);
} catch {
throw new Error('Invalid fetch response');
}
if (typeof result.error === 'string') {
throw new Error(result.error || 'Fetch failed');
}
// Create a Response-like object
return {
ok: result.ok,
status: result.status,
statusText: result.statusText,
headers: {
get: (name) => result.headers[name.toLowerCase()] || null,
entries: () => Object.entries(result.headers),
},
text: async () => result.body,
json: async () => {
try {
return JSON.parse(result.body);
} catch (e) {
throw new Error('Failed to parse response as JSON: ' + e.message);
}
},
blob: async () => { throw new Error('blob() not supported in sandbox'); },
arrayBuffer: async () => { throw new Error('arrayBuffer() not supported in sandbox'); },
};
}
const sim = (() => {
const broker = __brokerRef;
async function callSimBroker(name, args) {
let argsJson;
try {
argsJson = args === undefined ? undefined : JSON.stringify(args);
} catch {
throw new Error('sim helper arguments must be JSON-serializable');
}
if (argsJson && argsJson.length > ${MAX_FETCH_OPTIONS_JSON_CHARS}) {
throw new Error('sim helper arguments exceed maximum payload size');
}
const responseJson = await broker.apply(undefined, [name, argsJson], { result: { promise: true } });
let response;
try {
response = JSON.parse(responseJson);
} catch {
throw new Error('Invalid sim helper response');
}
if (typeof response.error === 'string') {
throw new Error(response.error || 'Sim helper call failed');
}
return response.resultJson === undefined || response.resultJson === null
? null
: JSON.parse(response.resultJson);
}
return Object.freeze({
files: Object.freeze({
readBase64: (file, options) => callSimBroker('sim.files.readBase64', { file, options }),
readText: (file, options) => callSimBroker('sim.files.readText', { file, options }),
readBase64Chunk: (file, options) => callSimBroker('sim.files.readBase64Chunk', { file, options }),
readTextChunk: (file, options) => callSimBroker('sim.files.readTextChunk', { file, options }),
}),
values: Object.freeze({
read: (ref, options) => callSimBroker('sim.values.read', { ref, options }),
readArray: (ref, options) => callSimBroker('sim.values.readArray', { ref, options }),
}),
});
})();
Object.defineProperty(global, 'sim', {
value: sim,
writable: false,
configurable: false,
enumerable: true
});
// Prevent access to dangerous globals with stronger protection
const undefined_globals = [
'Isolate', 'Context', 'Script', 'Module', 'Callback', 'Reference',
'ExternalCopy', 'process', 'require', 'module', 'exports', '__dirname', '__filename',
'__brokerRef', '__broker', '__callSimBroker'
];
for (const name of undefined_globals) {
try {
Object.defineProperty(global, name, {
value: undefined,
writable: false,
configurable: false
});
} catch {}
}
`
bootstrapScript = await isolate.compileScript(bootstrap)
await bootstrapScript.run(context)
const wrappedCode = `
(async () => {
try {
const __userResult = await (async () => {
${code}
})();
return JSON.stringify({ success: true, result: __userResult });
} catch (error) {
// Capture full error details including stack trace
const errorInfo = {
message: error.message || String(error),
name: error.name || 'Error',
stack: error.stack || ''
};
console.error(error.stack || error.message || error);
return JSON.stringify({ success: false, errorInfo });
}
})()
`
userScript = await isolate.compileScript(wrappedCode, { filename: 'user-function.js' })
const resultJson = await userScript.run(context, { timeout: timeoutMs, promise: true })
let result = null
let error
if (typeof resultJson === 'string') {
try {
const parsed = JSON.parse(resultJson)
if (parsed.success) {
result = parsed.result
} else if (parsed.errorInfo) {
error = convertToCompatibleError(parsed.errorInfo, code)
} else {
error = { message: 'Unknown error', name: 'Error' }
}
} catch {
result = resultJson
}
}
const stdout = stdoutChunks.join('')
if (error) {
return { result: null, stdout, error }
}
return { result, stdout }
} catch (err) {
const stdout = stdoutChunks.join('')
if (err instanceof Error) {
const errorInfo = {
message: err.message,
name: err.name,
stack: err.stack,
}
// OOM check must run before the isDisposed guard: isolate OOM auto-disposes
// the isolate (isDisposed becomes true), so the cancel branch would fire first
// and mask the real cause. Message-based detection disambiguates the two.
if (
err.message.includes('Array buffer allocation failed') ||
err.message.includes('memory limit')
) {
return {
result: null,
stdout,
error: {
message:
'Execution exceeded memory limit (128 MB). Reduce image sizes or split the work into smaller batches.',
name: 'MemoryLimitError',
},
}
}
// Host sent a `cancel` IPC which called `isolate.dispose()`. Any
// in-flight compileScript/run then throws; detect that authoritatively
// via the isolate flag rather than fuzzy-matching the error message.
if (isolate?.isDisposed) {
return {
result: null,
stdout,
error: { message: 'Execution cancelled', name: 'AbortError' },
}
}
if (err.message.includes('Script execution timed out')) {
return {
result: null,
stdout,
error: {
message: `Execution timed out after ${timeoutMs}ms`,
name: 'TimeoutError',
},
}
}
return {
result: null,
stdout,
error: convertToCompatibleError(errorInfo, code),
}
}
return {
result: null,
stdout,
error: {
message: String(err),
name: 'Error',
line: 1,
lineContent: code.split('\n')[0]?.trim(),
},
}
} finally {
const releaseables = [
userScript,
bootstrapScript,
...externalCopies,
fetchCallback,
brokerCallback,
errorCallback,
logCallback,
context,
]
for (const obj of releaseables) {
if (obj) {
try {
obj.release()
} catch {}
}
}
if (isolate) {
try {
isolate.dispose()
} catch {}
}
if (executionId !== undefined) activeIsolates.delete(executionId)
}
}
/**
* Task-mode execution. Loads pre-built library bundles into the isolate,
* exposes host-side brokers as isolate globals under `__brokers.<name>(args)`,
* runs the task bootstrap (which installs friendly names on globalThis),
* executes user code, then runs `finalize` (must return a Uint8Array). The
* resulting bytes are returned as base64 in `bytesBase64`.
*/
async function executeTask(request, executionId) {
const { code, timeoutMs, task } = request
const stdoutChunks = []
let stdoutLength = 0
let stdoutTruncated = false
let isolate = null
const appendStdout = (line) => {
if (stdoutTruncated || !line) return
const remaining = MAX_STDOUT_CHARS - stdoutLength
if (remaining <= 0) {
stdoutTruncated = true
stdoutChunks.push('[stdout truncated]\n')
return
}
if (line.length <= remaining) {
stdoutChunks.push(line)
stdoutLength += line.length
return
}
stdoutChunks.push(line.slice(0, remaining))
stdoutChunks.push('\n[stdout truncated]\n')
stdoutLength = MAX_STDOUT_CHARS
stdoutTruncated = true
}
let context = null
const releaseables = []
// Timer bookkeeping — hoisted out of the try so the finally can always
// sweep regardless of where execution throws.
let nextTimerId = 1
const timers = new Map()
const cleanupTimers = () => {
for (const entry of timers.values()) {
try {
if (entry.recurring) clearInterval(entry.nodeTimer)
else clearTimeout(entry.nodeTimer)
} catch {}
try {
entry.fnRef.release()
} catch {}
}
timers.clear()
}
// Phase timings (ms). Populated inline during execution; returned in
// every result shape so the host can log where time is spent per request.
const timings = {
setup: 0,
runtimeBootstrap: 0,
bundles: 0,
brokerInstall: 0,
taskBootstrap: 0,
harden: 0,
userCode: 0,
finalize: 0,
total: 0,
}
const tStart = Date.now()
let tPhase = tStart
try {
isolate = new ivm.Isolate({ memoryLimit: 128 })
if (executionId !== undefined) activeIsolates.set(executionId, isolate)
context = await isolate.createContext()
const jail = context.global
await jail.set('global', jail.derefInto())
const logCallback = new ivm.Callback((...args) => {
const message = args.map((arg) => stringifyLogValue(arg)).join(' ')
appendStdout(`${message}\n`)
})
releaseables.push(logCallback)
await jail.set('__log', logCallback)
const errorCallback = new ivm.Callback((...args) => {
const message = args.map((arg) => stringifyLogValue(arg)).join(' ')
appendStdout(`ERROR: ${message}\n`)
})
releaseables.push(errorCallback)
await jail.set('__error', errorCallback)
// Delegate TextEncoder / TextDecoder to Node's native implementations
// (C++-backed, WHATWG-compliant). The isolate-side classes installed in
// the runtime bootstrap below call into these via closure-captured refs
// so hardening can safely undefine the raw globals afterwards.
const nodeEncoder = new TextEncoder()
const nodeDecoder = new TextDecoder()
const textEncodeCallback = new ivm.Callback((str) =>
nodeEncoder.encode(typeof str === 'string' ? str : String(str ?? ''))
)
releaseables.push(textEncodeCallback)
await jail.set('__textEncode', textEncodeCallback)
const textDecodeCallback = new ivm.Callback((bytes) => {
if (!bytes) return ''
return nodeDecoder.decode(bytes)
})
releaseables.push(textDecodeCallback)
await jail.set('__textDecode', textDecodeCallback)
// Delegate timers to Node's real timer heap via ivm.Reference (the pattern
// recommended by laverdet/isolated-vm#136). Host-side bookkeeping was
// hoisted to the function scope above so the finally can always sweep.
//
// Note on arg marshaling: we call the reference with
// `arguments: { reference: true }` from the isolate so the function arg
// crosses as a Reference (functions aren't transferable by default).
// That option applies uniformly, so primitive args (ms, id) also arrive
// as `Reference<primitive>`. The `unwrapPrimitive` helper calls
// `.copySync()` to get the real value. Numbers/strings/booleans are
// supported; anything exotic falls back to `undefined`.
const unwrapPrimitive = (v) => {
if (v === null || v === undefined) return v
const t = typeof v
if (t === 'number' || t === 'string' || t === 'boolean') return v
if (v && typeof v.copySync === 'function') {
try {
return v.copySync()
} catch {
return undefined
}
}
return v
}
const setTimeoutRef = new ivm.Reference((fnRef, msRef) => {
const id = nextTimerId++
const delay = Math.max(0, Math.min(Number(unwrapPrimitive(msRef)) || 0, timeoutMs))
const nodeTimer = setTimeout(() => {
const entry = timers.get(id)
if (!entry) return
timers.delete(id)
try {
fnRef.applyIgnored(undefined, [], { timeout: timeoutMs })
} catch {
// isolate disposed between schedule and fire — callback silently dropped
}
try {
fnRef.release()
} catch {}
}, delay)
timers.set(id, { nodeTimer, fnRef, recurring: false })
return id
})
releaseables.push(setTimeoutRef)
await jail.set('__setTimeoutRef', setTimeoutRef)
const clearTimeoutRef = new ivm.Reference((idRef) => {
const key = Number(unwrapPrimitive(idRef))
if (!Number.isFinite(key)) return
const entry = timers.get(key)
if (!entry) return
try {
if (entry.recurring) clearInterval(entry.nodeTimer)
else clearTimeout(entry.nodeTimer)
} catch {}
try {
entry.fnRef.release()
} catch {}
timers.delete(key)
})
releaseables.push(clearTimeoutRef)
await jail.set('__clearTimeoutRef', clearTimeoutRef)
const setIntervalRef = new ivm.Reference((fnRef, msRef) => {
const id = nextTimerId++
const delay = Math.max(1, Math.min(Number(unwrapPrimitive(msRef)) || 1, timeoutMs))
const nodeTimer = setInterval(() => {
const entry = timers.get(id)
if (!entry) return
try {
fnRef.applyIgnored(undefined, [], { timeout: timeoutMs })
} catch {
// isolate disposed — callback silently dropped; the sweep on dispose
// clears the Node interval and releases the fn ref.
}
}, delay)
timers.set(id, { nodeTimer, fnRef, recurring: true })
return id
})
releaseables.push(setIntervalRef)
await jail.set('__setIntervalRef', setIntervalRef)
const brokerRef = new ivm.Reference(async (brokerName, argsJson) => {
return new Promise((resolve) => {
const brokerId = ++brokerIdCounter
const timeout = setTimeout(() => {
if (pendingBrokerCalls.has(brokerId)) {
pendingBrokerCalls.delete(brokerId)
resolve(JSON.stringify({ error: `Broker "${brokerName}" timed out` }))
}
}, BROKER_TIMEOUT_MS)
pendingBrokerCalls.set(brokerId, { resolve, timeout, executionId })
if (!process.send || !process.connected) {
clearTimeout(timeout)
pendingBrokerCalls.delete(brokerId)
resolve(JSON.stringify({ error: 'Parent process disconnected' }))
return
}
sendIpcRequest({ type: 'broker', brokerId, executionId, brokerName, argsJson }, (err) => {
const pending = pendingBrokerCalls.get(brokerId)
if (!pending) return
clearTimeout(pending.timeout)
pendingBrokerCalls.delete(brokerId)
pending.resolve(JSON.stringify({ error: `Broker IPC send failed: ${err.message}` }))
})
})
})
releaseables.push(brokerRef)
await jail.set('__brokerRef', brokerRef)
const runtimeBootstrap = `
// Capture every host bridge in a closure so later hardening can unset
// the raw globals without breaking the runtime surface user code
// depends on.
(() => {
const __log = globalThis.__log;
const __error = globalThis.__error;
const __textEncode = globalThis.__textEncode;
const __textDecode = globalThis.__textDecode;
const __setTimeoutRef = globalThis.__setTimeoutRef;
const __clearTimeoutRef = globalThis.__clearTimeoutRef;
const __setIntervalRef = globalThis.__setIntervalRef;
globalThis.console = {
log: (...args) => __log(...args),
error: (...args) => __error(...args),
warn: (...args) => __log('WARN:', ...args),
info: (...args) => __log(...args),
debug: (...args) => __log(...args),
};
// TextEncoder / TextDecoder delegate to Node's native implementations
// via ivm.Callback bridges. UTF-8 only — that's all the doc libraries
// need. If a library passes an alternate label to TextDecoder, the
// bridge still decodes as UTF-8; \`encoding\` getter returns the label
// for parity with the spec's getter behaviour.
globalThis.TextEncoder = class TextEncoder {
get encoding() { return 'utf-8' }
encode(input) {
return __textEncode(input == null ? '' : String(input));
}
};
globalThis.TextDecoder = class TextDecoder {
constructor(label) { this._label = (label || 'utf-8').toLowerCase(); }
get encoding() { return this._label; }
decode(input) {
if (!input) return '';
const bytes = input instanceof Uint8Array
? input
: ArrayBuffer.isView(input)
? new Uint8Array(input.buffer, input.byteOffset, input.byteLength)
: new Uint8Array(input);
return __textDecode(bytes);
}
};
// setTimeout / setInterval delegate to Node's real timer heap via
// ivm.Reference. The \`ms\` arg is honored (host clamps to the script
// timeout window); \`clearTimeout\` / \`clearInterval\` actually cancel.
// All outstanding timers are swept on isolate dispose.
globalThis.setTimeout = function(fn, ms) {
if (typeof fn !== 'function') {
throw new TypeError('setTimeout requires a function callback');
}
return __setTimeoutRef.applySync(undefined, [fn, ms], {
arguments: { reference: true },
});
};
globalThis.clearTimeout = function(id) {
if (id == null) return;
__clearTimeoutRef.applyIgnored(undefined, [id], {
arguments: { reference: true },
});
};
globalThis.setImmediate = function(fn) {
return globalThis.setTimeout(fn, 0);
};
globalThis.clearImmediate = globalThis.clearTimeout;
globalThis.setInterval = function(fn, ms) {
if (typeof fn !== 'function') {
throw new TypeError('setInterval requires a function callback');
}
return __setIntervalRef.applySync(undefined, [fn, ms], {
arguments: { reference: true },
});
};
globalThis.clearInterval = globalThis.clearTimeout;
// queueMicrotask is V8-intrinsic in modern isolates; provide a
// defensive fallback for older V8 builds.
if (typeof globalThis.queueMicrotask === 'undefined') {
globalThis.queueMicrotask = function(fn) { Promise.resolve().then(fn); };
}
})();
`
timings.setup = Date.now() - tPhase
tPhase = Date.now()
const runtimeScript = await isolate.compileScript(runtimeBootstrap)
releaseables.push(runtimeScript)
await runtimeScript.run(context)
timings.runtimeBootstrap = Date.now() - tPhase
tPhase = Date.now()
for (const bundleName of task.bundles) {
const { source, fileName } = getBundleSource(bundleName)
const bundleScript = await isolate.compileScript(source, { filename: `sandbox/${fileName}` })
releaseables.push(bundleScript)
await bundleScript.run(context, { timeout: timeoutMs })
}
timings.bundles = Date.now() - tPhase
tPhase = Date.now()
const brokerNamesJson = JSON.stringify(task.brokers)
const brokerInstallScript = `
(() => {
// Capture the bridge reference in a closure so hardening can unset the
// global without breaking already-installed brokers.
const __ref = globalThis.__brokerRef;
globalThis.__brokers = globalThis.__brokers || {};
for (const name of ${brokerNamesJson}) {
globalThis.__brokers[name] = async (args) => {
const argsJson = args === undefined ? undefined : JSON.stringify(args);
const responseJson = await __ref.apply(
undefined,
[name, argsJson],
{ result: { promise: true } }
);
let response;
try { response = JSON.parse(responseJson); } catch { throw new Error('Invalid broker response'); }
if (typeof response.error === 'string') {
throw new Error(response.error || 'Broker call failed');
}
return response.resultJson === undefined || response.resultJson === null
? null
: JSON.parse(response.resultJson);
};
}
})();
`
const brokerScript = await isolate.compileScript(brokerInstallScript)
releaseables.push(brokerScript)
await brokerScript.run(context)
timings.brokerInstall = Date.now() - tPhase
tPhase = Date.now()
const bootstrapScript = await isolate.compileScript(`(async () => { ${task.bootstrap} })()`, {
filename: `sandbox/${task.id}/bootstrap.js`,
})
releaseables.push(bootstrapScript)
await bootstrapScript.run(context, { timeout: timeoutMs, promise: true })
timings.taskBootstrap = Date.now() - tPhase
tPhase = Date.now()
const hardenScript = await isolate.compileScript(`
// Remove host-provided bridges + isolated-vm escape globals before user
// code runs. Leave the library polyfills (Buffer, process, etc.) alone —
// bundles have already captured what they need and user code calling into
// them would break if we stripped these.
const undefined_globals = [
'Isolate', 'Context', 'Script', 'Module', 'Callback', 'Reference',
'ExternalCopy', '__dirname', '__filename', '__brokerRef',
'__log', '__error', '__textEncode', '__textDecode',
'__setTimeoutRef', '__clearTimeoutRef', '__setIntervalRef'
];
for (const name of undefined_globals) {
try {
Object.defineProperty(globalThis, name, {
value: undefined, writable: false, configurable: false
});
} catch {}
}
`)
releaseables.push(hardenScript)
await hardenScript.run(context)
timings.harden = Date.now() - tPhase
tPhase = Date.now()
const wrappedUserCode = `
(async () => {
try {
await (async () => {
${code}
})();
return JSON.stringify({ success: true });
} catch (error) {
return JSON.stringify({
success: false,
errorInfo: {
message: error && error.message ? error.message : String(error),
name: error && error.name ? error.name : 'Error',
stack: error && error.stack ? error.stack : '',
},
});
}
})()
`
const userScript = await isolate.compileScript(wrappedUserCode, {
filename: 'user-function.js',
})
releaseables.push(userScript)
const userResultJson = await userScript.run(context, { timeout: timeoutMs, promise: true })
timings.userCode = Date.now() - tPhase
tPhase = Date.now()
let userResult
try {
userResult = JSON.parse(userResultJson)
} catch {
userResult = { success: false, errorInfo: { message: 'Invalid user result', name: 'Error' } }
}
if (!userResult.success) {
timings.total = Date.now() - tStart
return {
result: null,
stdout: stdoutChunks.join(''),
error: convertToCompatibleError(userResult.errorInfo, code),
timings,
}
}
const finalizeWrapped = `
(async () => {
const __bytes = await (async () => {
${task.finalize}
})();
if (!__bytes) {
throw new Error('Task finalize returned nothing; expected a Uint8Array');
}
const __u8 = __bytes instanceof Uint8Array
? __bytes
: ArrayBuffer.isView(__bytes)
? new Uint8Array(__bytes.buffer, __bytes.byteOffset, __bytes.byteLength)
: new Uint8Array(__bytes);
// Inline base64 encoding (no Buffer dep; works even if polyfill stripped).
const __alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
let __out = '';
const __len = __u8.length;
for (let __i = 0; __i < __len; __i += 3) {
const __b0 = __u8[__i];
const __b1 = __i + 1 < __len ? __u8[__i + 1] : 0;
const __b2 = __i + 2 < __len ? __u8[__i + 2] : 0;
__out += __alphabet[__b0 >> 2];
__out += __alphabet[((__b0 & 0x03) << 4) | (__b1 >> 4)];
__out += __i + 1 < __len ? __alphabet[((__b1 & 0x0f) << 2) | (__b2 >> 6)] : '=';
__out += __i + 2 < __len ? __alphabet[__b2 & 0x3f] : '=';
}
return __out;
})()
`
const finalizeScript = await isolate.compileScript(finalizeWrapped, {
filename: `sandbox/${task.id}/finalize.js`,
})
releaseables.push(finalizeScript)
const bytesBase64 = await finalizeScript.run(context, { timeout: timeoutMs, promise: true })
timings.finalize = Date.now() - tPhase
timings.total = Date.now() - tStart
return {
result: null,
stdout: stdoutChunks.join(''),
bytesBase64,
timings,
}
} catch (err) {
const stdout = stdoutChunks.join('')
timings.total = Date.now() - tStart
if (err instanceof Error) {
const errorInfo = { message: err.message, name: err.name, stack: err.stack }
// OOM check must run before the isDisposed guard: isolate OOM auto-disposes
// the isolate (isDisposed becomes true), so the cancel branch would fire first
// and mask the real cause. Message-based detection disambiguates the two.
if (
err.message?.includes('Array buffer allocation failed') ||
err.message?.includes('memory limit')
) {
return {
result: null,
stdout,
error: {
message:
'Execution exceeded memory limit (128 MB). Reduce image sizes or split the work into smaller batches.',
name: 'MemoryLimitError',
},
timings,
}
}
// Cancellation: host sent `cancel` IPC which called `isolate.dispose()`.
// Detect authoritatively via the isolate flag so we don't depend on
// isolated-vm's internal error wording.
if (isolate?.isDisposed) {
return {
result: null,
stdout,
error: { message: 'Execution cancelled', name: 'AbortError' },
timings,
}
}
if (err.message?.includes('Script execution timed out')) {
return {
result: null,
stdout,
error: {
message: `Execution timed out after ${timeoutMs}ms`,
name: 'TimeoutError',
},
timings,
}
}
return {
result: null,
stdout,
error: convertToCompatibleError(errorInfo, code),
timings,
}
}
return {
result: null,
stdout,
error: { message: String(err), name: 'Error' },
timings,
}
} finally {
// Sweep pending timers BEFORE releasing the isolate-side references they
// hold. This cancels any scheduled Node timers and releases the fn refs
// so callbacks can't fire after dispose.
cleanupTimers()
for (const obj of releaseables) {
if (obj) {
try {
obj.release()
} catch {}
}
}
if (context) {
try {
context.release()
} catch {}
}
if (isolate) {
try {
isolate.dispose()
} catch {}
}
if (executionId !== undefined) activeIsolates.delete(executionId)
}
}
process.on('message', async (msg) => {
try {
if (msg.type === 'execute') {
const result = msg.request.task
? await executeTask(msg.request, msg.executionId)
: await executeCode(msg.request, msg.executionId)
if (process.send && process.connected) {
process.send({ type: 'result', executionId: msg.executionId, result })
}
} else if (msg.type === 'cancel') {
// Host asked us to abort this execution. Disposing the isolate causes
// the in-flight compileScript/run to throw; the surrounding try/catch
// in execute{Code,Task} detects `isolate.isDisposed` and converts that
// into an AbortError result, which the host still processes for cleanup.
const iso = activeIsolates.get(msg.executionId)
if (iso) {
try {
iso.dispose()
} catch {}
}
// Release any pending broker-call bookkeeping tied to this execution
// so its timers + Map entries don't linger up to BROKER_TIMEOUT_MS.
for (const [brokerId, pending] of pendingBrokerCalls) {
if (pending.executionId === msg.executionId) {
clearTimeout(pending.timeout)
pendingBrokerCalls.delete(brokerId)
pending.resolve(JSON.stringify({ error: 'Execution cancelled' }))
}
}
} else if (msg.type === 'fetchResponse') {
const pending = pendingFetches.get(msg.fetchId)
if (pending) {
clearTimeout(pending.timeout)
pendingFetches.delete(msg.fetchId)
pending.resolve(msg.response)
}
} else if (msg.type === 'brokerResponse') {
const pending = pendingBrokerCalls.get(msg.brokerId)
if (pending) {
clearTimeout(pending.timeout)
pendingBrokerCalls.delete(msg.brokerId)
pending.resolve(JSON.stringify({ error: msg.error, resultJson: msg.resultJson }))
}
}
} catch (err) {
if (msg.type === 'execute' && process.send && process.connected) {
process.send({
type: 'result',
executionId: msg.executionId,
result: {
result: null,
stdout: '',
error: {
message: err instanceof Error ? err.message : 'Worker error',
name: 'WorkerError',
},
},
})
}
}
})
if (process.send) {
process.send({ type: 'ready' })
}