diff --git a/docs/content/docs/host-functions.mdx b/docs/content/docs/host-functions.mdx index 697e979405..b5d6683f90 100644 --- a/docs/content/docs/host-functions.mdx +++ b/docs/content/docs/host-functions.mdx @@ -110,6 +110,11 @@ Host functions run on the host with full access to the host environment, so do n Pass the same host-function collections to `AgentOs.create()`. The function contract and generated VM commands are unchanged. +VM-scoped host-function and filesystem-bridge callbacks use the originating +VM's ownership. Each VM retains its own callback handler on a shared sidecar. +Callbacks for an unknown or removed VM are rejected. A VM's handler is removed +after its teardown hooks finish, so disposing one VM preserves its siblings. + Read more in the [embedded API quickstart](/agentos/docs/quickstart-embedded). diff --git a/docs/content/docs/resource-limits.mdx b/docs/content/docs/resource-limits.mdx index 9cf2ac0432..3410310a1a 100644 --- a/docs/content/docs/resource-limits.mdx +++ b/docs/content/docs/resource-limits.mdx @@ -15,6 +15,15 @@ sets the live execution count that triggers a warning, not a rejection. These fields are optional in both Rust and TypeScript Core; omission keeps the sidecar default. +The TypeScript runtime-core transport retains at most 4,096 VM callback +handlers per connection. Embedded sidecar users can raise this local bound +with `SidecarSpawnOptions.maxVmRequestHandlers`; direct protocol clients use +`SidecarProtocolClientOptions.maxVmRequestHandlers`. The SDK writes a host +console warning at 80% capacity and rejects a new handler with +`SidecarVmRequestHandlerLimit` when full. Replacing a handler keeps the same +slot, and removing it releases capacity. This transport-local option does not +change the native VM admission or executor policy. + Process and terminal output replay is also sidecar-owned. The defaults retain up to 1,024 events or 1 MiB for each opted-in process, return at most 256 events or 768 KiB per read, and retain at most 1,024 replay records per VM. Override diff --git a/packages/core/src/agent-os.ts b/packages/core/src/agent-os.ts index a9c4d782c2..f04fde925d 100644 --- a/packages/core/src/agent-os.ts +++ b/packages/core/src/agent-os.ts @@ -12,13 +12,6 @@ import type { MountConfigJsonValue, NativeMountPluginDescriptor, } from "@rivet-dev/agentos-runtime-core/descriptors"; -import * as executionProtocol from "@rivet-dev/agentos-runtime-core/protocol"; -import type { LivePackageAcquisitionSource } from "@rivet-dev/agentos-runtime-core/request-payloads"; -import { SidecarRejectedError } from "@rivet-dev/agentos-runtime-core/sidecar-errors"; -import type { - CreateVmConfig, - VmUserConfig, -} from "@rivet-dev/agentos-runtime-core/vm-config"; import { type HostFunction, type HostFunctionCollections, @@ -28,6 +21,13 @@ import { resolveHostFunctions, } from "@rivet-dev/agentos-runtime-core/host-functions"; import { zodToJsonSchema } from "@rivet-dev/agentos-runtime-core/host-functions-zod"; +import * as executionProtocol from "@rivet-dev/agentos-runtime-core/protocol"; +import type { LivePackageAcquisitionSource } from "@rivet-dev/agentos-runtime-core/request-payloads"; +import { SidecarRejectedError } from "@rivet-dev/agentos-runtime-core/sidecar-errors"; +import type { + CreateVmConfig, + VmUserConfig, +} from "@rivet-dev/agentos-runtime-core/vm-config"; import type { CodeEvaluationResult, CodeExecutionResult, @@ -54,6 +54,12 @@ import type { TypeScriptFileExecutionOptions, } from "./language-execution.js"; import { parseAgentOsOptions } from "./options-schema.js"; +import { + type ReplayReadOptions, + replayPage, + replayPageLimits, + replayWirePageLimits, +} from "./output-replay.js"; import { buildProcessForest } from "./process-forest.js"; import type { ConnectTerminalOptions, @@ -75,12 +81,6 @@ import { resolveSandboxOptions, } from "./sandbox.js"; import { resolvePublishedSidecarBinary } from "./sidecar/binary.js"; -import { - replayPage, - replayPageLimits, - replayWirePageLimits, - type ReplayReadOptions, -} from "./output-replay.js"; import { findCargoBinary, resolveCargoBinary } from "./sidecar/cargo.js"; export type { @@ -5633,7 +5633,7 @@ export class AgentOs { }, }; } - }); + }, this._sidecarVm.vmId); } // ── Cron ──────────────────────────────────────────────────── @@ -5733,6 +5733,7 @@ export class AgentOs { result.status === "rejected" ? [result.reason] : [], ), ); + this._sidecarClient.setSidecarRequestHandler(null, this._sidecarVm.vmId); if (errors.length === 1) throw errors[0]; if (errors.length > 1) { throw new AggregateError(errors, "AgentOS VM disposal failed"); diff --git a/packages/core/tests/host-function-permissions.test.ts b/packages/core/tests/host-function-permissions.test.ts index ae1fbccb65..d360a17998 100644 --- a/packages/core/tests/host-function-permissions.test.ts +++ b/packages/core/tests/host-function-permissions.test.ts @@ -22,12 +22,13 @@ async function createVmCapturingHandler( .mockImplementation(function ( this: NativeSidecarProcessClient, handler: any, + vmId?: string, ) { if (handler) { captured = handler as CapturedHandler; } // Still install on the real client so the VM behaves normally. - return original.call(this, handler); + return original.call(this, handler, vmId); }); try { const vm = await AgentOs.create(options); @@ -410,3 +411,56 @@ describe("host-function collection permissions: raw host_callback RPC path", () expect(response.error).toMatch(/invalid registry callback/i); }); }); + +describe("host functions on a shared sidecar", () => { + test("keeps each VM's callbacks after a sibling is disposed or has no host functions", async () => { + const sidecar = await AgentOs.createSidecar(); + const vms: AgentOs[] = []; + const permissions = { + fs: "allow", + childProcess: "allow", + hostFunction: "allow", + } as const; + const invoke = async (vm: AgentOs, id: string) => { + const result = await runCommand(vm, "agentos-identity", ["who"]); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ ok: true, result: { id } }); + }; + try { + for (const id of ["a", "b", "c"]) { + vms.push( + await AgentOs.create({ + sidecar: { kind: "explicit", handle: sidecar }, + defaultSoftware: false, + permissions, + hostFunctions: { + identity: { + who: { inputSchema: z.object({}), execute: () => ({ id }) }, + }, + }, + }), + ); + } + await invoke(vms[0], "a"); + await invoke(vms[1], "b"); + await invoke(vms[2], "c"); + await vms[1].dispose(); + await invoke(vms[0], "a"); + vms.push( + await AgentOs.create({ + sidecar: { kind: "explicit", handle: sidecar }, + defaultSoftware: false, + permissions, + }), + ); + await invoke(vms[0], "a"); + await invoke(vms[2], "c"); + } finally { + try { + await Promise.all(vms.map((vm) => vm.dispose())); + } finally { + await sidecar.dispose(); + } + } + }); +}); diff --git a/packages/core/tests/leak-agent-os-processes.test.ts b/packages/core/tests/leak-agent-os-processes.test.ts index f777a92727..2c9a5a4e91 100644 --- a/packages/core/tests/leak-agent-os-processes.test.ts +++ b/packages/core/tests/leak-agent-os-processes.test.ts @@ -26,7 +26,10 @@ function makeAgentOs(): { } { const noop = () => {}; const kernelMock = { dispose: async () => {} }; - const sidecarClientMock = { onEvent: () => noop }; + const sidecarClientMock = { + onEvent: () => noop, + setSidecarRequestHandler: noop, + }; // The constructor is private at the type level but callable at runtime; it // runs the class field initializers (including `_processes = new Map()`). diff --git a/packages/core/tests/native-sidecar-process.nightly.test.ts b/packages/core/tests/native-sidecar-process.nightly.test.ts index 7a7bd896e7..d19cbc4e79 100644 --- a/packages/core/tests/native-sidecar-process.nightly.test.ts +++ b/packages/core/tests/native-sidecar-process.nightly.test.ts @@ -5,8 +5,8 @@ import { mkdirSync, mkdtempSync, readFileSync, - rmSync, realpathSync, + rmSync, statSync, symlinkSync, writeFileSync, @@ -20,10 +20,10 @@ import { createHostDirBackend } from "../src/host-dir-mount.js"; import { createKernel, createNodeRuntime, - NodeFileSystem, createWasmVmRuntime, + NodeFileSystem, } from "../src/runtime-compat.js"; -import { createInMemoryFileSystem } from "../src/test/runtime.js"; +import { serializePermissionsForSidecar } from "../src/sidecar/permissions.js"; import { NativeSidecarKernelProxy, NativeSidecarProcessClient, @@ -34,7 +34,7 @@ import { serializeRootFilesystemForSidecar, toSidecarSignalName, } from "../src/sidecar/rpc-client.js"; -import { serializePermissionsForSidecar } from "../src/sidecar/permissions.js"; +import { createInMemoryFileSystem } from "../src/test/runtime.js"; import { findPackageWithCommand, packageCommandsDir, @@ -44,9 +44,7 @@ const REPO_ROOT = fileURLToPath(new URL("../../..", import.meta.url)); const SIDECAR_BINARY = process.env.AGENTOS_SIDECAR_BIN ? resolve(process.env.AGENTOS_SIDECAR_BIN) : join(REPO_ROOT, "target/debug/agentos-native-sidecar"); -const REGISTRY_COMMANDS_DIR = packageCommandsDir( - findPackageWithCommand("sh"), -); +const REGISTRY_COMMANDS_DIR = packageCommandsDir(findPackageWithCommand("sh")); const SIGNAL_STATE_CONTROL_PREFIX = "__AGENT_OS_SIGNAL_STATE__:"; const ALLOW_ALL_VM_PERMISSIONS = { fs: "allow", @@ -389,7 +387,9 @@ describe("native sidecar process client", () => { }); test("dispatches BARE sidecar_request frames to the registered handler", async () => { - const fixtureRoot = mkdtempSync(join(tmpdir(), "agentos-native-sidecar-request-")); + const fixtureRoot = mkdtempSync( + join(tmpdir(), "agentos-native-sidecar-request-"), + ); cleanupPaths.push(fixtureRoot); const capturePath = join(fixtureRoot, "captured-response.json"); const driverPath = join(fixtureRoot, "fake-sidecar.mjs"); @@ -459,7 +459,7 @@ describe("native sidecar process client", () => { content: "from-handler", }, }; - }); + }, "vm-1"); try { const captured = await waitFor( @@ -496,7 +496,9 @@ describe("native sidecar process client", () => { }); test("dispose forcibly terminates a sidecar that ignores stdin closure", async () => { - const fixtureRoot = mkdtempSync(join(tmpdir(), "agentos-native-sidecar-dispose-")); + const fixtureRoot = mkdtempSync( + join(tmpdir(), "agentos-native-sidecar-dispose-"), + ); cleanupPaths.push(fixtureRoot); const driverPath = join(fixtureRoot, "stuck-sidecar.mjs"); writeFileSync( diff --git a/packages/runtime-core/src/native-client.ts b/packages/runtime-core/src/native-client.ts index a961fd1c22..4fec389c52 100644 --- a/packages/runtime-core/src/native-client.ts +++ b/packages/runtime-core/src/native-client.ts @@ -22,6 +22,7 @@ export interface StdioSidecarProtocolClientSpawnOptions { command?: string; args?: string[]; eventBufferCapacity?: number; + maxVmRequestHandlers?: number; gracefulExitMs?: number; forceExitMs?: number; disposedErrorMessage?: string; @@ -44,7 +45,10 @@ type ResolvedStdioSidecarProtocolClientOptions = Required< | "payloadCodec" > > & - Pick; + Pick< + StdioSidecarProtocolClientSpawnOptions, + "silenceTimeoutMs" | "maxVmRequestHandlers" + >; export class StdioSidecarProtocolClient implements SidecarProcessTransport { readonly child: StdioSidecarProcess["child"]; @@ -77,6 +81,7 @@ export class StdioSidecarProtocolClient implements SidecarProcessTransport { this.protocolClient = new SidecarProtocolClient({ ...transportOptions, eventBufferCapacity: options.eventBufferCapacity, + maxVmRequestHandlers: options.maxVmRequestHandlers, payloadCodec: options.payloadCodec, silenceTimeoutMs: options.silenceTimeoutMs, // A silent sidecar is dead or wedged; reap the process so it cannot @@ -125,6 +130,7 @@ export class StdioSidecarProtocolClient implements SidecarProcessTransport { }), { silenceTimeoutMs: options.silenceTimeoutMs, + maxVmRequestHandlers: options.maxVmRequestHandlers, eventBufferCapacity: options.eventBufferCapacity ?? DEFAULT_SIDECAR_EVENT_BUFFER_CAPACITY, gracefulExitMs: @@ -137,8 +143,11 @@ export class StdioSidecarProtocolClient implements SidecarProcessTransport { ); } - setSidecarRequestHandler(handler: LiveSidecarRequestHandler | null): void { - this.protocolClient.setSidecarRequestHandler(handler); + setSidecarRequestHandler( + handler: LiveSidecarRequestHandler | null, + vmId?: string, + ): void { + this.protocolClient.setSidecarRequestHandler(handler, vmId); } onEvent(handler: (event: LiveEventFrame) => void): () => void { diff --git a/packages/runtime-core/src/protocol-client.ts b/packages/runtime-core/src/protocol-client.ts index a279b477d3..7a92f346c6 100644 --- a/packages/runtime-core/src/protocol-client.ts +++ b/packages/runtime-core/src/protocol-client.ts @@ -33,6 +33,7 @@ import type { LiveRequestPayload } from "./request-payloads.js"; import { SidecarRejectedError, SidecarSilenceTimeout, + SidecarVmRequestHandlerLimit, } from "./sidecar-errors.js"; /** @@ -61,6 +62,8 @@ export interface SidecarProtocolClientOptions { /** Use one bidirectional stdin/stdout transport when the host cannot inherit fd 3. */ combinedStdio?: boolean; eventBufferCapacity: number; + /** Maximum VM callback dispatchers on this connection (default 4096). */ + maxVmRequestHandlers?: number; payloadCodec?: ProtocolFramePayloadCodec; stderrText?: () => string; frameError?: (error: Error) => Error; @@ -97,9 +100,24 @@ export class SidecarProtocolClient { reject: (error: Error) => void; timer: ReturnType | null; }>(); + private readonly maxVmRequestHandlers: number; + private vmHandlerWarningActive = false; private sidecarRequestHandler: LiveSidecarRequestHandler | null = null; + private readonly vmRequestHandlers = new Map< + string, + LiveSidecarRequestHandler + >(); constructor(options: SidecarProtocolClientOptions) { + this.maxVmRequestHandlers = options.maxVmRequestHandlers ?? 4096; + if ( + !Number.isSafeInteger(this.maxVmRequestHandlers) || + this.maxVmRequestHandlers <= 0 + ) { + throw new RangeError( + "maxVmRequestHandlers must be a positive safe integer", + ); + } this.silenceTimeoutMs = options.silenceTimeoutMs ?? DEFAULT_SIDECAR_SILENCE_TIMEOUT_MS; this.eventBuffer = new SidecarEventBuffer(options.eventBufferCapacity); @@ -179,8 +197,35 @@ export class SidecarProtocolClient { } } - setSidecarRequestHandler(handler: LiveSidecarRequestHandler | null): void { - this.sidecarRequestHandler = handler; + setSidecarRequestHandler( + handler: LiveSidecarRequestHandler | null, + vmId?: string, + ): void { + if (handler && this.closedError) throw this.closedError; + if (vmId !== undefined) { + if (handler) { + if ( + !this.vmRequestHandlers.has(vmId) && + this.vmRequestHandlers.size >= this.maxVmRequestHandlers + ) { + throw new SidecarVmRequestHandlerLimit(this.maxVmRequestHandlers); + } + this.vmRequestHandlers.set(vmId, handler); + } else { + this.vmRequestHandlers.delete(vmId); + } + const nearCapacity = + this.vmRequestHandlers.size >= + Math.ceil(this.maxVmRequestHandlers * 0.8); + if (nearCapacity && !this.vmHandlerWarningActive) { + console.warn( + `agentOS VM callback handlers near maxVmRequestHandlers (${this.vmRequestHandlers.size}/${this.maxVmRequestHandlers}); raise SidecarSpawnOptions.maxVmRequestHandlers`, + ); + } + this.vmHandlerWarningActive = nearCapacity; + } else { + this.sidecarRequestHandler = handler; + } } onEvent(handler: (event: LiveEventFrame) => void): () => void { @@ -305,12 +350,14 @@ export class SidecarProtocolClient { } } this.closedError = error; + this.vmRequestHandlers.clear(); + this.sidecarRequestHandler = null; this.stopSilenceWatchdog(); this.rejectPending(error); } dispose(): void { - this.stopSilenceWatchdog(); + this.failPermanently(new Error("sidecar protocol client disposed")); this.frameTransport.dispose(); } @@ -340,7 +387,9 @@ export class SidecarProtocolClient { }); const payload = await resolveSidecarRequestFramePayload( request, - this.sidecarRequestHandler, + request.ownership.scope === "vm" + ? (this.vmRequestHandlers.get(request.ownership.vm_id) ?? null) + : this.sidecarRequestHandler, ); try { diff --git a/packages/runtime-core/src/sidecar-client.ts b/packages/runtime-core/src/sidecar-client.ts index 75423b704e..6f179aab83 100644 --- a/packages/runtime-core/src/sidecar-client.ts +++ b/packages/runtime-core/src/sidecar-client.ts @@ -8,16 +8,17 @@ import type { import type { LiveRequestPayload } from "./request-payloads.js"; export interface SidecarProcessTransport { - setSidecarRequestHandler(handler: LiveSidecarRequestHandler | null): void; + setSidecarRequestHandler( + handler: LiveSidecarRequestHandler | null, + vmId?: string, + ): void; onEvent(handler: (event: LiveEventFrame) => void): () => void; sendRequest(input: { ownership: LiveOwnershipScope; payload: LiveRequestPayload; }): Promise; waitForEvent( - matcher: - | LiveSidecarEventSelector - | ((event: LiveEventFrame) => boolean), + matcher: LiveSidecarEventSelector | ((event: LiveEventFrame) => boolean), timeoutMs?: number, options?: { signal?: AbortSignal; diff --git a/packages/runtime-core/src/sidecar-errors.ts b/packages/runtime-core/src/sidecar-errors.ts index c3c7e5d140..a716ead411 100644 --- a/packages/runtime-core/src/sidecar-errors.ts +++ b/packages/runtime-core/src/sidecar-errors.ts @@ -94,3 +94,14 @@ export class SidecarProcessError extends Error { this.stderr = stderr; } } + +export class SidecarVmRequestHandlerLimit extends Error { + readonly limit: number; + constructor(limit: number) { + super( + `VM callback handler limit maxVmRequestHandlers (${limit}) reached; raise SidecarSpawnOptions.maxVmRequestHandlers`, + ); + this.name = "SidecarVmRequestHandlerLimit"; + this.limit = limit; + } +} diff --git a/packages/runtime-core/src/sidecar-process.ts b/packages/runtime-core/src/sidecar-process.ts index bc4a09ef5b..1833058c54 100644 --- a/packages/runtime-core/src/sidecar-process.ts +++ b/packages/runtime-core/src/sidecar-process.ts @@ -203,6 +203,8 @@ export interface SidecarSpawnOptions { command?: string; args?: string[]; eventBufferCapacity?: number; + /** Maximum VM callback dispatchers on this connection (default 4096). */ + maxVmRequestHandlers?: number; gracefulExitMs?: number; forceExitMs?: number; // Migration-only compatibility path for pre-BARE test fixtures. @@ -220,6 +222,7 @@ export interface ResolvedSidecarSpawnOptions { command?: string; args: string[]; eventBufferCapacity: number; + maxVmRequestHandlers?: number; gracefulExitMs: number; forceExitMs: number; disposedErrorMessage: string; @@ -367,6 +370,7 @@ export class SidecarProcess { args: options.args ?? [], cwd: options.cwd, silenceTimeoutMs: options.silenceTimeoutMs, + maxVmRequestHandlers: options.maxVmRequestHandlers, eventBufferCapacity: options.eventBufferCapacity ?? DEFAULT_SIDECAR_EVENT_BUFFER_CAPACITY, gracefulExitMs: @@ -378,8 +382,11 @@ export class SidecarProcess { return SidecarProcess.fromClient(protocolClient); } - setSidecarRequestHandler(handler: SidecarRequestHandler | null): void { - this.protocolClient.setSidecarRequestHandler(handler); + setSidecarRequestHandler( + handler: SidecarRequestHandler | null, + vmId?: string, + ): void { + this.protocolClient.setSidecarRequestHandler(handler, vmId); } onEvent(handler: (event: EventFrame) => void): () => void { diff --git a/packages/runtime-core/src/test-runtime.ts b/packages/runtime-core/src/test-runtime.ts index 24fbdd8812..b9d335d212 100644 --- a/packages/runtime-core/src/test-runtime.ts +++ b/packages/runtime-core/src/test-runtime.ts @@ -2957,6 +2957,11 @@ class NativeKernel implements Kernel { try { await this.proxy?.dispose().catch(() => {}); } finally { + if (this.hostFunctionRequestHandlerInstalled && this.client && this.vm) { + this.client.setSidecarRequestHandler(null, this.vm.vmId); + } + this.hostFunctionRequestHandlerInstalled = false; + this.hostFunctionHandlers.clear(); this.proxy = null; this.rootFilesystem = null; this.client = null; @@ -3096,8 +3101,10 @@ class NativeKernel implements Kernel { // sidecar emits to the matching registered handler and replies with a // host_callback_result frame. if (!this.hostFunctionRequestHandlerInstalled) { - this.client.setSidecarRequestHandler((request: SidecarRequestFrame) => - this.dispatchHostFunctionRequest(request), + this.client.setSidecarRequestHandler( + (request: SidecarRequestFrame) => + this.dispatchHostFunctionRequest(request), + this.vm.vmId, ); this.hostFunctionRequestHandlerInstalled = true; } diff --git a/packages/runtime-core/tests/mount-fs-custom-vfs.test.ts b/packages/runtime-core/tests/mount-fs-custom-vfs.test.ts index a2a8ed8d9e..cbb43a52e4 100644 --- a/packages/runtime-core/tests/mount-fs-custom-vfs.test.ts +++ b/packages/runtime-core/tests/mount-fs-custom-vfs.test.ts @@ -1,4 +1,5 @@ -import { afterEach, describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import type { SidecarProcess } from "../src/sidecar-process.js"; import { createInMemoryFileSystem, createKernel, @@ -63,26 +64,22 @@ describe("Kernel.mountFs custom JS VFS", () => { kernel = undefined; }); - test( - "routes runtime reads and writes through a plain JS VFS object", - async () => { - const mounted = createRecordingFilesystem(); - kernel = createKernel({ filesystem: createInMemoryFileSystem() }); + test("routes runtime reads and writes through a plain JS VFS object", async () => { + const mounted = createRecordingFilesystem(); + kernel = createKernel({ filesystem: createInMemoryFileSystem() }); - kernel.mountFs("/mnt/custom", mounted.fs); - await kernel.writeFile("/mnt/custom/note.txt", "from custom vfs"); + kernel.mountFs("/mnt/custom", mounted.fs); + await kernel.writeFile("/mnt/custom/note.txt", "from custom vfs"); - expect( - new TextDecoder().decode(await kernel.readFile("/mnt/custom/note.txt")), - ).toBe("from custom vfs"); - expect(mounted.calls).toContain("writeFile:/note.txt"); - expect(mounted.calls).toContain("readFile:/note.txt"); + expect( + new TextDecoder().decode(await kernel.readFile("/mnt/custom/note.txt")), + ).toBe("from custom vfs"); + expect(mounted.calls).toContain("writeFile:/note.txt"); + expect(mounted.calls).toContain("readFile:/note.txt"); - kernel.unmountFs("/mnt/custom"); - await expect(kernel.readFile("/mnt/custom/note.txt")).rejects.toThrow(); - }, - 120_000, - ); + kernel.unmountFs("/mnt/custom"); + await expect(kernel.readFile("/mnt/custom/note.txt")).rejects.toThrow(); + }, 120_000); test("routes positioned writes through a mounted JS VFS", async () => { const mounted = createRecordingFilesystem(); @@ -102,3 +99,47 @@ describe("Kernel.mountFs custom JS VFS", () => { expect(mounted.calls).toContain("pwrite:/db.bin"); }); }); + +test("disposal unregisters only the shared kernel VM callback", async () => { + const sidecar = { + authenticateAndOpenSession: vi.fn(async () => ({ + connectionId: "conn", + sessionId: "session", + })), + createVm: vi + .fn() + .mockResolvedValueOnce({ vmId: "a" }) + .mockResolvedValueOnce({ vmId: "b" }), + waitForEvent: vi.fn(async () => ({})), + onEvent: vi.fn(() => () => {}), + setSidecarRequestHandler: vi.fn(), + disposeVm: vi.fn(async () => {}), + dispose: vi.fn(async () => {}), + } as unknown as SidecarProcess; + const a = createKernel({ + filesystem: createInMemoryFileSystem(), + sidecar, + syncFilesystemOnDispose: false, + }); + const b = createKernel({ + filesystem: createInMemoryFileSystem(), + sidecar, + syncFilesystemOnDispose: false, + }); + try { + await a.registerHostFunctions({}); + await b.registerHostFunctions({}); + await a.dispose(); + expect(sidecar.setSidecarRequestHandler).toHaveBeenCalledWith(null, "a"); + expect(sidecar.setSidecarRequestHandler).not.toHaveBeenCalledWith( + null, + "b", + ); + expect(sidecar.dispose).not.toHaveBeenCalled(); + await b.dispose(); + expect(sidecar.setSidecarRequestHandler).toHaveBeenCalledWith(null, "b"); + } finally { + await a.dispose(); + await b.dispose(); + } +}); diff --git a/packages/runtime-core/tests/protocol-client.test.ts b/packages/runtime-core/tests/protocol-client.test.ts index dd4b7329ea..a8d905c55a 100644 --- a/packages/runtime-core/tests/protocol-client.test.ts +++ b/packages/runtime-core/tests/protocol-client.test.ts @@ -1,5 +1,5 @@ import { Duplex, PassThrough } from "node:stream"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { FrameTransport } from "../src/frame-stream.js"; import { encodeLengthPrefixedPayload } from "../src/framing.js"; import { SidecarProtocolClient } from "../src/protocol-client.js"; @@ -11,7 +11,10 @@ import { type LiveSidecarRequestFrame, } from "../src/protocol-frames.js"; import { SIDECAR_PROTOCOL_SCHEMA } from "../src/protocol-schema.js"; -import { SidecarRejectedError } from "../src/sidecar-errors.js"; +import { + SidecarRejectedError, + SidecarVmRequestHandlerLimit, +} from "../src/sidecar-errors.js"; const ownership = { scope: "connection" as const, @@ -141,6 +144,57 @@ function writeIncomingControlFrame( } describe("sidecar protocol client", () => { + it("bounds VM callback registrations, warns near capacity, and reuses released slots", () => { + const client = new SidecarProtocolClient({ + frameTransport: new MemoryFrameTransport(), + eventBufferCapacity: 2, + maxVmRequestHandlers: 5, + }); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const handler = async () => ({ + type: "host_callback_result" as const, + invocation_id: "invocation", + result: null, + }); + try { + for (let i = 0; i < 4; i++) + client.setSidecarRequestHandler(handler, `vm-${i}`); + expect(warn).toHaveBeenCalledOnce(); + expect(warn.mock.calls[0][0]).toContain("maxVmRequestHandlers"); + client.setSidecarRequestHandler(handler, "vm-4"); + expect(() => client.setSidecarRequestHandler(handler, "vm-5")).toThrow( + SidecarVmRequestHandlerLimit, + ); + // Replacing an existing VM does not consume another slot. + client.setSidecarRequestHandler(handler, "vm-0"); + client.setSidecarRequestHandler(null, "vm-1"); + client.setSidecarRequestHandler(handler, "vm-5"); + } finally { + warn.mockRestore(); + client.dispose(); + } + }); + + it("rejects new callback registrations after normal protocol disposal", () => { + const client = new SidecarProtocolClient({ + frameTransport: new MemoryFrameTransport(), + eventBufferCapacity: 2, + }); + client.dispose(); + expect(() => + client.setSidecarRequestHandler( + async () => ({ + type: "host_callback_result", + invocation_id: "invocation", + result: null, + }), + "vm", + ), + ).toThrow("sidecar protocol client disposed"); + // Cleanup remains possible after transport closure. + client.setSidecarRequestHandler(null, "vm"); + }); + it("preserves structured resource-limit rejection metadata", async () => { const frameTransport = new MemoryFrameTransport(); const client = new SidecarProtocolClient({ @@ -440,6 +494,163 @@ describe("sidecar protocol client", () => { client.dispose(); }); + it.each([ + "host_callback", + "js_bridge_call", + ] as const)("routes %s by VM without borrowing a sibling or global handler", async (type) => { + const bridge = type === "js_bridge_call"; + const frameTransport = new MemoryFrameTransport(); + const client = new SidecarProtocolClient({ + frameTransport, + eventBufferCapacity: 8, + payloadCodec: "json", + }); + const calls: string[] = []; + for (const vmId of ["a", "b"]) { + client.setSidecarRequestHandler(async (request) => { + calls.push(vmId); + if (request.payload.type === "js_bridge_call") { + return { + type: "js_bridge_result", + call_id: request.payload.call_id, + result: vmId, + }; + } + return { + type: "host_callback_result", + invocation_id: "invoke", + result: vmId, + }; + }, vmId); + } + client.setSidecarRequestHandler(async () => { + calls.push("global"); + return { + type: "host_callback_result", + invocation_id: "invoke", + result: "global", + }; + }); + const emit = (vmId: string, requestId: number, bridge = false) => + frameTransport.emitFrame({ + frame_type: "sidecar_request", + schema: SIDECAR_PROTOCOL_SCHEMA, + request_id: requestId, + ownership: { + scope: "vm", + connection_id: "conn", + session_id: "session", + vm_id: vmId, + }, + payload: bridge + ? { + type: "js_bridge_call", + call_id: "bridge", + mount_id: "mount", + operation: "read_file", + args: [], + } + : { + type: "host_callback", + invocation_id: "invoke", + callback_key: "tool", + input: {}, + timeout_ms: 1000, + }, + }); + const response = (id: number) => + frameTransport.writes.find( + (frame) => "request_id" in frame && frame.request_id === id, + ); + try { + emit("a", -1, bridge); + emit("b", -2, bridge); + emit("unregistered", -3, bridge); + await expect.poll(() => frameTransport.writes.length).toBe(3); + expect(calls).toEqual(["a", "b"]); + expect(response(-1)).toMatchObject({ + request_id: -1, + ownership: { scope: "vm", vm_id: "a" }, + payload: { result: "a" }, + }); + expect(response(-2)).toMatchObject({ + request_id: -2, + payload: { result: "b" }, + }); + expect(response(-3)).toMatchObject({ + request_id: -3, + payload: { + error: expect.stringContaining("no sidecar request handler"), + }, + }); + client.setSidecarRequestHandler(null, "b"); + client.setSidecarRequestHandler(null, "disabled"); + emit("b", -4, bridge); + emit("a", -5, bridge); + await expect.poll(() => frameTransport.writes.length).toBe(5); + expect(calls).toEqual(["a", "b", "a"]); + expect(response(-4)).toMatchObject({ + request_id: -4, + payload: { error: expect.any(String) }, + }); + expect(response(-5)).toMatchObject({ + request_id: -5, + payload: { result: "a" }, + }); + } finally { + client.dispose(); + } + }); + + it.each([ + "connection", + "session", + ] as const)("keeps the global handler for %s requests", async (scope) => { + const frameTransport = new MemoryFrameTransport(); + const client = new SidecarProtocolClient({ + frameTransport, + eventBufferCapacity: 8, + payloadCodec: "json", + }); + client.setSidecarRequestHandler(async () => ({ + type: "host_callback_result", + invocation_id: "invoke", + result: "global", + })); + client.setSidecarRequestHandler( + async () => ({ + type: "host_callback_result", + invocation_id: "invoke", + result: "vm", + }), + "a", + ); + try { + frameTransport.emitFrame({ + frame_type: "sidecar_request", + schema: SIDECAR_PROTOCOL_SCHEMA, + request_id: -1, + ownership: + scope === "connection" + ? ownership + : { scope, connection_id: "conn", session_id: "session" }, + payload: { + type: "host_callback", + invocation_id: "invoke", + callback_key: "tool", + input: {}, + timeout_ms: 1000, + }, + }); + await expect.poll(() => frameTransport.writes.length).toBe(1); + expect(frameTransport.writes[0]).toMatchObject({ + payload: { result: "global" }, + }); + } finally { + client.dispose(); + } + }); + it("writes typed shutdown control on fd3 without touching fd0", async () => { const { stdin, control, client } = createClient(); const written = readWrittenFrame(control.written);