From 4c8f89926c10c29ceb454dd1fad690645ac525ab Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 26 Oct 2026 19:28:15 +0100 Subject: [PATCH 01/28] test: a caller opens WS /v1/leases/{id}/input and gets 0x83 then 0x82 through a gateway (#1017) --- src/daemon/input-lease.e2e.test.ts | 153 +++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 src/daemon/input-lease.e2e.test.ts diff --git a/src/daemon/input-lease.e2e.test.ts b/src/daemon/input-lease.e2e.test.ts new file mode 100644 index 000000000..cf2d9c32d --- /dev/null +++ b/src/daemon/input-lease.e2e.test.ts @@ -0,0 +1,153 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { WebSocket } from "ws"; + +import { FakeDriver, fakeDeviceDataTables } from "../core/testing.js"; +import { MemoryFilesystem, NoopLogger, SystemClock } from "../ports/index.js"; +import type { DispatchSession } from "./dispatch.js"; +import { startDaemon } from "./main.js"; +import type { DaemonServer } from "./server.js"; + +async function freeLoopbackPort(): Promise { + const server = createServer(); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + await new Promise((resolve) => server.close(() => resolve())); + if (typeof address !== "object" || address === null) throw new Error("no port bound"); + return address.port; +} + +const admin: DispatchSession = { + manageEventSubscription: () => undefined, + via: "socket", + principal: "test-operator", + role: "admin", +}; + +const agent: DispatchSession = { + manageEventSubscription: () => undefined, + via: "socket", + principal: "agent-1", + role: "agent", +}; + +const UNAVAILABLE_CONFIG = + '{"width":1,"height":1,"orientation":"portrait","inputUnavailable":true}'; + +interface OpenedSocket { + // why: 101 for an upgrade that was accepted, else the status the refusal answered. + readonly status: number; + readonly messages: Buffer[]; +} + +async function openInput(url: string, token: string | undefined): Promise { + const socket = new WebSocket(url, { + headers: token === undefined ? {} : { authorization: `Bearer ${token}` }, + }); + sockets.push(socket); + const messages: Buffer[] = []; + socket.on("message", (data) => messages.push(data as Buffer)); + const status = await new Promise((resolve, reject) => { + socket.once("open", () => resolve(101)); + socket.once("unexpected-response", (_request, response) => resolve(response.statusCode ?? 0)); + socket.once("error", reject); + }); + return { messages, status }; +} + +const sockets: WebSocket[] = []; + +describe("a lease's input socket", () => { + const daemons: DaemonServer[] = []; + const directories: string[] = []; + + afterEach(async () => { + for (const socket of sockets.splice(0)) socket.terminate(); + await Promise.all(daemons.splice(0).map((daemon) => daemon.stop("test"))); + await Promise.all( + directories.splice(0).map((directory) => rm(directory, { force: true, recursive: true })), + ); + }); + + async function fleet() { + const gatewayDirectory = await mkdtemp(join(tmpdir(), "simlock-e2e-input-gateway-")); + directories.push(gatewayDirectory); + const port = await freeLoopbackPort(); + const gateway = await startDaemon({ + configOverrides: { + capabilities: { input: { enabled: true } }, + http: { enabled: true, host: "127.0.0.1", port }, + }, + dataDirectory: gatewayDirectory, + filesystem: new MemoryFilesystem(), + logger: new NoopLogger(), + role: "gateway", + version: "1.0.0-e2e", + }); + daemons.push(gateway); + const token = (await gateway.dispatch("token.create", { label: "w", role: "worker" }, admin)) + .secret; + const workerDirectory = await mkdtemp(join(tmpdir(), "simlock-e2e-input-worker-")); + directories.push(workerDirectory); + const worker = await startDaemon({ + configOverrides: { + capabilities: { input: { enabled: true } }, + gateway: { label: "w", token, url: `ws://127.0.0.1:${String(port)}` }, + }, + dataDirectory: workerDirectory, + deviceDataTables: fakeDeviceDataTables, + drivers: [ + new FakeDriver({ + availableOsVersions: ["18.0"], + clock: new SystemClock(), + knownModels: ["Pixel-A"], + platform: "android", + }), + ], + filesystem: new MemoryFilesystem(), + logger: new NoopLogger(), + version: "1.0.0-e2e", + }); + daemons.push(worker); + await vi.waitFor( + async () => { + const { workers } = await gateway.dispatch("worker.list", {}, admin); + expect(workers[0]?.catalog?.length).toBeGreaterThan(0); + }, + { timeout: 10_000 }, + ); + const holder = await gateway.dispatch( + "token.create", + { label: "holder", role: "agent" }, + admin, + ); + const owner: DispatchSession = { ...agent, principal: holder.token.id }; + const granted = await gateway.dispatch( + "lease.request", + { model: "Pixel-A", platform: "android" }, + owner, + ); + const leaseId = (granted as { lease: { id: string } }).lease.id; + await vi.waitFor(() => gateway.dispatch("lease.get", { leaseId }, owner), { timeout: 10_000 }); + const inputUrl = (id = leaseId) => `ws://127.0.0.1:${String(port)}/v1/leases/${id}/input`; + return { holder: holder.secret, inputUrl, leaseId }; + } + + it( + "opens WS /v1/leases/{id}/input and gets 0x83 then 0x82, or a refused upgrade", + { timeout: 30_000 }, + async () => { + const { holder, inputUrl } = await fleet(); + const own = await openInput(inputUrl(), holder); + expect(own.status).toBe(101); + await vi.waitFor(() => expect(own.messages).toHaveLength(2), { timeout: 10_000 }); + expect([...(own.messages[0] ?? [])]).toEqual([0x83]); + expect(own.messages[1]?.[0]).toBe(0x82); + expect(own.messages[1]?.subarray(1).toString("utf8")).toBe(UNAVAILABLE_CONFIG); + }, + ); +}); From b73ea27437f49dbe688caa436bc7aec73726e934 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 26 Oct 2026 19:35:31 +0100 Subject: [PATCH 02/28] feat(input): a lease's input socket opens through a gateway and says its device takes no input (#1017) --- src/core/driver-catalog.ts | 11 ++ src/core/driver.ts | 4 + src/core/fake-driver.ts | 4 + src/core/index.ts | 11 ++ src/core/input.ts | 69 ++++++++ src/daemon/input-lease.e2e.test.ts | 12 +- src/daemon/main.ts | 16 +- src/http/app.ts | 59 +++++-- src/input/channel.ts | 45 ++++++ src/input/create.ts | 44 ++++++ src/input/definition.ts | 15 ++ src/input/framing.ts | 48 ++++++ src/input/gateway-link.ts | 55 +++++++ src/input/gateway.ts | 12 ++ src/input/index.ts | 1 + src/input/refusal.ts | 17 ++ src/input/worker-link.ts | 60 +++++++ src/input/worker.ts | 17 ++ src/module-registry/index.ts | 2 + src/modules/host.ts | 20 ++- src/modules/index.ts | 8 + src/modules/testing.ts | 1 + src/modules/types.ts | 28 ++++ src/ports/index.ts | 1 + src/ports/lease-socket.ts | 44 ++++++ src/ports/upgrade-router.ts | 149 ++++++++++++++++++ src/ports/uplink-websocket.ts | 15 +- .../docs/docs/reference/configuration.mdx | 1 + 28 files changed, 742 insertions(+), 27 deletions(-) create mode 100644 src/core/input.ts create mode 100644 src/input/channel.ts create mode 100644 src/input/create.ts create mode 100644 src/input/definition.ts create mode 100644 src/input/framing.ts create mode 100644 src/input/gateway-link.ts create mode 100644 src/input/gateway.ts create mode 100644 src/input/index.ts create mode 100644 src/input/refusal.ts create mode 100644 src/input/worker-link.ts create mode 100644 src/input/worker.ts create mode 100644 src/ports/lease-socket.ts create mode 100644 src/ports/upgrade-router.ts diff --git a/src/core/driver-catalog.ts b/src/core/driver-catalog.ts index 1bd3d7976..2ede11607 100644 --- a/src/core/driver-catalog.ts +++ b/src/core/driver-catalog.ts @@ -10,6 +10,7 @@ import type { PhysicalDriverDevice, VirtualDriverDevice, } from "./driver.js"; +import type { DeviceInput, UnavailableDeviceInput } from "./input.js"; import { type ScreenFeed, ScreenNotSupportedError, type ScreenSettings } from "./screen.js"; import { stableError } from "./stable-error.js"; @@ -106,6 +107,16 @@ export class DriverCatalog { return await capture.open(device, settings, signal); } + async openInput( + platform: Platform, + device: VirtualDriverDevice | PhysicalDriverDevice, + signal: AbortSignal, + ): Promise { + const factory = this.#drivers.get(platform)?.input; + if (factory === undefined) return { unavailable: `${platform} devices take no input` }; + return await factory.open(device, signal); + } + async screenAvailability(): Promise { return await Promise.all( [...this.#drivers.values()].flatMap((driver) => diff --git a/src/core/driver.ts b/src/core/driver.ts index aee6e0abb..a07fad491 100644 --- a/src/core/driver.ts +++ b/src/core/driver.ts @@ -1,6 +1,7 @@ import type { EventMap } from "../bus/index.js"; import type { Filesystem, SqliteDatabase, SqliteSchemaOwner } from "../ports/index.js"; import type { RootRejectionReason } from "./device-root.js"; +import type { DeviceInputFactory } from "./input.js"; import type { ScreenCapture } from "./screen.js"; import type { AbsentReason, @@ -372,6 +373,9 @@ export interface Driver { // why: optional because a platform whose screen cannot be taken leaves it out, and the stream // module learns that from its absence rather than from a platform name. readonly screenCapture?: ScreenCapture | undefined; + // why: optional like `screenCapture`: a platform whose device takes no input leaves it out, and a + // lease's input socket then says the device is unavailable. + readonly input?: DeviceInputFactory | undefined; /** why: `device.exec` has no pseudo-terminal (ADR 0005 §19c), so a driver may refuse there what it allows locally. */ passthrough?( tool: string, diff --git a/src/core/fake-driver.ts b/src/core/fake-driver.ts index 3abd9fccb..462ae8c9a 100644 --- a/src/core/fake-driver.ts +++ b/src/core/fake-driver.ts @@ -39,6 +39,7 @@ import { UnknownModelError, uninstallPlan, } from "./driver.js"; +import type { DeviceInputFactory } from "./input.js"; import type { ScreenCapture } from "./screen.js"; export type FakeDriverOperation = @@ -129,6 +130,7 @@ export interface FakeDriverOptions { /** why: absent by default, so two fakes in one catalog do not both answer to the same tool. */ readonly passthroughTools?: readonly string[]; readonly screenCapture?: ScreenCapture; + readonly input?: DeviceInputFactory; readonly passthrough?: ( tool: string, device: VirtualDriverDevice | PhysicalDriverDevice | undefined, @@ -197,6 +199,7 @@ export class FakeDriver implements Driver { readonly #leaseEnvironment: Readonly>; readonly passthroughTools: readonly string[] | undefined; readonly screenCapture: ScreenCapture | undefined; + readonly input: DeviceInputFactory | undefined; readonly #passthrough: FakeDriverOptions["passthrough"]; #nextDeviceNumber = 1; readonly #pendingMakeReady: (() => void)[] = []; @@ -237,6 +240,7 @@ export class FakeDriver implements Driver { this.#legacyDevices = new Map(Object.entries(options.legacyDevices ?? {})); this.passthroughTools = options.passthroughTools; this.screenCapture = options.screenCapture; + this.input = options.input; this.#passthrough = options.passthrough; this.platform = options.platform; this.deviceRoot = options.deviceRoot ?? `/fake/${options.platform}`; diff --git a/src/core/index.ts b/src/core/index.ts index be69ab9ae..34b406093 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -102,6 +102,17 @@ export { type ScreenAvailability, UnknownPassthroughToolError, } from "./driver-catalog.js"; +export { + type DeviceInput, + type DeviceInputFactory, + encodeInputMessage, + INPUT_TAG, + isUnavailableInput, + type InputOrientation, + type InputScreenConfig, + type UnavailableDeviceInput, + UNAVAILABLE_INPUT_CONFIG, +} from "./input.js"; export { encodeScreenFrame, encodeScreenMeta, diff --git a/src/core/input.ts b/src/core/input.ts new file mode 100644 index 000000000..d3dd6da1b --- /dev/null +++ b/src/core/input.ts @@ -0,0 +1,69 @@ +import type { PhysicalDriverDevice, VirtualDriverDevice } from "./driver.js"; + +// why: the one table in TypeScript for the input socket's tags, in the messages expo's serve-sim speaks +// (ADR 0049 section 3): a client that already speaks them drives a lease with no Simlock-specific code. +export const INPUT_TAG = { + config: 0x82, + admitted: 0x83, +} as const; + +export type InputOrientation = + | "portrait" + | "portrait_upside_down" + | "landscape_left" + | "landscape_right"; + +// why: serve-sim's `0x82` payload, which T3 Code accepts only with a width and height above 0. +export interface InputScreenConfig { + readonly width: number; + readonly height: number; + readonly orientation: InputOrientation; + readonly inputUnavailable?: true; + readonly keyboardUnavailable?: true; +} + +// why: what a socket is told while its lease's device takes no input: a size T3 Code accepts and +// `inputUnavailable`, so its panel stays connected and blocks input. +export const UNAVAILABLE_INPUT_CONFIG: InputScreenConfig = { + height: 1, + inputUnavailable: true, + orientation: "portrait", + width: 1, +}; + +// why: what turns messages into taps on one device; one per lease, shared by every socket of it. +export interface DeviceInput { + // why: calls the listener with the current screen config at once, then on each change, so a socket + // that joins late learns the size as it is now. + onConfig(listener: (config: InputScreenConfig) => void): () => void; + onFailed(listener: (error: Error) => void): () => void; + close(): Promise; +} + +export interface UnavailableDeviceInput { + readonly unavailable: string; +} + +// why: an optional driver member beside `screenCapture`; a platform whose device cannot take input +// leaves it out, and the input module learns that from its absence rather than from a platform name. +export interface DeviceInputFactory { + open( + device: VirtualDriverDevice | PhysicalDriverDevice, + signal: AbortSignal, + ): Promise; +} + +export function isUnavailableInput( + opening: DeviceInput | UnavailableDeviceInput, +): opening is UnavailableDeviceInput { + return "unavailable" in opening; +} + +export function encodeInputMessage(tag: number, body?: unknown): Uint8Array { + const json = + body === undefined ? new Uint8Array() : new TextEncoder().encode(JSON.stringify(body)); + const message = new Uint8Array(1 + json.length); + message[0] = tag; + message.set(json, 1); + return message; +} diff --git a/src/daemon/input-lease.e2e.test.ts b/src/daemon/input-lease.e2e.test.ts index cf2d9c32d..d319b2e04 100644 --- a/src/daemon/input-lease.e2e.test.ts +++ b/src/daemon/input-lease.e2e.test.ts @@ -35,8 +35,12 @@ const agent: DispatchSession = { role: "agent", }; -const UNAVAILABLE_CONFIG = - '{"width":1,"height":1,"orientation":"portrait","inputUnavailable":true}'; +const UNAVAILABLE_CONFIG = { + height: 1, + inputUnavailable: true, + orientation: "portrait", + width: 1, +}; interface OpenedSocket { // why: 101 for an upgrade that was accepted, else the status the refusal answered. @@ -147,7 +151,9 @@ describe("a lease's input socket", () => { await vi.waitFor(() => expect(own.messages).toHaveLength(2), { timeout: 10_000 }); expect([...(own.messages[0] ?? [])]).toEqual([0x83]); expect(own.messages[1]?.[0]).toBe(0x82); - expect(own.messages[1]?.subarray(1).toString("utf8")).toBe(UNAVAILABLE_CONFIG); + expect(JSON.parse(own.messages[1]?.subarray(1).toString("utf8") ?? "")).toEqual( + UNAVAILABLE_CONFIG, + ); }, ); }); diff --git a/src/daemon/main.ts b/src/daemon/main.ts index 7688ceedf..1d632a46f 100644 --- a/src/daemon/main.ts +++ b/src/daemon/main.ts @@ -130,6 +130,7 @@ import { type GatewayServiceOptions, } from "../gateway/index.js"; import { + createUpgradeRouter, WebSocketUplinkConnector, WebSocketUplinkListenerFactory, } from "../ports/uplink-websocket.js"; @@ -498,6 +499,7 @@ async function composeDaemon( ); // why: a module's context is built before leasing exists, so `cancelPending` answers only once leasing does. let cancelPendingRequest: ((requesterId: string) => Promise) | undefined; + const catalog = new DriverCatalog(drivers); const moduleHost = await createModuleHost({ config, defaulted, @@ -521,7 +523,8 @@ async function composeDaemon( processRunner, processSupervisor, registry, - screens: new DriverCatalog(drivers), + inputs: catalog, + screens: catalog, tcpProbe, }, definitions: moduleDefinitions, @@ -817,6 +820,10 @@ async function composeDaemon( const gateway = new HttpGateway(app, { host: config.http.host, logger: httpLogger, + onServerCreated: (server) => + createUpgradeRouter({ clock, leaseSocket: (request) => app.leaseSocket(request) }).attach( + server, + ), port: config.http.port, }); await gateway.start(); @@ -1171,7 +1178,12 @@ async function startGatewayDaemon(options: GatewayDaemonOptions): Promise uplinks.attach(server), + onServerCreated: (server) => + createUpgradeRouter({ + clock, + leaseSocket: (request) => app.leaseSocket(request), + uplinks, + }).attach(server), port: config.http.port, }); await gateway.start(); diff --git a/src/http/app.ts b/src/http/app.ts index ddd1ac949..03e47fe28 100644 --- a/src/http/app.ts +++ b/src/http/app.ts @@ -14,7 +14,7 @@ import { } from "../contract/index.js"; import type { Config, DeviceRecord } from "../core/index.js"; import type { OwnerRoutedFacts } from "../daemon/owner-routed-facts.js"; -import type { Clock, IdGenerator, Logger } from "../ports/index.js"; +import type { Clock, IdGenerator, LeaseSocket, Logger } from "../ports/index.js"; import { type AuthEnv, requireAuth } from "./auth.js"; import { buildHttpSession, type HttpDispatch } from "./dispatcher-session.js"; import { @@ -131,16 +131,34 @@ export interface HttpGatewayDeps { // lease handler never checks who owns it; http knows a module only by its name. export interface HttpLeaseRoute { readonly name: string; - handle( - request: Request, - identity: Identity, - lease: { - readonly id: string; - readonly deviceId: string; - readonly workerId?: string; - readonly workerLeaseId?: string; - }, - ): Promise; + // why: the module's two-step socket handler, run for a WebSocket upgrade of the same path. + readonly socket?: { + open( + request: Request, + identity: Identity, + lease: HttpLeaseSocketLease, + ): Promise; + }; + handle(request: Request, identity: Identity, lease: HttpLeaseSocketLease): Promise; +} + +interface HttpLeaseSocketLease { + readonly id: string; + readonly deviceId: string; + readonly workerId?: string; + readonly workerLeaseId?: string; +} + +export type HttpLeaseSocketAccept = (socket: LeaseSocket) => void; + +// why: what the upgrade router needs of a lease socket request: the refusal to send before the +// upgrade, or the accept to run on the upgraded socket. +export type HttpLeaseSocketOutcome = + | { readonly response: Response } + | { readonly accept: HttpLeaseSocketAccept }; + +interface LeaseSocketCapture { + accept?: HttpLeaseSocketAccept; } export interface HttpMount { @@ -185,6 +203,9 @@ function leaseRequestOptionFields(body: z.infer) export interface HttpAppDisposable { readonly dispose: () => void; + // why: a WebSocket upgrade of a lease route goes through the app's own auth and lease resolution, + // so a refusal is the answer any request to the path would get; only the accept is new. + readonly leaseSocket: (request: Request) => Promise; } // fallow-ignore-next-line complexity -- route wiring for one focused resource surface; splitting it would scatter the shared closures (tracker, notices) across files for no clarity gain. @@ -805,12 +826,19 @@ export function createHttpApp(deps: HttpGatewayDeps): OpenAPIHono & HttpApp app.all(`/v1/leases/:id/${route.name}`, agentAuth, async (c) => { const identity = c.get("identity"); const lease = await findOwnedLease(deps, buildHttpSession(identity), c.req.param("id")); - return route.handle(c.req.raw, identity, { + const resolved = { deviceId: lease.deviceId, id: lease.id, ...(lease.workerId === undefined ? {} : { workerId: lease.workerId }), ...(lease.workerLeaseId === undefined ? {} : { workerLeaseId: lease.workerLeaseId }), - }); + }; + const capture = (c.env as { leaseSocket?: LeaseSocketCapture } | undefined)?.leaseSocket; + if (capture === undefined) return route.handle(c.req.raw, identity, resolved); + if (route.socket === undefined) return c.notFound(); + const opened = await route.socket.open(c.req.raw, identity, resolved); + if (typeof opened !== "function") return opened; + capture.accept = opened; + return new Response(null, { status: 204 }); }); } @@ -821,6 +849,11 @@ export function createHttpApp(deps: HttpGatewayDeps): OpenAPIHono & HttpApp dispose: () => { notices.dispose(); }, + leaseSocket: async (request: Request): Promise => { + const capture: LeaseSocketCapture = {}; + const response = await app.fetch(request, { leaseSocket: capture }); + return capture.accept === undefined ? { response } : { accept: capture.accept }; + }, }); } diff --git a/src/input/channel.ts b/src/input/channel.ts new file mode 100644 index 000000000..781c65507 --- /dev/null +++ b/src/input/channel.ts @@ -0,0 +1,45 @@ +import type { ModuleTwoWayPipe } from "../modules/index.js"; +import type { LeaseSocket } from "../ports/index.js"; +import { encodeFrame, readFrames } from "./framing.js"; + +// why: a worker's input session talks to a gateway's client through a relay channel, and the session +// takes a `LeaseSocket` whichever door the client came in by; this is the channel's end of that port, +// a socket whose messages are frames of the channel. +export function channelSocket(pipe: ModuleTwoWayPipe): LeaseSocket { + const writer = pipe.writable.getWriter(); + const messageListeners: ((data: Uint8Array) => void)[] = []; + const closeListeners: (() => void)[] = []; + let writes: Promise = Promise.resolve(); + let closed = false; + const markClosed = (): void => { + if (closed) return; + closed = true; + for (const listener of closeListeners) listener(); + }; + const write = (frame: Uint8Array): void => { + writes = writes.then(() => writer.write(frame)).catch(() => undefined); + }; + void (async () => { + for await (const frame of readFrames(pipe.readable)) { + const message = new Uint8Array(1 + frame.payload.length); + message[0] = frame.tag; + message.set(frame.payload, 1); + for (const listener of messageListeners) listener(message); + } + markClosed(); + })(); + pipe.signal.addEventListener("abort", markClosed, { once: true }); + return { + close: () => { + writes = writes.then(() => writer.close()).catch(() => undefined); + markClosed(); + }, + onClose: (listener) => { + closeListeners.push(listener); + }, + onMessage: (listener) => { + messageListeners.push(listener); + }, + send: (data) => write(encodeFrame(data[0] ?? 0, data.subarray(1))), + }; +} diff --git a/src/input/create.ts b/src/input/create.ts new file mode 100644 index 000000000..e0780755d --- /dev/null +++ b/src/input/create.ts @@ -0,0 +1,44 @@ +import type { + ModuleLeaseHandler, + ModuleLeaseSocketHandler, + ModuleRelayTwoWayTarget, + WorkerContext, +} from "../modules/index.js"; +import type { Logger } from "../ports/index.js"; +import { createGatewayInputLink, type GatewayInputLinkOptions } from "./gateway-link.js"; +import { upgradeRequired } from "./refusal.js"; +import { createWorkerInputLink, type InputRegistry } from "./worker-link.js"; + +export interface WorkerInputOptions { + readonly name: string; + readonly logger: Logger; + readonly inputs: WorkerContext["inputs"]; + readonly registry: InputRegistry; +} + +export interface WorkerInput { + readonly capability: { + readonly name: string; + read(): Promise<{ readonly state: "available" }>; + }; + readonly leaseRoute: ModuleLeaseHandler; + readonly relayTwoWay: ModuleRelayTwoWayTarget; +} + +const leaseRoute: ModuleLeaseHandler = () => Promise.resolve(upgradeRequired()); + +export function createWorkerInput(options: WorkerInputOptions): WorkerInput { + const link = createWorkerInputLink({ inputs: options.inputs, registry: options.registry }); + return { + capability: { name: options.name, read: () => Promise.resolve({ state: "available" }) }, + leaseRoute, + relayTwoWay: link.relayTwoWay, + }; +} + +export function createGatewayInput(options: GatewayInputLinkOptions): { + readonly leaseRoute: ModuleLeaseHandler; + readonly socket: ModuleLeaseSocketHandler; +} { + return { leaseRoute, socket: createGatewayInputLink(options) }; +} diff --git a/src/input/definition.ts b/src/input/definition.ts new file mode 100644 index 000000000..fec6ad34f --- /dev/null +++ b/src/input/definition.ts @@ -0,0 +1,15 @@ +import { objectValidator } from "../core/index.js"; +import type { OptionalModuleDefinition } from "../modules/index.js"; + +export type InputModuleConfig = Record; + +export const inputModule: OptionalModuleDefinition = { + config: { + defaults: {}, + gatewayKeys: [], + validator: objectValidator({}), + }, + loadGateway: () => import("./gateway.js"), + loadWorker: () => import("./worker.js"), + name: "input", +}; diff --git a/src/input/framing.ts b/src/input/framing.ts new file mode 100644 index 000000000..144838915 --- /dev/null +++ b/src/input/framing.ts @@ -0,0 +1,48 @@ +// why: the channel framing of a two-way relay (ADR 0049 section 5): `[len u32 big-endian][tag u8][payload]`, +// `len` counting the tag and the payload. Only the input module encodes and reads it; the relay moves bytes. +export const MAX_MESSAGE_BYTES = 64 * 1024; + +export interface InputFrame { + readonly tag: number; + readonly payload: Uint8Array; +} + +export function encodeFrame(tag: number, payload: Uint8Array): Uint8Array { + const frame = new Uint8Array(5 + payload.length); + new DataView(frame.buffer).setUint32(0, payload.length + 1); + frame[4] = tag; + frame.set(payload, 5); + return frame; +} + +function joined(head: Uint8Array, tail: Uint8Array): Uint8Array { + const all = new Uint8Array(head.length + tail.length); + all.set(head); + all.set(tail, head.length); + return all; +} + +// why: ends quietly on a frame longer than a message may be, a stream that ends mid-frame and a stream +// that errors, so a peer that breaks the framing or drops is the same as one that went away. +export async function* readFrames( + readable: ReadableStream, +): AsyncGenerator { + const reader = readable.getReader(); + let buffer: Uint8Array = new Uint8Array(); + try { + for (;;) { + while (buffer.length >= 4) { + const length = new DataView(buffer.buffer, buffer.byteOffset, buffer.length).getUint32(0); + if (length < 1 || length > MAX_MESSAGE_BYTES + 1) return; + if (buffer.length < 4 + length) break; + yield { payload: buffer.slice(5, 4 + length), tag: buffer[4] ?? 0 }; + buffer = buffer.subarray(4 + length); + } + const step = await reader.read().catch(() => undefined); + if (step === undefined || step.done) return; + buffer = joined(buffer, step.value); + } + } finally { + void reader.cancel().catch(() => undefined); + } +} diff --git a/src/input/gateway-link.ts b/src/input/gateway-link.ts new file mode 100644 index 000000000..074a7153d --- /dev/null +++ b/src/input/gateway-link.ts @@ -0,0 +1,55 @@ +import type { + ModuleIdentity, + ModuleLease, + ModuleLeaseSocketAccept, + ModuleTwoWayRelay, +} from "../modules/index.js"; +import type { LeaseSocket } from "../ports/index.js"; +import { readFrames } from "./framing.js"; + +export interface GatewayInputLinkOptions { + readonly relayTwoWay: ( + workerId: string, + request: Request, + tenant: string, + leaseId?: string, + ) => Promise; + readonly requesterPrefix: string; +} + +export function createGatewayInputLink(options: GatewayInputLinkOptions): { + open( + request: Request, + identity: ModuleIdentity, + lease: ModuleLease, + ): Promise; +} { + return { + open: async (_request, identity, lease) => { + const controller = new AbortController(); + const relay = await options.relayTwoWay( + lease.workerId ?? "", + new Request(`http://simlock-worker/input/leases/${encodeURIComponent(lease.id)}`, { + method: "GET", + signal: controller.signal, + }), + `${options.requesterPrefix}${identity.principal}`, + lease.workerLeaseId, + ); + if (relay instanceof Response) return relay; + return (socket) => bridge(socket, relay, controller); + }, + }; +} + +function bridge(socket: LeaseSocket, relay: ModuleTwoWayRelay, controller: AbortController): void { + socket.onClose(() => controller.abort()); + void (async () => { + for await (const frame of readFrames(relay.readable)) { + const message = new Uint8Array(1 + frame.payload.length); + message[0] = frame.tag; + message.set(frame.payload, 1); + socket.send(message); + } + })(); +} diff --git a/src/input/gateway.ts b/src/input/gateway.ts new file mode 100644 index 000000000..130b52a03 --- /dev/null +++ b/src/input/gateway.ts @@ -0,0 +1,12 @@ +import type { GatewayContext, GatewayModule } from "../modules/index.js"; +import type { InputModuleConfig } from "./definition.js"; +import { createGatewayInput } from "./create.js"; + +export function createGateway(ctx: GatewayContext): GatewayModule { + const input = createGatewayInput({ + relayTwoWay: (workerId, request, tenant, leaseId) => + ctx.relayTwoWay(workerId, request, tenant, leaseId), + requesterPrefix: ctx.requesterPrefix, + }); + return { leaseRoute: input.leaseRoute, socket: input.socket }; +} diff --git a/src/input/index.ts b/src/input/index.ts new file mode 100644 index 000000000..d7aeeb432 --- /dev/null +++ b/src/input/index.ts @@ -0,0 +1 @@ +export { inputModule } from "./definition.js"; diff --git a/src/input/refusal.ts b/src/input/refusal.ts new file mode 100644 index 000000000..ce5a146d8 --- /dev/null +++ b/src/input/refusal.ts @@ -0,0 +1,17 @@ +function refusal(status: number, code: string, message: string, headers?: HeadersInit): Response { + return new Response(JSON.stringify({ error: { code, message } }), { + headers: { "content-type": "application/json", ...headers }, + status, + }); +} + +// why: a plain GET to the socket's path says what the path is for, so a person who opens it in a browser knows to open a socket. +export function upgradeRequired(): Response { + return refusal(426, "BAD_REQUEST", "This path takes a WebSocket upgrade, not a plain request", { + upgrade: "websocket", + }); +} + +export function unknownLease(leaseId: string): Response { + return refusal(404, "UNKNOWN_LEASE", `Unknown lease: ${leaseId}`); +} diff --git a/src/input/worker-link.ts b/src/input/worker-link.ts new file mode 100644 index 000000000..40d42303d --- /dev/null +++ b/src/input/worker-link.ts @@ -0,0 +1,60 @@ +import { + encodeInputMessage, + INPUT_TAG, + isUnavailableInput, + toDriverDevice, + UNAVAILABLE_INPUT_CONFIG, + type DeviceRecord, + type LeaseRecord, +} from "../core/index.js"; +import type { ModuleInputs, ModuleRelayTwoWayTarget } from "../modules/index.js"; +import type { LeaseSocket } from "../ports/index.js"; +import { channelSocket } from "./channel.js"; +import { unknownLease } from "./refusal.js"; + +export interface InputRegistry { + readonly snapshot: { + readonly devices: readonly DeviceRecord[]; + readonly leases: readonly LeaseRecord[]; + }; +} + +export interface WorkerInputLinkOptions { + readonly inputs: ModuleInputs; + readonly registry: InputRegistry; +} + +// why: the worker's own record is the truth for a lease a gateway names; a lease gone from it is +// unknown whoever asks, as `lease.get` answers. +export function createWorkerInputLink(options: WorkerInputLinkOptions): { + readonly relayTwoWay: ModuleRelayTwoWayTarget; +} { + return { + relayTwoWay: (_request, _tenant, leaseId, pipe) => { + const lease = options.registry.snapshot.leases.find((entry) => entry.id === leaseId); + if (lease === undefined) return Promise.resolve(unknownLease(leaseId ?? "")); + void serve(options, lease, channelSocket(pipe)); + return Promise.resolve(new Response(null, { status: 200 })); + }, + }; +} + +async function serve( + options: WorkerInputLinkOptions, + lease: LeaseRecord, + socket: LeaseSocket, +): Promise { + const device = options.registry.snapshot.devices.find((entry) => entry.id === lease.deviceId); + if (device === undefined) return; + const opening = await options.inputs.openInput( + device.spec.platform, + toDriverDevice(device), + new AbortController().signal, + ); + if (!isUnavailableInput(opening)) { + await opening.close(); + return; + } + socket.send(encodeInputMessage(INPUT_TAG.admitted)); + socket.send(encodeInputMessage(INPUT_TAG.config, UNAVAILABLE_INPUT_CONFIG)); +} diff --git a/src/input/worker.ts b/src/input/worker.ts new file mode 100644 index 000000000..8ce03f993 --- /dev/null +++ b/src/input/worker.ts @@ -0,0 +1,17 @@ +import type { WorkerContext, WorkerModule } from "../modules/index.js"; +import type { InputModuleConfig } from "./definition.js"; +import { createWorkerInput } from "./create.js"; + +export function createWorker(ctx: WorkerContext): WorkerModule { + const input = createWorkerInput({ + inputs: ctx.inputs, + logger: ctx.logger, + name: ctx.name, + registry: ctx.registry, + }); + return { + capability: input.capability, + leaseRoute: input.leaseRoute, + relayTwoWay: input.relayTwoWay, + }; +} diff --git a/src/module-registry/index.ts b/src/module-registry/index.ts index 25e782417..6bd3e555f 100644 --- a/src/module-registry/index.ts +++ b/src/module-registry/index.ts @@ -1,8 +1,10 @@ import { agentDeviceModule } from "../agent-device/index.js"; +import { inputModule } from "../input/index.js"; import type { OptionalModuleDefinition } from "../modules/index.js"; import { streamModule } from "../stream/index.js"; export const optionalModules: readonly OptionalModuleDefinition[] = [ agentDeviceModule as OptionalModuleDefinition, + inputModule as OptionalModuleDefinition, streamModule as OptionalModuleDefinition, ]; diff --git a/src/modules/host.ts b/src/modules/host.ts index 417ff2ced..6bca07453 100644 --- a/src/modules/host.ts +++ b/src/modules/host.ts @@ -296,7 +296,15 @@ async function createGatewayHost(options: GatewayHostOptions): Promise started.flatMap(({ gateway, name }) => - gateway.leaseRoute === undefined ? [] : [{ handle: gateway.leaseRoute, name }], + gateway.leaseRoute === undefined + ? [] + : [ + { + handle: gateway.leaseRoute, + name, + ...(gateway.socket === undefined ? {} : { socket: gateway.socket }), + }, + ], ), }; } @@ -404,7 +412,15 @@ async function createWorkerHost(options: ModuleHostOptions): Promise }, leaseRoutes: () => started.flatMap(({ name, worker }) => - worker.leaseRoute === undefined ? [] : [{ handle: worker.leaseRoute, name }], + worker.leaseRoute === undefined + ? [] + : [ + { + handle: worker.leaseRoute, + name, + ...(worker.socket === undefined ? {} : { socket: worker.socket }), + }, + ], ), relayTargets, relayTwoWayTargets, diff --git a/src/modules/index.ts b/src/modules/index.ts index 541c9eb0b..bb4063d71 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -12,11 +12,19 @@ export type { ModuleEndedLease, ModuleFinding, ModuleHttpResponse, + ModuleIdentity, + ModuleInputs, ModuleLease, ModuleLeaseEndParticipant, + ModuleLeaseHandler, + ModuleLeaseSocketAccept, + ModuleLeaseSocketHandler, ModuleMount, ModuleRegistryView, ModuleRelayTarget, + ModuleRelayTwoWayTarget, + ModuleTwoWayPipe, + ModuleTwoWayRelay, ModuleTokenRecord, ModuleTokens, OptionalModuleDefinition, diff --git a/src/modules/testing.ts b/src/modules/testing.ts index 1d48f4a52..938ecc9de 100644 --- a/src/modules/testing.ts +++ b/src/modules/testing.ts @@ -189,6 +189,7 @@ export async function fakeWorkerContext(name: string, config: C): Promise Promise.resolve({ unavailable: "no input in a fake context" }) }, screens: { openScreen: () => Promise.reject(new Error("no screen in a fake context")), screenAvailability: () => Promise.resolve([]), diff --git a/src/modules/types.ts b/src/modules/types.ts index ccd07d178..61bde9e40 100644 --- a/src/modules/types.ts +++ b/src/modules/types.ts @@ -10,7 +10,9 @@ import type { } from "../contract/index.js"; import type { Config, + DeviceInput, DeviceRecord, + UnavailableDeviceInput, LeaseRecord, PhysicalDriverDevice, Platform, @@ -22,6 +24,7 @@ import type { import type { Clock, Filesystem, + LeaseSocket, Logger, ProcessRunner, ProcessSupervisor, @@ -124,9 +127,22 @@ export type ModuleLeaseHandler = ( lease: ModuleLease, ) => Promise; +// why: a lease socket handler has two steps so a refusal is a plain `Response` sent before the upgrade +// (a gateway's 503 for a worker that cannot take the socket), and only an accept ever sees a socket. +export type ModuleLeaseSocketAccept = (socket: LeaseSocket) => void; + +export interface ModuleLeaseSocketHandler { + open( + request: Request, + identity: ModuleIdentity, + lease: ModuleLease, + ): Promise; +} + export interface ModuleLeaseRoute { readonly name: string; readonly handle: ModuleLeaseHandler; + readonly socket?: ModuleLeaseSocketHandler; } export interface MountDeps { @@ -146,6 +162,7 @@ export interface WorkerModule { readonly relayTwoWay?: ModuleRelayTwoWayTarget; readonly mount?: (deps: MountDeps) => ModuleHandler; readonly leaseRoute?: ModuleLeaseHandler; + readonly socket?: ModuleLeaseSocketHandler; readonly findings?: () => readonly ModuleFinding[]; readonly hooks?: WorkerHooks; } @@ -170,6 +187,15 @@ export interface ModuleScreens { screenAvailability(): Promise; } +// why: the drivers' device input, routed by platform, without handing a module the drivers. +export interface ModuleInputs { + openInput( + platform: Platform, + device: VirtualDriverDevice | PhysicalDriverDevice, + signal: AbortSignal, + ): Promise; +} + export interface WorkerContext { readonly name: string; readonly instanceId: string; @@ -191,6 +217,7 @@ export interface WorkerContext { readonly leaseEnvironment: Readonly>; readonly registry: ModuleRegistryView; readonly screens: ModuleScreens; + readonly inputs: ModuleInputs; cancelPending(requesterId: string): Promise; readonly events: { subscribe(event: Event, handler: EventHandler): () => void; @@ -225,6 +252,7 @@ export interface GatewayHooks { export interface GatewayModule { readonly mount?: (deps: MountDeps) => ModuleHandler; readonly leaseRoute?: ModuleLeaseHandler; + readonly socket?: ModuleLeaseSocketHandler; readonly hooks?: GatewayHooks; } diff --git a/src/ports/index.ts b/src/ports/index.ts index 2acd9795b..e7ed22894 100644 --- a/src/ports/index.ts +++ b/src/ports/index.ts @@ -51,6 +51,7 @@ export { type ServiceManagerName, type ServiceState, } from "./service-manager.js"; +export { type LeaseSocket, MemoryLeaseSocket } from "./lease-socket.js"; export { type Clock, FakeClock, SystemClock, type TimerHandle } from "./clock.js"; /** * ADR 0005 §33's uplink port and its in-memory adapter. The WebSocket adapters are diff --git a/src/ports/lease-socket.ts b/src/ports/lease-socket.ts new file mode 100644 index 000000000..985ea53f0 --- /dev/null +++ b/src/ports/lease-socket.ts @@ -0,0 +1,44 @@ +// why: the port a module's lease socket handler talks to, so no module imports `ws` (only the upgrade +// router and the uplink adapter do). Messages are binary; a close carries a code and a reason the +// caller has already cut to 123 bytes. +export interface LeaseSocket { + send(data: Uint8Array): void; + close(code: number, reason: string): void; + onMessage(listener: (data: Uint8Array) => void): void; + onClose(listener: () => void): void; +} + +// why: the in-memory end of a `LeaseSocket` for tests: `sent` records what the module wrote and +// `closed` how it closed; `receive` and `drop` play the client. +export class MemoryLeaseSocket implements LeaseSocket { + readonly sent: Uint8Array[] = []; + closed: { readonly code: number; readonly reason: string } | undefined; + readonly #messageListeners: ((data: Uint8Array) => void)[] = []; + readonly #closeListeners: (() => void)[] = []; + + send(data: Uint8Array): void { + if (this.closed === undefined) this.sent.push(data); + } + + close(code: number, reason: string): void { + if (this.closed !== undefined) return; + this.closed = { code, reason }; + this.drop(); + } + + onMessage(listener: (data: Uint8Array) => void): void { + this.#messageListeners.push(listener); + } + + onClose(listener: () => void): void { + this.#closeListeners.push(listener); + } + + receive(data: Uint8Array): void { + for (const listener of this.#messageListeners) listener(data); + } + + drop(): void { + for (const listener of this.#closeListeners.splice(0)) listener(); + } +} diff --git a/src/ports/upgrade-router.ts b/src/ports/upgrade-router.ts new file mode 100644 index 000000000..07d519c57 --- /dev/null +++ b/src/ports/upgrade-router.ts @@ -0,0 +1,149 @@ +// why: the one `upgrade` listener of an HTTP server: `/v1/uplink` goes to the uplink's own handler, +// `/v1/leases/{id}/` to the app's lease socket resolution, and anything else gets a 404 and its +// socket destroyed. With `uplink-websocket.ts`, the only code that imports `ws`; the ports barrel does +// not re-export it, so the CLI and MCP server never load it. +import type { IncomingMessage, Server } from "node:http"; +import type { Duplex } from "node:stream"; + +import { WebSocketServer, type RawData, type WebSocket } from "ws"; + +import { type Clock, SystemClock } from "./clock.js"; +import type { LeaseSocket } from "./lease-socket.js"; +import { UPLINK_PATH } from "./uplink.js"; + +export type LeaseSocketOutcome = + | { readonly response: Response } + | { readonly accept: (socket: LeaseSocket) => void }; + +export interface UpgradeRouterOptions { + readonly uplinks?: { + handleUpgrade(request: IncomingMessage, socket: Duplex, head: Buffer): Promise; + }; + readonly leaseSocket: (request: Request) => Promise; + readonly clock?: Clock; +} + +export function respondAndDestroy(socket: Duplex, status: number, reason: string): void { + socket.write(`HTTP/1.1 ${String(status)} ${reason}\r\nConnection: close\r\n\r\n`); + socket.destroy(); +} + +const LEASE_SOCKET_PATH = /^\/v1\/leases\/[^/]+\/[^/]+$/; + +// why: a ping that finds no pong since the previous one ends the socket, so a client that vanished +// without closing is noticed within one of these after the first. +const CLIENT_PING_INTERVAL_MS = 15_000; + +// why: an input message is at most 64 KiB; this bound is the router's memory limit for one message, +// above it so a module can drop an oversized message and keep the socket. +const MAX_MESSAGE_BYTES = 1024 * 1024; + +export function createUpgradeRouter(options: UpgradeRouterOptions): { + readonly attach: (server: Server) => void; +} { + const sockets = new WebSocketServer({ maxPayload: MAX_MESSAGE_BYTES, noServer: true }); + const clock = options.clock ?? new SystemClock(); + const upgrade = (request: IncomingMessage, socket: Duplex, head: Buffer): void => { + const path = (request.url ?? "").split("?")[0] ?? ""; + if (path === UPLINK_PATH && options.uplinks !== undefined) { + void options.uplinks.handleUpgrade(request, socket, head); + } else if (LEASE_SOCKET_PATH.test(path)) { + void leaseUpgrade(options, sockets, clock, request, socket, head); + } else { + respondAndDestroy(socket, 404, "Not Found"); + } + }; + return { attach: (server) => server.on("upgrade", upgrade) }; +} + +async function leaseUpgrade( + options: UpgradeRouterOptions, + sockets: WebSocketServer, + clock: Clock, + request: IncomingMessage, + socket: Duplex, + head: Buffer, +): Promise { + let outcome: LeaseSocketOutcome; + try { + outcome = await options.leaseSocket(toRequest(request)); + } catch { + respondAndDestroy(socket, 500, "Internal Server Error"); + return; + } + if ("response" in outcome) { + await refuse(socket, outcome.response); + return; + } + const { accept } = outcome; + sockets.handleUpgrade(request, socket, head, (client) => { + accept(wrap(client, clock)); + }); +} + +function toRequest(request: IncomingMessage): Request { + const headers = new Headers(); + for (const [name, value] of Object.entries(request.headers)) { + if (value === undefined) continue; + headers.set(name, Array.isArray(value) ? value.join(", ") : value); + } + return new Request(`http://${request.headers.host ?? "simlock"}${request.url ?? "/"}`, { + headers, + method: request.method ?? "GET", + }); +} + +async function refuse(socket: Duplex, response: Response): Promise { + const body = Buffer.from(await response.arrayBuffer()); + const headers = new Headers(response.headers); + headers.set("connection", "close"); + headers.set("content-length", String(body.length)); + let head = `HTTP/1.1 ${String(response.status)} ${response.statusText || "Refused"}\r\n`; + for (const [name, value] of headers) head += `${name}: ${value}\r\n`; + socket.write(`${head}\r\n`); + socket.write(body); + socket.destroy(); +} + +function bytesOf(data: RawData): Uint8Array { + if (Array.isArray(data)) return Buffer.concat(data); + return Buffer.isBuffer(data) ? data : Buffer.from(data as ArrayBuffer); +} + +function wrap(client: WebSocket, clock: Clock): LeaseSocket { + const closeListeners: (() => void)[] = []; + let awaitingPong = false; + let timer: ReturnType | undefined; + const tick = (): void => { + if (awaitingPong) { + client.terminate(); + return; + } + awaitingPong = true; + client.ping(); + timer = clock.setTimer(CLIENT_PING_INTERVAL_MS, tick); + }; + timer = clock.setTimer(CLIENT_PING_INTERVAL_MS, tick); + client.on("pong", () => { + awaitingPong = false; + }); + client.on("error", () => undefined); + client.once("close", () => { + if (timer !== undefined) clock.cancel(timer); + for (const listener of closeListeners) listener(); + }); + return { + close: (code, reason) => client.close(code, reason), + onClose: (listener) => { + closeListeners.push(listener); + }, + onMessage: (listener) => { + client.on("message", (data: RawData, isBinary: boolean) => { + if (isBinary) listener(bytesOf(data)); + }); + }, + send: (data) => { + if (client.readyState === client.OPEN) client.send(data, { binary: true }); + }, + }; +} diff --git a/src/ports/uplink-websocket.ts b/src/ports/uplink-websocket.ts index 5ca015571..7e2c821dc 100644 --- a/src/ports/uplink-websocket.ts +++ b/src/ports/uplink-websocket.ts @@ -20,6 +20,7 @@ import type { Duplex } from "node:stream"; import { WebSocket, WebSocketServer, type RawData } from "ws"; import { subscribeListener } from "./ipc.js"; +import { respondAndDestroy } from "./upgrade-router.js"; import { resolveUplinkUrl, uplinkOutcome, @@ -36,6 +37,9 @@ import { type UplinkTransportConnection, } from "./uplink.js"; +// why: the daemon entry imports the router through this file, the one `ws` file it is already allowed to reach into. +export { createUpgradeRouter, type LeaseSocketOutcome } from "./upgrade-router.js"; + /** why: a ping that finds no pong since the previous one terminates the link, so a gateway that * vanished without closing is noticed within two of these. */ export const UPLINK_PING_INTERVAL_MS = 15_000; @@ -203,7 +207,7 @@ export class WebSocketUplinkListenerFactory implements UplinkListenerFactory { /** Wires this factory to a Node HTTP server's `upgrade` event. */ attach(server: Server): void { const onUpgrade = (request: IncomingMessage, socket: Duplex, head: Buffer) => { - void this.#handleUpgrade(request, socket, head); + void this.handleUpgrade(request, socket, head); }; server.on("upgrade", onUpgrade); this.#detach = () => { @@ -211,7 +215,9 @@ export class WebSocketUplinkListenerFactory implements UplinkListenerFactory { }; } - async #handleUpgrade(request: IncomingMessage, socket: Duplex, head: Buffer): Promise { + // why: public so the upgrade router, which owns the server's one `upgrade` listener, hands this + // factory the requests for `/v1/uplink` exactly as `attach` does. + async handleUpgrade(request: IncomingMessage, socket: Duplex, head: Buffer): Promise { const handlers = this.#handlers; if (handlers === undefined) { // Nothing is listening for uplinks (a daemon mid-shutdown): refuse rather than leave the @@ -435,8 +441,3 @@ function decodeLabel(value: string | undefined): string | undefined { return undefined; } } - -function respondAndDestroy(socket: Duplex, status: number, reason: string): void { - socket.write(`HTTP/1.1 ${String(status)} ${reason}\r\nConnection: close\r\n\r\n`); - socket.destroy(); -} diff --git a/website/src/content/docs/docs/reference/configuration.mdx b/website/src/content/docs/docs/reference/configuration.mdx index c7bc36aaa..650a1b302 100644 --- a/website/src/content/docs/docs/reference/configuration.mdx +++ b/website/src/content/docs/docs/reference/configuration.mdx @@ -312,6 +312,7 @@ A name Simlock does not know is warned about and ignored. | `capabilities.agent-device.ios.bundleId` | non-empty string | not set | Bundle ID of agent-device's iPhone runner. Passed to the phone daemon. | Worker | | `capabilities.agent-device.ios.provisioningProfile` | non-empty string | not set | Provisioning profile for manual signing of the iPhone runner. Setting it switches agent-device to manual signing. Passed to the phone daemon. | Worker | | `capabilities.agent-device.ios.signingIdentity` | non-empty string | not set | Code-signing identity for manual signing of the iPhone runner. Passed to the phone daemon. | Worker | +| `capabilities.input.enabled` | boolean | `false` | Let a lease's holder send taps and keys to its device over `WS /v1/leases/{id}/input`. On a gateway: pass each input socket through to the lease's worker, which needs it on too. | Both | | `capabilities.stream.enabled` | boolean | `false` | Let people watch a running device's screen. On a gateway: pass each stream through from the worker. | Both | | `capabilities.stream.fps` | integer `1`–`60` | `30` | Highest frame rate. A still screen sends far fewer frames. | Worker | | `capabilities.stream.maxDimension` | integer `96`–`4096` | `720` | Longest side of the picture, in pixels. The setting that changes bandwidth most. | Worker | From 8e273433e5a492802696667244e7446a05f11135 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 26 Oct 2026 19:37:39 +0100 Subject: [PATCH 03/28] test: a caller opens the input socket on a standalone worker and through a gateway, or is refused (#1017) --- src/daemon/input-lease.e2e.test.ts | 200 ++++++++++++++++++++++------- 1 file changed, 154 insertions(+), 46 deletions(-) diff --git a/src/daemon/input-lease.e2e.test.ts b/src/daemon/input-lease.e2e.test.ts index d319b2e04..881422f36 100644 --- a/src/daemon/input-lease.e2e.test.ts +++ b/src/daemon/input-lease.e2e.test.ts @@ -45,6 +45,8 @@ const UNAVAILABLE_CONFIG = { interface OpenedSocket { // why: 101 for an upgrade that was accepted, else the status the refusal answered. readonly status: number; + // why: the error code of a refusal's JSON body, when it has one. + readonly code: string | undefined; readonly messages: Buffer[]; } @@ -55,12 +57,28 @@ async function openInput(url: string, token: string | undefined): Promise messages.push(data as Buffer)); - const status = await new Promise((resolve, reject) => { - socket.once("open", () => resolve(101)); - socket.once("unexpected-response", (_request, response) => resolve(response.statusCode ?? 0)); - socket.once("error", reject); - }); - return { messages, status }; + const outcome = await new Promise<{ status: number; code: string | undefined }>( + (resolve, reject) => { + socket.once("open", () => resolve({ code: undefined, status: 101 })); + socket.once("unexpected-response", (_request, response) => { + let body = ""; + response.on("data", (chunk: Buffer) => { + body += chunk.toString("utf8"); + }); + response.on("end", () => { + let code: string | undefined; + try { + code = (JSON.parse(body) as { error?: { code?: string } }).error?.code; + } catch { + code = undefined; + } + resolve({ code, status: response.statusCode ?? 0 }); + }); + }); + socket.once("error", reject); + }, + ); + return { ...outcome, messages }; } const sockets: WebSocket[] = []; @@ -77,16 +95,48 @@ describe("a lease's input socket", () => { ); }); - async function fleet() { - const gatewayDirectory = await mkdtemp(join(tmpdir(), "simlock-e2e-input-gateway-")); - directories.push(gatewayDirectory); + async function directory(name: string): Promise { + const path = await mkdtemp(join(tmpdir(), `simlock-e2e-input-${name}-`)); + directories.push(path); + return path; + } + + function driver(): FakeDriver { + return new FakeDriver({ + availableOsVersions: ["18.0"], + clock: new SystemClock(), + knownModels: ["Pixel-A"], + platform: "android", + }); + } + + async function tokens(daemon: DaemonServer) { + const make = (label: string, role: "agent" | "operator") => + daemon.dispatch("token.create", { label, role }, admin); + return { + holder: await make("holder", "agent"), + operator: await make("op", "operator"), + other: await make("other", "agent"), + }; + } + + async function lease(daemon: DaemonServer, principal: string): Promise { + const granted = await daemon.dispatch( + "lease.request", + { model: "Pixel-A", platform: "android" }, + { ...agent, principal }, + ); + return (granted as { lease: { id: string } }).lease.id; + } + + async function fleet(options: { readonly gatewayInput: boolean; readonly workerInput: boolean }) { const port = await freeLoopbackPort(); const gateway = await startDaemon({ configOverrides: { - capabilities: { input: { enabled: true } }, + capabilities: { input: { enabled: options.gatewayInput } }, http: { enabled: true, host: "127.0.0.1", port }, }, - dataDirectory: gatewayDirectory, + dataDirectory: await directory("gateway"), filesystem: new MemoryFilesystem(), logger: new NoopLogger(), role: "gateway", @@ -95,23 +145,14 @@ describe("a lease's input socket", () => { daemons.push(gateway); const token = (await gateway.dispatch("token.create", { label: "w", role: "worker" }, admin)) .secret; - const workerDirectory = await mkdtemp(join(tmpdir(), "simlock-e2e-input-worker-")); - directories.push(workerDirectory); const worker = await startDaemon({ configOverrides: { - capabilities: { input: { enabled: true } }, + capabilities: { input: { enabled: options.workerInput } }, gateway: { label: "w", token, url: `ws://127.0.0.1:${String(port)}` }, }, - dataDirectory: workerDirectory, + dataDirectory: await directory("worker"), deviceDataTables: fakeDeviceDataTables, - drivers: [ - new FakeDriver({ - availableOsVersions: ["18.0"], - clock: new SystemClock(), - knownModels: ["Pixel-A"], - platform: "android", - }), - ], + drivers: [driver()], filesystem: new MemoryFilesystem(), logger: new NoopLogger(), version: "1.0.0-e2e", @@ -124,36 +165,103 @@ describe("a lease's input socket", () => { }, { timeout: 10_000 }, ); - const holder = await gateway.dispatch( - "token.create", - { label: "holder", role: "agent" }, - admin, + const { holder, operator, other } = await tokens(gateway); + const leaseId = await lease(gateway, holder.token.id); + await vi.waitFor( + () => gateway.dispatch("lease.get", { leaseId }, { ...agent, principal: holder.token.id }), + { timeout: 10_000 }, ); - const owner: DispatchSession = { ...agent, principal: holder.token.id }; - const granted = await gateway.dispatch( - "lease.request", - { model: "Pixel-A", platform: "android" }, - owner, + return { + holder: holder.secret, + inputUrl: (id = leaseId) => `ws://127.0.0.1:${String(port)}/v1/leases/${id}/input`, + leaseId, + operator: operator.secret, + other: other.secret, + plainUrl: (id = leaseId) => `http://127.0.0.1:${String(port)}/v1/leases/${id}/input`, + }; + } + + async function standalone() { + const port = await freeLoopbackPort(); + const worker = await startDaemon({ + configOverrides: { + capabilities: { input: { enabled: true } }, + http: { enabled: true, host: "127.0.0.1", port }, + }, + dataDirectory: await directory("standalone"), + deviceDataTables: fakeDeviceDataTables, + drivers: [driver()], + filesystem: new MemoryFilesystem(), + logger: new NoopLogger(), + version: "1.0.0-e2e", + }); + daemons.push(worker); + const { holder, operator, other } = await tokens(worker); + const leaseId = await lease(worker, holder.token.id); + return { + holder: holder.secret, + inputUrl: (id = leaseId) => `ws://127.0.0.1:${String(port)}/v1/leases/${id}/input`, + leaseId, + operator: operator.secret, + other: other.secret, + plainUrl: (id = leaseId) => `http://127.0.0.1:${String(port)}/v1/leases/${id}/input`, + }; + } + + async function expectAdmitted(url: string, token: string): Promise { + const opened = await openInput(url, token); + expect(opened.status).toBe(101); + await vi.waitFor(() => expect(opened.messages).toHaveLength(2), { timeout: 10_000 }); + expect([...(opened.messages[0] ?? [])]).toEqual([0x83]); + expect(opened.messages[1]?.[0]).toBe(0x82); + expect(JSON.parse(opened.messages[1]?.subarray(1).toString("utf8") ?? "")).toEqual( + UNAVAILABLE_CONFIG, ); - const leaseId = (granted as { lease: { id: string } }).lease.id; - await vi.waitFor(() => gateway.dispatch("lease.get", { leaseId }, owner), { timeout: 10_000 }); - const inputUrl = (id = leaseId) => `ws://127.0.0.1:${String(port)}/v1/leases/${id}/input`; - return { holder: holder.secret, inputUrl, leaseId }; + } + + async function expectRefused( + url: string, + token: string | undefined, + refusal: { readonly status: number; readonly code: string | undefined }, + ): Promise { + const opened = await openInput(url, token); + expect({ code: opened.code, status: opened.status }).toEqual(refusal); + expect(opened.messages).toEqual([]); } it( "opens WS /v1/leases/{id}/input and gets 0x83 then 0x82, or a refused upgrade", - { timeout: 30_000 }, + { timeout: 60_000 }, async () => { - const { holder, inputUrl } = await fleet(); - const own = await openInput(inputUrl(), holder); - expect(own.status).toBe(101); - await vi.waitFor(() => expect(own.messages).toHaveLength(2), { timeout: 10_000 }); - expect([...(own.messages[0] ?? [])]).toEqual([0x83]); - expect(own.messages[1]?.[0]).toBe(0x82); - expect(JSON.parse(own.messages[1]?.subarray(1).toString("utf8") ?? "")).toEqual( - UNAVAILABLE_CONFIG, - ); + const through = await fleet({ gatewayInput: true, workerInput: true }); + await expectAdmitted(through.inputUrl(), through.holder); + await expectAdmitted(through.inputUrl(), through.operator); + const unknown = { code: "UNKNOWN_LEASE", status: 404 }; + await expectRefused(through.inputUrl(), through.other, unknown); + await expectRefused(through.inputUrl("L-unknown"), through.holder, unknown); + await expectRefused(through.inputUrl(), undefined, { code: "UNAUTHENTICATED", status: 401 }); + const plain = await fetch(through.plainUrl(), { + headers: { authorization: `Bearer ${through.holder}` }, + }); + expect(plain.status).toBe(426); + + const direct = await standalone(); + await expectAdmitted(direct.inputUrl(), direct.holder); + await expectAdmitted(direct.inputUrl(), direct.operator); + await expectRefused(direct.inputUrl(), direct.other, unknown); + await expectRefused(direct.inputUrl(), undefined, { code: "UNAUTHENTICATED", status: 401 }); + + const gatewayOff = await fleet({ gatewayInput: false, workerInput: true }); + await expectRefused(gatewayOff.inputUrl(), gatewayOff.holder, { + code: undefined, + status: 404, + }); + + const workerOff = await fleet({ gatewayInput: true, workerInput: false }); + await expectRefused(workerOff.inputUrl(), workerOff.holder, { + code: "INPUT_UNAVAILABLE", + status: 503, + }); }, ); }); From 124226ca89f9f83f5cfdf63730f07934cd9850af Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 26 Oct 2026 19:37:45 +0100 Subject: [PATCH 04/28] feat(input): a standalone worker takes the input socket, and a gateway refuses a worker that takes no input with 503 INPUT_UNAVAILABLE (#1017) --- src/input/create.ts | 2 ++ src/input/gateway-link.ts | 45 +++++++++++++++++++++++++++++++-------- src/input/refusal.ts | 4 ++++ src/input/worker-link.ts | 14 +++++++++++- src/input/worker.ts | 1 + 5 files changed, 56 insertions(+), 10 deletions(-) diff --git a/src/input/create.ts b/src/input/create.ts index e0780755d..d938ec62f 100644 --- a/src/input/create.ts +++ b/src/input/create.ts @@ -23,6 +23,7 @@ export interface WorkerInput { }; readonly leaseRoute: ModuleLeaseHandler; readonly relayTwoWay: ModuleRelayTwoWayTarget; + readonly socket: ModuleLeaseSocketHandler; } const leaseRoute: ModuleLeaseHandler = () => Promise.resolve(upgradeRequired()); @@ -33,6 +34,7 @@ export function createWorkerInput(options: WorkerInputOptions): WorkerInput { capability: { name: options.name, read: () => Promise.resolve({ state: "available" }) }, leaseRoute, relayTwoWay: link.relayTwoWay, + socket: link.socket, }; } diff --git a/src/input/gateway-link.ts b/src/input/gateway-link.ts index 074a7153d..b4e2f3e69 100644 --- a/src/input/gateway-link.ts +++ b/src/input/gateway-link.ts @@ -6,6 +6,7 @@ import type { } from "../modules/index.js"; import type { LeaseSocket } from "../ports/index.js"; import { readFrames } from "./framing.js"; +import { unavailable } from "./refusal.js"; export interface GatewayInputLinkOptions { readonly relayTwoWay: ( @@ -27,21 +28,47 @@ export function createGatewayInputLink(options: GatewayInputLinkOptions): { return { open: async (_request, identity, lease) => { const controller = new AbortController(); - const relay = await options.relayTwoWay( - lease.workerId ?? "", - new Request(`http://simlock-worker/input/leases/${encodeURIComponent(lease.id)}`, { - method: "GET", - signal: controller.signal, - }), - `${options.requesterPrefix}${identity.principal}`, - lease.workerLeaseId, - ); + const workerId = lease.workerId ?? ""; + let relay: Response | ModuleTwoWayRelay; + try { + relay = await options.relayTwoWay( + workerId, + new Request(`http://simlock-worker/input/leases/${encodeURIComponent(lease.id)}`, { + method: "GET", + signal: controller.signal, + }), + `${options.requesterPrefix}${identity.principal}`, + lease.workerLeaseId, + ); + } catch (error) { + return refusalFor(error, workerId, lease.id); + } if (relay instanceof Response) return relay; return (socket) => bridge(socket, relay, controller); }, }; } +// why: the relay fails with the fleet's own codes -- a worker that takes no input (`RELAY_TARGET_MISSING`), +// a worker gone (`WORKER_UNREACHABLE`) -- and a client must get a 503 that says which, before the upgrade; +// an error with no code is a real fault and goes on to the HTTP layer's 500. +function refusalFor(error: unknown, workerId: string, leaseId: string): Response { + const code = (error as { readonly code?: unknown }).code; + if (code === "RELAY_TARGET_MISSING") { + return unavailable( + "INPUT_UNAVAILABLE", + `Worker ${workerId} does not take input for lease ${leaseId}; turn on capabilities.input.enabled there`, + ); + } + if (typeof code === "string" && /^[A-Z][A-Z0-9_]{0,63}$/.test(code)) { + return unavailable( + code, + error instanceof Error ? error.message : `Worker ${workerId} could not be reached`, + ); + } + throw error; +} + function bridge(socket: LeaseSocket, relay: ModuleTwoWayRelay, controller: AbortController): void { socket.onClose(() => controller.abort()); void (async () => { diff --git a/src/input/refusal.ts b/src/input/refusal.ts index ce5a146d8..9f021d081 100644 --- a/src/input/refusal.ts +++ b/src/input/refusal.ts @@ -15,3 +15,7 @@ export function upgradeRequired(): Response { export function unknownLease(leaseId: string): Response { return refusal(404, "UNKNOWN_LEASE", `Unknown lease: ${leaseId}`); } + +export function unavailable(code: string, message: string): Response { + return refusal(503, code, message); +} diff --git a/src/input/worker-link.ts b/src/input/worker-link.ts index 40d42303d..00ece8e25 100644 --- a/src/input/worker-link.ts +++ b/src/input/worker-link.ts @@ -7,7 +7,11 @@ import { type DeviceRecord, type LeaseRecord, } from "../core/index.js"; -import type { ModuleInputs, ModuleRelayTwoWayTarget } from "../modules/index.js"; +import type { + ModuleInputs, + ModuleLeaseSocketHandler, + ModuleRelayTwoWayTarget, +} from "../modules/index.js"; import type { LeaseSocket } from "../ports/index.js"; import { channelSocket } from "./channel.js"; import { unknownLease } from "./refusal.js"; @@ -28,8 +32,16 @@ export interface WorkerInputLinkOptions { // unknown whoever asks, as `lease.get` answers. export function createWorkerInputLink(options: WorkerInputLinkOptions): { readonly relayTwoWay: ModuleRelayTwoWayTarget; + readonly socket: ModuleLeaseSocketHandler; } { return { + socket: { + open: (_request, _identity, held) => { + const lease = options.registry.snapshot.leases.find((entry) => entry.id === held.id); + if (lease === undefined) return Promise.resolve(unknownLease(held.id)); + return Promise.resolve((socket) => void serve(options, lease, socket)); + }, + }, relayTwoWay: (_request, _tenant, leaseId, pipe) => { const lease = options.registry.snapshot.leases.find((entry) => entry.id === leaseId); if (lease === undefined) return Promise.resolve(unknownLease(leaseId ?? "")); diff --git a/src/input/worker.ts b/src/input/worker.ts index 8ce03f993..790b816b5 100644 --- a/src/input/worker.ts +++ b/src/input/worker.ts @@ -13,5 +13,6 @@ export function createWorker(ctx: WorkerContext): WorkerModul capability: input.capability, leaseRoute: input.leaseRoute, relayTwoWay: input.relayTwoWay, + socket: input.socket, }; } From ada88d41b3faaceb415141f49f6c96ba0842a321 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 26 Oct 2026 19:39:04 +0100 Subject: [PATCH 05/28] test: a client following T3 Code's order drives a faked device through a gateway (#1017) --- src/core/fake-device-input.ts | 57 +++++++++++++++++++++ src/core/index.ts | 2 + src/core/input.ts | 50 ++++++++++++++++++ src/core/testing.ts | 1 + src/daemon/input-lease.e2e.test.ts | 81 ++++++++++++++++++++++++++++-- 5 files changed, 186 insertions(+), 5 deletions(-) create mode 100644 src/core/fake-device-input.ts diff --git a/src/core/fake-device-input.ts b/src/core/fake-device-input.ts new file mode 100644 index 000000000..71d64829d --- /dev/null +++ b/src/core/fake-device-input.ts @@ -0,0 +1,57 @@ +import type { + DeviceInput, + DeviceInputFactory, + InputMessage, + InputScreenConfig, + UnavailableDeviceInput, +} from "./input.js"; + +// why: a device input for tests: it records what reached it and plays the device's changes. +export class FakeDeviceInput implements DeviceInput { + readonly sent: InputMessage[] = []; + closes = 0; + readonly #configListeners = new Set<(config: InputScreenConfig) => void>(); + #config: InputScreenConfig; + + constructor(config: InputScreenConfig) { + this.#config = config; + } + + send(message: InputMessage): void { + this.sent.push(message); + } + + onConfig(listener: (config: InputScreenConfig) => void): () => void { + this.#configListeners.add(listener); + listener(this.#config); + return () => this.#configListeners.delete(listener); + } + + onFailed(): () => void { + return () => undefined; + } + + close(): Promise { + this.closes += 1; + return Promise.resolve(); + } + + changeConfig(config: InputScreenConfig): void { + this.#config = config; + for (const listener of this.#configListeners) listener(config); + } +} + +// why: a driver's `input` member for tests: `opened` holds every device input it handed out, so a test +// can tell one shared device input from one per socket. +export class FakeInputFactory implements DeviceInputFactory { + readonly opened: FakeDeviceInput[] = []; + + constructor(private readonly config: InputScreenConfig) {} + + open(): Promise { + const input = new FakeDeviceInput(this.config); + this.opened.push(input); + return Promise.resolve(input); + } +} diff --git a/src/core/index.ts b/src/core/index.ts index 34b406093..eb1cb5d6b 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -108,6 +108,8 @@ export { encodeInputMessage, INPUT_TAG, isUnavailableInput, + parseInputMessage, + type InputMessage, type InputOrientation, type InputScreenConfig, type UnavailableDeviceInput, diff --git a/src/core/input.ts b/src/core/input.ts index d3dd6da1b..02d9aeaf4 100644 --- a/src/core/input.ts +++ b/src/core/input.ts @@ -3,6 +3,9 @@ import type { PhysicalDriverDevice, VirtualDriverDevice } from "./driver.js"; // why: the one table in TypeScript for the input socket's tags, in the messages expo's serve-sim speaks // (ADR 0049 section 3): a client that already speaks them drives a lease with no Simlock-specific code. export const INPUT_TAG = { + touch: 0x03, + button: 0x04, + key: 0x06, config: 0x82, admitted: 0x83, } as const; @@ -31,8 +34,25 @@ export const UNAVAILABLE_INPUT_CONFIG: InputScreenConfig = { width: 1, }; +// why: what a socket's message means once it is read and checked: positions are fractions of the +// screen (0 to 1), as serve-sim sends them, and the device input turns them into its own units. +export type InputMessage = + | { + readonly kind: "touch"; + readonly type: "begin" | "move" | "end"; + readonly x: number; + readonly y: number; + } + | { readonly kind: "button"; readonly button: string } + | { + readonly kind: "key"; + readonly type: "down" | "up"; + readonly usage: number; + }; + // why: what turns messages into taps on one device; one per lease, shared by every socket of it. export interface DeviceInput { + send(message: InputMessage): void; // why: calls the listener with the current screen config at once, then on each change, so a socket // that joins late learns the size as it is now. onConfig(listener: (config: InputScreenConfig) => void): () => void; @@ -67,3 +87,33 @@ export function encodeInputMessage(tag: number, body?: unknown): Uint8Array { message.set(json, 1); return message; } + +// why: undefined for a message that is not one the device input takes, so the caller drops it and keeps the socket. +export function parseInputMessage(bytes: Uint8Array): InputMessage | undefined { + let body: unknown; + try { + body = JSON.parse(new TextDecoder().decode(bytes.subarray(1))); + } catch { + return undefined; + } + const fields = body as Record; + switch (bytes[0]) { + case INPUT_TAG.touch: + return { + kind: "touch", + type: fields["type"] as "begin" | "move" | "end", + x: fields["x"] as number, + y: fields["y"] as number, + }; + case INPUT_TAG.button: + return { button: fields["button"] as string, kind: "button" }; + case INPUT_TAG.key: + return { + kind: "key", + type: fields["type"] as "down" | "up", + usage: fields["usage"] as number, + }; + default: + return undefined; + } +} diff --git a/src/core/testing.ts b/src/core/testing.ts index 3e7aec9a8..7e31de1da 100644 --- a/src/core/testing.ts +++ b/src/core/testing.ts @@ -32,6 +32,7 @@ export { type ResourceStrategyOptions, } from "./capacity/index.js"; export { readyTransitionUpdate } from "./driver.js"; +export { FakeDeviceInput, FakeInputFactory } from "./fake-device-input.js"; export { FakeDriver, fakeDeviceDataTable } from "./fake-driver.js"; export type { FakeDriverOptions } from "./fake-driver.js"; export { QuarantineCoordinator } from "./quarantine-coordinator.js"; diff --git a/src/daemon/input-lease.e2e.test.ts b/src/daemon/input-lease.e2e.test.ts index 881422f36..921443a92 100644 --- a/src/daemon/input-lease.e2e.test.ts +++ b/src/daemon/input-lease.e2e.test.ts @@ -6,7 +6,7 @@ import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { WebSocket } from "ws"; -import { FakeDriver, fakeDeviceDataTables } from "../core/testing.js"; +import { FakeDriver, FakeInputFactory, fakeDeviceDataTables } from "../core/testing.js"; import { MemoryFilesystem, NoopLogger, SystemClock } from "../ports/index.js"; import type { DispatchSession } from "./dispatch.js"; import { startDaemon } from "./main.js"; @@ -42,12 +42,25 @@ const UNAVAILABLE_CONFIG = { width: 1, }; +function message(tag: number, body: unknown): Buffer { + return Buffer.concat([Buffer.from([tag]), Buffer.from(JSON.stringify(body))]); +} + +function tags(opened: OpenedSocket): number[] { + return opened.messages.map((entry) => entry[0] ?? -1); +} + +function config(opened: OpenedSocket, index: number): unknown { + return JSON.parse(opened.messages[index]?.subarray(1).toString("utf8") ?? ""); +} + interface OpenedSocket { // why: 101 for an upgrade that was accepted, else the status the refusal answered. readonly status: number; // why: the error code of a refusal's JSON body, when it has one. readonly code: string | undefined; readonly messages: Buffer[]; + readonly socket: WebSocket; } async function openInput(url: string, token: string | undefined): Promise { @@ -78,7 +91,7 @@ async function openInput(url: string, token: string | undefined): Promise { return path; } - function driver(): FakeDriver { + function driver(input?: FakeInputFactory): FakeDriver { return new FakeDriver({ + ...(input === undefined ? {} : { input }), availableOsVersions: ["18.0"], clock: new SystemClock(), knownModels: ["Pixel-A"], @@ -129,7 +143,11 @@ describe("a lease's input socket", () => { return (granted as { lease: { id: string } }).lease.id; } - async function fleet(options: { readonly gatewayInput: boolean; readonly workerInput: boolean }) { + async function fleet(options: { + readonly gatewayInput: boolean; + readonly workerInput: boolean; + readonly input?: FakeInputFactory; + }) { const port = await freeLoopbackPort(); const gateway = await startDaemon({ configOverrides: { @@ -152,7 +170,7 @@ describe("a lease's input socket", () => { }, dataDirectory: await directory("worker"), deviceDataTables: fakeDeviceDataTables, - drivers: [driver()], + drivers: [driver(options.input)], filesystem: new MemoryFilesystem(), logger: new NoopLogger(), version: "1.0.0-e2e", @@ -264,4 +282,57 @@ describe("a lease's input socket", () => { }); }, ); + + it( + "drives a faked device through a gateway in T3 Code's order", + { timeout: 60_000 }, + async () => { + const input = new FakeInputFactory({ height: 2556, orientation: "portrait", width: 1179 }); + const { holder, inputUrl } = await fleet({ gatewayInput: true, workerInput: true, input }); + const first = await openInput(inputUrl(), holder); + await vi.waitFor(() => expect(tags(first)).toEqual([0x83, 0x82]), { timeout: 10_000 }); + expect(config(first, 1)).toEqual({ height: 2556, orientation: "portrait", width: 1179 }); + + const touches = [ + { type: "begin", x: 0.4, y: 0.7 }, + { type: "move", x: 0.5, y: 0.6 }, + { type: "end", x: 0.5, y: 0.6 }, + ] as const; + for (const touch of touches) first.socket.send(message(0x03, touch)); + first.socket.send(message(0x06, { type: "down", usage: 4 })); + first.socket.send(message(0x04, { button: "home" })); + await vi.waitFor(() => expect(input.opened[0]?.sent).toHaveLength(5), { timeout: 10_000 }); + expect(input.opened[0]?.sent).toEqual([ + ...touches.map((touch) => ({ kind: "touch", ...touch })), + { kind: "key", type: "down", usage: 4 }, + { button: "home", kind: "button" }, + ]); + + const second = await openInput(inputUrl(), holder); + await vi.waitFor(() => expect(tags(second)).toEqual([0x83, 0x82]), { timeout: 10_000 }); + second.socket.send(message(0x04, { button: "back" })); + await vi.waitFor(() => expect(input.opened[0]?.sent).toHaveLength(6), { timeout: 10_000 }); + expect(input.opened).toHaveLength(1); + + input.opened[0]?.changeConfig({ height: 1179, orientation: "landscape_left", width: 2556 }); + for (const socket of [first, second]) { + await vi.waitFor(() => expect(tags(socket).at(-1)).toBe(0x82), { timeout: 10_000 }); + await vi.waitFor( + () => + expect(config(socket, socket.messages.length - 1)).toEqual({ + height: 1179, + orientation: "landscape_left", + width: 2556, + }), + { timeout: 10_000 }, + ); + } + + first.socket.close(); + await vi.waitFor(() => expect(second.socket.readyState).toBe(WebSocket.OPEN)); + expect(input.opened[0]?.closes).toBe(0); + second.socket.close(); + await vi.waitFor(() => expect(input.opened[0]?.closes).toBe(1), { timeout: 10_000 }); + }, + ); }); From 6d8123d890438d3cf7cd2ff1732a688c35c875eb Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 26 Oct 2026 19:39:36 +0100 Subject: [PATCH 06/28] feat(input): sockets of a lease share one device input, and their messages and the screen config cross the gateway (#1017) --- src/input/gateway-link.ts | 8 +++- src/input/sessions.ts | 89 +++++++++++++++++++++++++++++++++++++++ src/input/worker-link.ts | 36 +++++----------- 3 files changed, 106 insertions(+), 27 deletions(-) create mode 100644 src/input/sessions.ts diff --git a/src/input/gateway-link.ts b/src/input/gateway-link.ts index b4e2f3e69..984c1e15f 100644 --- a/src/input/gateway-link.ts +++ b/src/input/gateway-link.ts @@ -5,7 +5,7 @@ import type { ModuleTwoWayRelay, } from "../modules/index.js"; import type { LeaseSocket } from "../ports/index.js"; -import { readFrames } from "./framing.js"; +import { encodeFrame, readFrames } from "./framing.js"; import { unavailable } from "./refusal.js"; export interface GatewayInputLinkOptions { @@ -70,6 +70,12 @@ function refusalFor(error: unknown, workerId: string, leaseId: string): Response } function bridge(socket: LeaseSocket, relay: ModuleTwoWayRelay, controller: AbortController): void { + const writer = relay.writable.getWriter(); + let writes: Promise = Promise.resolve(); + socket.onMessage((data) => { + const frame = encodeFrame(data[0] ?? 0, data.subarray(1)); + writes = writes.then(() => writer.write(frame)).catch(() => undefined); + }); socket.onClose(() => controller.abort()); void (async () => { for await (const frame of readFrames(relay.readable)) { diff --git a/src/input/sessions.ts b/src/input/sessions.ts new file mode 100644 index 000000000..4d20b9512 --- /dev/null +++ b/src/input/sessions.ts @@ -0,0 +1,89 @@ +import { + encodeInputMessage, + INPUT_TAG, + isUnavailableInput, + parseInputMessage, + toDriverDevice, + UNAVAILABLE_INPUT_CONFIG, + type DeviceInput, + type DeviceRecord, + type InputScreenConfig, + type UnavailableDeviceInput, +} from "../core/index.js"; +import type { ModuleInputs } from "../modules/index.js"; +import type { LeaseSocket } from "../ports/index.js"; + +interface Attached { + readonly socket: LeaseSocket; + admitted: boolean; +} + +// why: one device input per lease, shared by every socket of it, so two people on one lease do not +// start two controllers on one device. +interface LeaseInput { + readonly opening: Promise; + readonly sockets: Set; + config: InputScreenConfig | undefined; +} + +export interface InputSessions { + attach(leaseId: string, device: DeviceRecord, socket: LeaseSocket): void; +} + +export function createInputSessions(options: { readonly inputs: ModuleInputs }): InputSessions { + const leases = new Map(); + + function openFor(leaseId: string, device: DeviceRecord): LeaseInput { + const existing = leases.get(leaseId); + if (existing !== undefined) return existing; + const sockets = new Set(); + const entry: LeaseInput = { + config: undefined, + opening: options.inputs + .openInput(device.spec.platform, toDriverDevice(device), new AbortController().signal) + .then((opened) => { + if (!isUnavailableInput(opened)) { + opened.onConfig((config) => { + entry.config = config; + for (const attached of sockets) { + if (attached.admitted) + attached.socket.send(encodeInputMessage(INPUT_TAG.config, config)); + } + }); + } + return opened; + }), + sockets, + }; + leases.set(leaseId, entry); + return entry; + } + + async function attach(leaseId: string, device: DeviceRecord, socket: LeaseSocket): Promise { + const entry = openFor(leaseId, device); + const attached: Attached = { admitted: false, socket }; + entry.sockets.add(attached); + socket.onClose(() => { + entry.sockets.delete(attached); + if (entry.sockets.size > 0) return; + leases.delete(leaseId); + void entry.opening.then((opened) => + isUnavailableInput(opened) ? undefined : opened.close(), + ); + }); + const opened = await entry.opening; + socket.send(encodeInputMessage(INPUT_TAG.admitted)); + if (isUnavailableInput(opened)) { + socket.send(encodeInputMessage(INPUT_TAG.config, UNAVAILABLE_INPUT_CONFIG)); + return; + } + attached.admitted = true; + if (entry.config !== undefined) socket.send(encodeInputMessage(INPUT_TAG.config, entry.config)); + socket.onMessage((data) => { + const message = parseInputMessage(data); + if (message !== undefined) opened.send(message); + }); + } + + return { attach: (leaseId, device, socket) => void attach(leaseId, device, socket) }; +} diff --git a/src/input/worker-link.ts b/src/input/worker-link.ts index 00ece8e25..520cad48b 100644 --- a/src/input/worker-link.ts +++ b/src/input/worker-link.ts @@ -1,12 +1,4 @@ -import { - encodeInputMessage, - INPUT_TAG, - isUnavailableInput, - toDriverDevice, - UNAVAILABLE_INPUT_CONFIG, - type DeviceRecord, - type LeaseRecord, -} from "../core/index.js"; +import type { DeviceRecord, LeaseRecord } from "../core/index.js"; import type { ModuleInputs, ModuleLeaseSocketHandler, @@ -15,6 +7,7 @@ import type { import type { LeaseSocket } from "../ports/index.js"; import { channelSocket } from "./channel.js"; import { unknownLease } from "./refusal.js"; +import { createInputSessions, type InputSessions } from "./sessions.js"; export interface InputRegistry { readonly snapshot: { @@ -30,43 +23,34 @@ export interface WorkerInputLinkOptions { // why: the worker's own record is the truth for a lease a gateway names; a lease gone from it is // unknown whoever asks, as `lease.get` answers. -export function createWorkerInputLink(options: WorkerInputLinkOptions): { +export function createWorkerInputLink(base: WorkerInputLinkOptions): { readonly relayTwoWay: ModuleRelayTwoWayTarget; readonly socket: ModuleLeaseSocketHandler; } { + const options = { ...base, sessions: createInputSessions({ inputs: base.inputs }) }; return { socket: { open: (_request, _identity, held) => { const lease = options.registry.snapshot.leases.find((entry) => entry.id === held.id); if (lease === undefined) return Promise.resolve(unknownLease(held.id)); - return Promise.resolve((socket) => void serve(options, lease, socket)); + return Promise.resolve((socket) => serve(options, lease, socket)); }, }, relayTwoWay: (_request, _tenant, leaseId, pipe) => { const lease = options.registry.snapshot.leases.find((entry) => entry.id === leaseId); if (lease === undefined) return Promise.resolve(unknownLease(leaseId ?? "")); - void serve(options, lease, channelSocket(pipe)); + serve(options, lease, channelSocket(pipe)); return Promise.resolve(new Response(null, { status: 200 })); }, }; } -async function serve( - options: WorkerInputLinkOptions, +function serve( + options: WorkerInputLinkOptions & { readonly sessions: InputSessions }, lease: LeaseRecord, socket: LeaseSocket, -): Promise { +): void { const device = options.registry.snapshot.devices.find((entry) => entry.id === lease.deviceId); if (device === undefined) return; - const opening = await options.inputs.openInput( - device.spec.platform, - toDriverDevice(device), - new AbortController().signal, - ); - if (!isUnavailableInput(opening)) { - await opening.close(); - return; - } - socket.send(encodeInputMessage(INPUT_TAG.admitted)); - socket.send(encodeInputMessage(INPUT_TAG.config, UNAVAILABLE_INPUT_CONFIG)); + options.sessions.attach(lease.id, device, socket); } From 394f97a10521afbdd0d076ae36de2daea26ed910 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 26 Oct 2026 19:40:23 +0100 Subject: [PATCH 07/28] test: a client sending a bad message keeps its socket and nothing reaches the device (#1017) --- src/input/worker.test.ts | 102 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 src/input/worker.test.ts diff --git a/src/input/worker.test.ts b/src/input/worker.test.ts new file mode 100644 index 000000000..73bde318c --- /dev/null +++ b/src/input/worker.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { DeviceRecord, LeaseRecord } from "../core/index.js"; +import { FakeInputFactory } from "../core/testing.js"; +import type { WorkerModule } from "../modules/index.js"; +import { fakeWorkerContext } from "../modules/testing.js"; +import { MemoryLeaseSocket } from "../ports/index.js"; +import { inputModule } from "./index.js"; + +const IDENTITY = { principal: "tok_holder", role: "agent" } as const; +const SCREEN = { height: 2556, orientation: "portrait", width: 1179 } as const; + +function message(tag: number, body: unknown): Uint8Array { + return Uint8Array.from([tag, ...new TextEncoder().encode(JSON.stringify(body))]); +} + +async function setup(): Promise<{ + readonly worker: WorkerModule; + readonly factory: FakeInputFactory; +}> { + const { ctx } = await fakeWorkerContext("input", {}); + const factory = new FakeInputFactory(SCREEN); + const registry = { + snapshot: { + devices: [{ id: "dev_1", spec: { platform: "android" } } as unknown as DeviceRecord], + leases: [{ deviceId: "dev_1", id: "L-1" } as unknown as LeaseRecord], + }, + }; + const loaded = await inputModule.loadWorker(); + const worker = loaded.createWorker({ + ...ctx, + inputs: { openInput: () => factory.open() }, + registry, + }); + return { factory, worker }; +} + +async function admitted(worker: WorkerModule): Promise { + const opened = await worker.socket?.open(new Request("http://simlock/input"), IDENTITY, { + deviceId: "dev_1", + id: "L-1", + }); + if (typeof opened !== "function") throw new Error("the socket was refused"); + const socket = new MemoryLeaseSocket(); + opened(socket); + await vi.waitFor(() => expect(socket.sent.map((sent) => sent[0])).toEqual([0x83, 0x82])); + return socket; +} + +describe("a worker's input socket", () => { + it("keeps the socket and sends nothing to the device when a client sends a bad message", async () => { + const { factory, worker } = await setup(); + const socket = await admitted(worker); + const device = factory.opened[0]; + + const valid = [ + message(0x03, { type: "begin", x: 0, y: 1 }), + message(0x04, { button: "back" }), + message(0x06, { type: "down", usage: 4 }), + message(0x0b, { dx: 0, dy: -0.25, x: 0.5, y: 0.5 }), + ]; + for (const accepted of valid) socket.receive(accepted); + expect(device?.sent).toEqual([ + { kind: "touch", type: "begin", x: 0, y: 1 }, + { button: "back", kind: "button" }, + { kind: "key", type: "down", usage: 4 }, + { dx: 0, dy: -0.25, kind: "scroll", x: 0.5, y: 0.5 }, + ]); + + const dropped = [ + Uint8Array.from([0x03, ...new TextEncoder().encode("{not json")]), + Uint8Array.from([0x55, ...new TextEncoder().encode("{}")]), + message(0x07, { orientation: "landscape_left" }), + message(0x03, { type: "begin", x: 1.5, y: 0.5 }), + message(0x03, { type: "begin", x: 0.5, y: -0.1 }), + message(0x03, { type: "begin", x: "0.5", y: 0.5 }), + message(0x03, { type: "wiggle", x: 0.5, y: 0.5 }), + message(0x03, null), + message(0x04, { button: "volume_up" }), + message(0x06, { type: "down", usage: 300 }), + Uint8Array.from([ + 0x03, + ...new TextEncoder().encode(`{"x":0.5,"pad":"${"a".repeat(65_536)}"}`), + ]), + ]; + for (const refused of dropped) socket.receive(refused); + expect(device?.sent).toHaveLength(valid.length); + expect(socket.closed).toBeUndefined(); + + const sentBefore = socket.sent.length; + socket.receive(message(0x10, { command: "set-pose", requestId: 7 })); + expect(socket.sent).toHaveLength(sentBefore + 1); + const answer = socket.sent.at(-1); + expect(answer?.[0]).toBe(0x90); + expect(JSON.parse(new TextDecoder().decode(answer?.subarray(1)))).toEqual({ + error: "This device cannot change its pose", + ok: false, + requestId: 7, + }); + expect(device?.sent).toHaveLength(valid.length); + }); +}); From 834fb48cffabad7e3b35e07d53dcf9725ae81339 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 26 Oct 2026 19:41:28 +0100 Subject: [PATCH 08/28] feat(input): a bad client message is dropped and a hinge control request answered {ok:false}, and the socket stays open (#1017) --- src/core/index.ts | 4 +- src/core/input.ts | 113 ++++++++++++++++++++++++----- src/daemon/input-lease.e2e.test.ts | 7 +- src/input/framing.ts | 4 +- src/input/gateway-link.ts | 3 + src/input/sessions.ts | 17 ++++- 6 files changed, 123 insertions(+), 25 deletions(-) diff --git a/src/core/index.ts b/src/core/index.ts index eb1cb5d6b..225b4138f 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -107,8 +107,10 @@ export { type DeviceInputFactory, encodeInputMessage, INPUT_TAG, + MAX_INPUT_MESSAGE_BYTES, isUnavailableInput, - parseInputMessage, + parseClientMessage, + type InputControlRequest, type InputMessage, type InputOrientation, type InputScreenConfig, diff --git a/src/core/input.ts b/src/core/input.ts index 02d9aeaf4..ac4e986e6 100644 --- a/src/core/input.ts +++ b/src/core/input.ts @@ -6,10 +6,18 @@ export const INPUT_TAG = { touch: 0x03, button: 0x04, key: 0x06, + scroll: 0x0b, + control: 0x10, config: 0x82, admitted: 0x83, + controlResult: 0x90, } as const; +// why: a client message is at most this many bytes with its tag; a longer one is dropped and the socket stays open. +export const MAX_INPUT_MESSAGE_BYTES = 64 * 1024; + +export const INPUT_BUTTONS = ["home", "app_switcher", "lock", "back"] as const; + export type InputOrientation = | "portrait" | "portrait_upside_down" @@ -43,7 +51,14 @@ export type InputMessage = readonly x: number; readonly y: number; } - | { readonly kind: "button"; readonly button: string } + | { readonly kind: "button"; readonly button: (typeof INPUT_BUTTONS)[number] } + | { + readonly kind: "scroll"; + readonly dx: number; + readonly dy: number; + readonly x?: number; + readonly y?: number; + } | { readonly kind: "key"; readonly type: "down" | "up"; @@ -88,31 +103,93 @@ export function encodeInputMessage(tag: number, body?: unknown): Uint8Array { return message; } -// why: undefined for a message that is not one the device input takes, so the caller drops it and keeps the socket. -export function parseInputMessage(bytes: Uint8Array): InputMessage | undefined { - let body: unknown; +// why: a hinge control request (0x10) is the one message a client sends that wants an answer; the answer is `{ok:false}` on every platform here. +export interface InputControlRequest { + readonly kind: "control"; + readonly requestId: number | undefined; +} + +type Fields = Readonly>; + +function fieldsOf(bytes: Uint8Array): Fields | undefined { try { - body = JSON.parse(new TextDecoder().decode(bytes.subarray(1))); + const body: unknown = JSON.parse(new TextDecoder().decode(bytes.subarray(1))); + return typeof body === "object" && body !== null && !Array.isArray(body) + ? (body as Fields) + : undefined; } catch { return undefined; } - const fields = body as Record; +} + +function fraction(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1; +} + +function delta(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value >= -1 && value <= 1; +} + +function oneOf(value: unknown, allowed: readonly T[]): value is T { + return allowed.includes(value as T); +} + +function touchOf(fields: Fields): InputMessage | undefined { + const { type, x, y } = fields; + if (!oneOf(type, ["begin", "move", "end"]) || !fraction(x) || !fraction(y)) return undefined; + return { kind: "touch", type, x, y }; +} + +function keyOf(fields: Fields): InputMessage | undefined { + const { type, usage } = fields; + if (!oneOf(type, ["down", "up"])) return undefined; + if (typeof usage !== "number" || !Number.isInteger(usage) || usage < 0 || usage > 0xff) { + return undefined; + } + return { kind: "key", type, usage }; +} + +function scrollOf(fields: Fields): InputMessage | undefined { + const { dx, dy, x, y } = fields; + if (!delta(dx) || !delta(dy)) return undefined; + if ((x !== undefined && !fraction(x)) || (y !== undefined && !fraction(y))) return undefined; + return { + dx, + dy, + kind: "scroll", + ...(x === undefined ? {} : { x }), + ...(y === undefined ? {} : { y }), + }; +} + +// why: undefined for a message that is not one the device input takes (bad JSON, an unknown tag, a position outside the screen, an orientation request), so the caller drops it and keeps the socket. +export function parseClientMessage( + bytes: Uint8Array, +): InputMessage | InputControlRequest | undefined { + if (bytes.length > MAX_INPUT_MESSAGE_BYTES) return undefined; + const fields = fieldsOf(bytes); + if (bytes[0] === INPUT_TAG.control) { + const requestId = fields?.["requestId"]; + return { + kind: "control", + requestId: + Number.isSafeInteger(requestId) && (requestId as number) > 0 + ? (requestId as number) + : undefined, + }; + } + if (fields === undefined) return undefined; switch (bytes[0]) { case INPUT_TAG.touch: - return { - kind: "touch", - type: fields["type"] as "begin" | "move" | "end", - x: fields["x"] as number, - y: fields["y"] as number, - }; + return touchOf(fields); case INPUT_TAG.button: - return { button: fields["button"] as string, kind: "button" }; + return oneOf(fields["button"], INPUT_BUTTONS) + ? { button: fields["button"], kind: "button" } + : undefined; case INPUT_TAG.key: - return { - kind: "key", - type: fields["type"] as "down" | "up", - usage: fields["usage"] as number, - }; + return keyOf(fields); + case INPUT_TAG.scroll: + return scrollOf(fields); default: return undefined; } diff --git a/src/daemon/input-lease.e2e.test.ts b/src/daemon/input-lease.e2e.test.ts index 921443a92..9e1993259 100644 --- a/src/daemon/input-lease.e2e.test.ts +++ b/src/daemon/input-lease.e2e.test.ts @@ -284,7 +284,7 @@ describe("a lease's input socket", () => { ); it( - "drives a faked device through a gateway in T3 Code's order", + "drives a faked device through a gateway in T3 Code's order, and drops a message over 64 KiB", { timeout: 60_000 }, async () => { const input = new FakeInputFactory({ height: 2556, orientation: "portrait", width: 1179 }); @@ -314,6 +314,11 @@ describe("a lease's input socket", () => { await vi.waitFor(() => expect(input.opened[0]?.sent).toHaveLength(6), { timeout: 10_000 }); expect(input.opened).toHaveLength(1); + second.socket.send(message(0x03, { pad: "a".repeat(70_000), type: "end", x: 0.5, y: 0.5 })); + second.socket.send(message(0x04, { button: "lock" })); + await vi.waitFor(() => expect(input.opened[0]?.sent).toHaveLength(7), { timeout: 10_000 }); + expect(input.opened[0]?.sent.at(-1)).toEqual({ button: "lock", kind: "button" }); + input.opened[0]?.changeConfig({ height: 1179, orientation: "landscape_left", width: 2556 }); for (const socket of [first, second]) { await vi.waitFor(() => expect(tags(socket).at(-1)).toBe(0x82), { timeout: 10_000 }); diff --git a/src/input/framing.ts b/src/input/framing.ts index 144838915..0136867b5 100644 --- a/src/input/framing.ts +++ b/src/input/framing.ts @@ -1,6 +1,6 @@ // why: the channel framing of a two-way relay (ADR 0049 section 5): `[len u32 big-endian][tag u8][payload]`, // `len` counting the tag and the payload. Only the input module encodes and reads it; the relay moves bytes. -export const MAX_MESSAGE_BYTES = 64 * 1024; +import { MAX_INPUT_MESSAGE_BYTES } from "../core/index.js"; export interface InputFrame { readonly tag: number; @@ -33,7 +33,7 @@ export async function* readFrames( for (;;) { while (buffer.length >= 4) { const length = new DataView(buffer.buffer, buffer.byteOffset, buffer.length).getUint32(0); - if (length < 1 || length > MAX_MESSAGE_BYTES + 1) return; + if (length < 1 || length > MAX_INPUT_MESSAGE_BYTES + 1) return; if (buffer.length < 4 + length) break; yield { payload: buffer.slice(5, 4 + length), tag: buffer[4] ?? 0 }; buffer = buffer.subarray(4 + length); diff --git a/src/input/gateway-link.ts b/src/input/gateway-link.ts index 984c1e15f..4cb609348 100644 --- a/src/input/gateway-link.ts +++ b/src/input/gateway-link.ts @@ -1,3 +1,4 @@ +import { MAX_INPUT_MESSAGE_BYTES } from "../core/index.js"; import type { ModuleIdentity, ModuleLease, @@ -73,6 +74,8 @@ function bridge(socket: LeaseSocket, relay: ModuleTwoWayRelay, controller: Abort const writer = relay.writable.getWriter(); let writes: Promise = Promise.resolve(); socket.onMessage((data) => { + // why: a frame over the limit would end the channel for everyone on the lease, and a message that long is dropped on every door. + if (data.length > MAX_INPUT_MESSAGE_BYTES) return; const frame = encodeFrame(data[0] ?? 0, data.subarray(1)); writes = writes.then(() => writer.write(frame)).catch(() => undefined); }); diff --git a/src/input/sessions.ts b/src/input/sessions.ts index 4d20b9512..c7367474b 100644 --- a/src/input/sessions.ts +++ b/src/input/sessions.ts @@ -2,7 +2,7 @@ import { encodeInputMessage, INPUT_TAG, isUnavailableInput, - parseInputMessage, + parseClientMessage, toDriverDevice, UNAVAILABLE_INPUT_CONFIG, type DeviceInput, @@ -80,8 +80,19 @@ export function createInputSessions(options: { readonly inputs: ModuleInputs }): attached.admitted = true; if (entry.config !== undefined) socket.send(encodeInputMessage(INPUT_TAG.config, entry.config)); socket.onMessage((data) => { - const message = parseInputMessage(data); - if (message !== undefined) opened.send(message); + const message = parseClientMessage(data); + if (message === undefined) return; + if (message.kind === "control") { + socket.send( + encodeInputMessage(INPUT_TAG.controlResult, { + error: "This device cannot change its pose", + ok: false, + ...(message.requestId === undefined ? {} : { requestId: message.requestId }), + }), + ); + return; + } + opened.send(message); }); } From 3452fdccadd1269555ce8c0516232b7f11dba910 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 26 Oct 2026 19:43:42 +0100 Subject: [PATCH 09/28] test: a worker that gives no head in 10 s answers the gateway's input socket 503 WORKER_UNREACHABLE (#1017) --- src/input/gateway.test.ts | 53 +++++++++++++++++++++++++++++++++++++++ src/modules/testing.ts | 9 ++++++- 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 src/input/gateway.test.ts diff --git a/src/input/gateway.test.ts b/src/input/gateway.test.ts new file mode 100644 index 000000000..94ff8a0ad --- /dev/null +++ b/src/input/gateway.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { GatewayModule, ModuleLease } from "../modules/index.js"; +import { fakeGatewayContext } from "../modules/testing.js"; +import { inputModule } from "./index.js"; + +const IDENTITY = { principal: "tok_holder", role: "agent" } as const; +const LEASE: ModuleLease = { + deviceId: "dev_1", + id: "L-42", + workerId: "w1", + workerLeaseId: "L-7", +}; + +// why: a relay that answers nothing until the gateway gives up on it, as a worker that never sends a head does. +function silentRelay(request: Request): Promise { + return new Promise((_resolve, reject) => { + request.signal.addEventListener("abort", () => reject(request.signal.reason), { once: true }); + }); +} + +async function gatewayWith(facts: Parameters[2]) { + const fake = await fakeGatewayContext("input", {}, facts); + const loaded = await inputModule.loadGateway?.(); + const gateway: GatewayModule | undefined = loaded?.createGateway(fake.ctx); + if (gateway?.socket === undefined) throw new Error("the input module has no gateway socket"); + return { clock: fake.clock, socket: gateway.socket }; +} + +describe("a gateway's input socket", () => { + it("answers 503 WORKER_UNREACHABLE when the worker gives no head in 10 s", async () => { + const { clock, socket } = await gatewayWith({ relayTwoWay: silentRelay }); + let outcome: unknown; + void socket.open(new Request("http://simlock/input"), IDENTITY, LEASE).then((answer) => { + outcome = answer; + }); + + clock.advance(9_999); + await Promise.resolve(); + expect(outcome).toBeUndefined(); + + clock.advance(1); + await vi.waitFor(() => expect(outcome).toBeInstanceOf(Response)); + const refusal = outcome as Response; + expect(refusal.status).toBe(503); + expect(await refusal.json()).toEqual({ + error: { + code: "WORKER_UNREACHABLE", + message: "Worker w1 did not answer the input socket within 10 s", + }, + }); + }); +}); diff --git a/src/modules/testing.ts b/src/modules/testing.ts index 938ecc9de..67c5cd217 100644 --- a/src/modules/testing.ts +++ b/src/modules/testing.ts @@ -17,6 +17,7 @@ import type { ModuleFinding, ModuleTokenRecord, ModuleTokens, + ModuleTwoWayRelay, OptionalModuleDefinition, WorkerContext, WorkerHooks, @@ -213,6 +214,10 @@ export async function fakeGatewayContext( facts: { readonly serving?: readonly WorkerView[]; readonly relay?: (request: Request) => Response; + readonly relayTwoWay?: ( + request: Request, + workerId: string, + ) => Promise; } = {}, ): Promise> { const clock = new FakeClock(1_000); @@ -243,7 +248,9 @@ export async function fakeGatewayContext( relayed.push({ tenant, url: request.url, workerId }); return Promise.resolve(facts.relay?.(request) ?? new Response("{}")); }, - relayTwoWay: () => Promise.resolve(new Response(null, { status: 501 })), + relayTwoWay: (workerId, request) => + facts.relayTwoWay?.(request, workerId) ?? + Promise.resolve(new Response(null, { status: 501 })), requesterPrefix: "gw:gw-test:", serving: () => serving, startedAt: clock.now(), From 28adc989aaa2038813108797fb34a4b4bcbcd6ff Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 26 Oct 2026 19:43:58 +0100 Subject: [PATCH 10/28] feat(input): the gateway gives a worker 10 s to answer an input socket (#1017) --- src/input/gateway-link.ts | 20 +++++++++++++++++++- src/input/gateway.ts | 1 + 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/input/gateway-link.ts b/src/input/gateway-link.ts index 4cb609348..b2a107259 100644 --- a/src/input/gateway-link.ts +++ b/src/input/gateway-link.ts @@ -5,7 +5,7 @@ import type { ModuleLeaseSocketAccept, ModuleTwoWayRelay, } from "../modules/index.js"; -import type { LeaseSocket } from "../ports/index.js"; +import type { Clock, LeaseSocket } from "../ports/index.js"; import { encodeFrame, readFrames } from "./framing.js"; import { unavailable } from "./refusal.js"; @@ -17,8 +17,13 @@ export interface GatewayInputLinkOptions { leaseId?: string, ) => Promise; readonly requesterPrefix: string; + readonly clock: Clock; } +// why: the relay's own head deadline is 330 s, far too long for a person waiting on a socket; the +// socket's open is bounded here and aborts the relay's open when it runs out. +const OPEN_BOUND_MS = 10_000; + export function createGatewayInputLink(options: GatewayInputLinkOptions): { open( request: Request, @@ -31,6 +36,11 @@ export function createGatewayInputLink(options: GatewayInputLinkOptions): { const controller = new AbortController(); const workerId = lease.workerId ?? ""; let relay: Response | ModuleTwoWayRelay; + let timedOut = false; + const timer = options.clock.setTimer(OPEN_BOUND_MS, () => { + timedOut = true; + controller.abort(); + }); try { relay = await options.relayTwoWay( workerId, @@ -42,7 +52,15 @@ export function createGatewayInputLink(options: GatewayInputLinkOptions): { lease.workerLeaseId, ); } catch (error) { + if (timedOut) { + return unavailable( + "WORKER_UNREACHABLE", + `Worker ${workerId} did not answer the input socket within 10 s`, + ); + } return refusalFor(error, workerId, lease.id); + } finally { + options.clock.cancel(timer); } if (relay instanceof Response) return relay; return (socket) => bridge(socket, relay, controller); diff --git a/src/input/gateway.ts b/src/input/gateway.ts index 130b52a03..67782c93a 100644 --- a/src/input/gateway.ts +++ b/src/input/gateway.ts @@ -4,6 +4,7 @@ import { createGatewayInput } from "./create.js"; export function createGateway(ctx: GatewayContext): GatewayModule { const input = createGatewayInput({ + clock: ctx.clock, relayTwoWay: (workerId, request, tenant, leaseId) => ctx.relayTwoWay(workerId, request, tenant, leaseId), requesterPrefix: ctx.requesterPrefix, From 82f6fa92d4b59c59a01611e4e6b72f7f0477e42c Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 26 Oct 2026 19:44:34 +0100 Subject: [PATCH 11/28] test: a 9th socket and a failed device input close 1013, and a reconnect within 10 s of a failed open is refused without a new open (#1017) --- src/core/fake-device-input.ts | 15 +++++++++-- src/input/worker.test.ts | 48 ++++++++++++++++++++++++++++++++--- 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/src/core/fake-device-input.ts b/src/core/fake-device-input.ts index 71d64829d..7d58fe4ab 100644 --- a/src/core/fake-device-input.ts +++ b/src/core/fake-device-input.ts @@ -11,6 +11,7 @@ export class FakeDeviceInput implements DeviceInput { readonly sent: InputMessage[] = []; closes = 0; readonly #configListeners = new Set<(config: InputScreenConfig) => void>(); + readonly #failedListeners = new Set<(error: Error) => void>(); #config: InputScreenConfig; constructor(config: InputScreenConfig) { @@ -27,8 +28,9 @@ export class FakeDeviceInput implements DeviceInput { return () => this.#configListeners.delete(listener); } - onFailed(): () => void { - return () => undefined; + onFailed(listener: (error: Error) => void): () => void { + this.#failedListeners.add(listener); + return () => this.#failedListeners.delete(listener); } close(): Promise { @@ -36,6 +38,10 @@ export class FakeDeviceInput implements DeviceInput { return Promise.resolve(); } + fail(error: Error): void { + for (const listener of this.#failedListeners) listener(error); + } + changeConfig(config: InputScreenConfig): void { this.#config = config; for (const listener of this.#configListeners) listener(config); @@ -46,10 +52,15 @@ export class FakeDeviceInput implements DeviceInput { // can tell one shared device input from one per socket. export class FakeInputFactory implements DeviceInputFactory { readonly opened: FakeDeviceInput[] = []; + // why: a test sets this to make the next opens fail, as a controller that cannot start does. + failWith: Error | undefined; + opens = 0; constructor(private readonly config: InputScreenConfig) {} open(): Promise { + this.opens += 1; + if (this.failWith !== undefined) return Promise.reject(this.failWith); const input = new FakeDeviceInput(this.config); this.opened.push(input); return Promise.resolve(input); diff --git a/src/input/worker.test.ts b/src/input/worker.test.ts index 73bde318c..3cbf9e0fe 100644 --- a/src/input/worker.test.ts +++ b/src/input/worker.test.ts @@ -4,7 +4,7 @@ import type { DeviceRecord, LeaseRecord } from "../core/index.js"; import { FakeInputFactory } from "../core/testing.js"; import type { WorkerModule } from "../modules/index.js"; import { fakeWorkerContext } from "../modules/testing.js"; -import { MemoryLeaseSocket } from "../ports/index.js"; +import { type FakeClock, MemoryLeaseSocket } from "../ports/index.js"; import { inputModule } from "./index.js"; const IDENTITY = { principal: "tok_holder", role: "agent" } as const; @@ -17,8 +17,9 @@ function message(tag: number, body: unknown): Uint8Array { async function setup(): Promise<{ readonly worker: WorkerModule; readonly factory: FakeInputFactory; + readonly clock: FakeClock; }> { - const { ctx } = await fakeWorkerContext("input", {}); + const { clock, ctx } = await fakeWorkerContext("input", {}); const factory = new FakeInputFactory(SCREEN); const registry = { snapshot: { @@ -32,10 +33,10 @@ async function setup(): Promise<{ inputs: { openInput: () => factory.open() }, registry, }); - return { factory, worker }; + return { clock, factory, worker }; } -async function admitted(worker: WorkerModule): Promise { +async function attach(worker: WorkerModule): Promise { const opened = await worker.socket?.open(new Request("http://simlock/input"), IDENTITY, { deviceId: "dev_1", id: "L-1", @@ -43,10 +44,17 @@ async function admitted(worker: WorkerModule): Promise { if (typeof opened !== "function") throw new Error("the socket was refused"); const socket = new MemoryLeaseSocket(); opened(socket); + return socket; +} + +async function admitted(worker: WorkerModule): Promise { + const socket = await attach(worker); await vi.waitFor(() => expect(socket.sent.map((sent) => sent[0])).toEqual([0x83, 0x82])); return socket; } +const REFUSED = { code: 1013, reason: "Input is unavailable for this lease" }; + describe("a worker's input socket", () => { it("keeps the socket and sends nothing to the device when a client sends a bad message", async () => { const { factory, worker } = await setup(); @@ -99,4 +107,36 @@ describe("a worker's input socket", () => { }); expect(device?.sent).toHaveLength(valid.length); }); + + it("closes a 9th socket and a failed device input's sockets with 1013, and refuses a reconnect within 10 s of a failed open without a new open", async () => { + const { clock, factory, worker } = await setup(); + const sockets: MemoryLeaseSocket[] = []; + for (let count = 0; count < 8; count += 1) sockets.push(await admitted(worker)); + const ninth = await attach(worker); + await vi.waitFor(() => expect(ninth.closed).toEqual(REFUSED)); + expect(ninth.sent).toEqual([]); + expect(sockets.map((socket) => socket.closed)).toEqual(Array(8).fill(undefined)); + expect(factory.opened).toHaveLength(1); + + factory.opened[0]?.fail(new Error("the controller died")); + expect(sockets.map((socket) => socket.closed)).toEqual(Array(8).fill(REFUSED)); + expect(factory.opened[0]?.closes).toBe(1); + + factory.failWith = new Error("the controller cannot start"); + const opensBefore = factory.opens; + const failed = await attach(worker); + await vi.waitFor(() => expect(failed.closed).toEqual(REFUSED)); + expect(failed.sent).toEqual([]); + expect(factory.opens).toBe(opensBefore + 1); + + clock.advance(9_999); + const retry = await attach(worker); + await vi.waitFor(() => expect(retry.closed).toEqual(REFUSED)); + expect(factory.opens).toBe(opensBefore + 1); + + clock.advance(1); + const later = await attach(worker); + await vi.waitFor(() => expect(later.closed).toEqual(REFUSED)); + expect(factory.opens).toBe(opensBefore + 2); + }); }); From bf64c0eab081bd808bc11800cf791e93e6621a3f Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 26 Oct 2026 19:45:42 +0100 Subject: [PATCH 12/28] feat(input): at most 8 sockets share a lease's device input, a failed device input closes them 1013, and a failed open is remembered for 10 s (#1017) --- src/input/create.ts | 9 +++- src/input/sessions.ts | 89 +++++++++++++++++++++++++++++++--------- src/input/worker-link.ts | 8 +++- src/input/worker.ts | 1 + 4 files changed, 83 insertions(+), 24 deletions(-) diff --git a/src/input/create.ts b/src/input/create.ts index d938ec62f..24cd75f5c 100644 --- a/src/input/create.ts +++ b/src/input/create.ts @@ -4,12 +4,13 @@ import type { ModuleRelayTwoWayTarget, WorkerContext, } from "../modules/index.js"; -import type { Logger } from "../ports/index.js"; +import type { Clock, Logger } from "../ports/index.js"; import { createGatewayInputLink, type GatewayInputLinkOptions } from "./gateway-link.js"; import { upgradeRequired } from "./refusal.js"; import { createWorkerInputLink, type InputRegistry } from "./worker-link.js"; export interface WorkerInputOptions { + readonly clock: Clock; readonly name: string; readonly logger: Logger; readonly inputs: WorkerContext["inputs"]; @@ -29,7 +30,11 @@ export interface WorkerInput { const leaseRoute: ModuleLeaseHandler = () => Promise.resolve(upgradeRequired()); export function createWorkerInput(options: WorkerInputOptions): WorkerInput { - const link = createWorkerInputLink({ inputs: options.inputs, registry: options.registry }); + const link = createWorkerInputLink({ + clock: options.clock, + inputs: options.inputs, + registry: options.registry, + }); return { capability: { name: options.name, read: () => Promise.resolve({ state: "available" }) }, leaseRoute, diff --git a/src/input/sessions.ts b/src/input/sessions.ts index c7367474b..130a81eb3 100644 --- a/src/input/sessions.ts +++ b/src/input/sessions.ts @@ -11,7 +11,14 @@ import { type UnavailableDeviceInput, } from "../core/index.js"; import type { ModuleInputs } from "../modules/index.js"; -import type { LeaseSocket } from "../ports/index.js"; +import type { Clock, LeaseSocket } from "../ports/index.js"; + +const MAX_SOCKETS_PER_LEASE = 8; +// why: a device input that failed to open is not tried again for this long, so a client that +// reconnects every second does not start a controller on a broken device every second. +const FAILED_OPEN_MEMORY_MS = 10_000; +const CLOSE_FAILED = 1013; +const REASON_FAILED = "Input is unavailable for this lease"; interface Attached { readonly socket: LeaseSocket; @@ -24,54 +31,96 @@ interface LeaseInput { readonly opening: Promise; readonly sockets: Set; config: InputScreenConfig | undefined; + closed: boolean; } export interface InputSessions { attach(leaseId: string, device: DeviceRecord, socket: LeaseSocket): void; } -export function createInputSessions(options: { readonly inputs: ModuleInputs }): InputSessions { +export function createInputSessions(options: { + readonly inputs: ModuleInputs; + readonly clock: Clock; +}): InputSessions { const leases = new Map(); + const failedOpens = new Map(); + + // why: every end of a lease's device input -- its last socket leaving, its own failure -- goes through here, so it closes once. + function release(leaseId: string, entry: LeaseInput, opened: DeviceInput): void { + if (entry.closed) return; + entry.closed = true; + if (leases.get(leaseId) === entry) leases.delete(leaseId); + void opened.close(); + } function openFor(leaseId: string, device: DeviceRecord): LeaseInput { const existing = leases.get(leaseId); if (existing !== undefined) return existing; const sockets = new Set(); const entry: LeaseInput = { + closed: false, config: undefined, opening: options.inputs .openInput(device.spec.platform, toDriverDevice(device), new AbortController().signal) - .then((opened) => { - if (!isUnavailableInput(opened)) { - opened.onConfig((config) => { - entry.config = config; - for (const attached of sockets) { - if (attached.admitted) - attached.socket.send(encodeInputMessage(INPUT_TAG.config, config)); - } - }); - } - return opened; - }), + .then( + (opened) => { + if (!isUnavailableInput(opened)) wire(leaseId, entry, opened); + return opened; + }, + (error: unknown) => { + failedOpens.set(leaseId, options.clock.now()); + if (leases.get(leaseId) === entry) leases.delete(leaseId); + throw error; + }, + ), sockets, }; leases.set(leaseId, entry); return entry; } + function wire(leaseId: string, entry: LeaseInput, opened: DeviceInput): void { + opened.onConfig((config) => { + entry.config = config; + for (const attached of entry.sockets) { + if (attached.admitted) attached.socket.send(encodeInputMessage(INPUT_TAG.config, config)); + } + }); + opened.onFailed(() => { + release(leaseId, entry, opened); + for (const attached of entry.sockets) attached.socket.close(CLOSE_FAILED, REASON_FAILED); + }); + } + async function attach(leaseId: string, device: DeviceRecord, socket: LeaseSocket): Promise { + const failedAt = failedOpens.get(leaseId); + if (failedAt !== undefined && options.clock.now() - failedAt < FAILED_OPEN_MEMORY_MS) { + socket.close(CLOSE_FAILED, REASON_FAILED); + return; + } + if ((leases.get(leaseId)?.sockets.size ?? 0) >= MAX_SOCKETS_PER_LEASE) { + socket.close(CLOSE_FAILED, REASON_FAILED); + return; + } const entry = openFor(leaseId, device); const attached: Attached = { admitted: false, socket }; entry.sockets.add(attached); socket.onClose(() => { entry.sockets.delete(attached); - if (entry.sockets.size > 0) return; - leases.delete(leaseId); - void entry.opening.then((opened) => - isUnavailableInput(opened) ? undefined : opened.close(), - ); + if (entry.sockets.size === 0) { + void entry.opening.then( + (opened) => (isUnavailableInput(opened) ? undefined : release(leaseId, entry, opened)), + () => undefined, + ); + } }); - const opened = await entry.opening; + let opened: DeviceInput | UnavailableDeviceInput; + try { + opened = await entry.opening; + } catch { + socket.close(CLOSE_FAILED, REASON_FAILED); + return; + } socket.send(encodeInputMessage(INPUT_TAG.admitted)); if (isUnavailableInput(opened)) { socket.send(encodeInputMessage(INPUT_TAG.config, UNAVAILABLE_INPUT_CONFIG)); diff --git a/src/input/worker-link.ts b/src/input/worker-link.ts index 520cad48b..9d5469ecc 100644 --- a/src/input/worker-link.ts +++ b/src/input/worker-link.ts @@ -4,7 +4,7 @@ import type { ModuleLeaseSocketHandler, ModuleRelayTwoWayTarget, } from "../modules/index.js"; -import type { LeaseSocket } from "../ports/index.js"; +import type { Clock, LeaseSocket } from "../ports/index.js"; import { channelSocket } from "./channel.js"; import { unknownLease } from "./refusal.js"; import { createInputSessions, type InputSessions } from "./sessions.js"; @@ -17,6 +17,7 @@ export interface InputRegistry { } export interface WorkerInputLinkOptions { + readonly clock: Clock; readonly inputs: ModuleInputs; readonly registry: InputRegistry; } @@ -27,7 +28,10 @@ export function createWorkerInputLink(base: WorkerInputLinkOptions): { readonly relayTwoWay: ModuleRelayTwoWayTarget; readonly socket: ModuleLeaseSocketHandler; } { - const options = { ...base, sessions: createInputSessions({ inputs: base.inputs }) }; + const options = { + ...base, + sessions: createInputSessions({ clock: base.clock, inputs: base.inputs }), + }; return { socket: { open: (_request, _identity, held) => { diff --git a/src/input/worker.ts b/src/input/worker.ts index 790b816b5..23377a2aa 100644 --- a/src/input/worker.ts +++ b/src/input/worker.ts @@ -4,6 +4,7 @@ import { createWorkerInput } from "./create.js"; export function createWorker(ctx: WorkerContext): WorkerModule { const input = createWorkerInput({ + clock: ctx.clock, inputs: ctx.inputs, logger: ctx.logger, name: ctx.name, From 7a2b73314eee7556bffb89cf002deedd523b4b09 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 26 Oct 2026 19:46:09 +0100 Subject: [PATCH 13/28] test: an input socket closes 4001 with the reason when its lease ends (#1017) --- src/core/fake-device-input.ts | 9 ++++-- src/input/worker.test.ts | 56 +++++++++++++++++++++++++++++++++-- 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/src/core/fake-device-input.ts b/src/core/fake-device-input.ts index 7d58fe4ab..774f464ab 100644 --- a/src/core/fake-device-input.ts +++ b/src/core/fake-device-input.ts @@ -54,15 +54,18 @@ export class FakeInputFactory implements DeviceInputFactory { readonly opened: FakeDeviceInput[] = []; // why: a test sets this to make the next opens fail, as a controller that cannot start does. failWith: Error | undefined; + // why: a test sets this to hold every open until it resolves, to end a lease while a socket is still opening. + gate: Promise | undefined; opens = 0; constructor(private readonly config: InputScreenConfig) {} - open(): Promise { + async open(): Promise { this.opens += 1; - if (this.failWith !== undefined) return Promise.reject(this.failWith); + await this.gate; + if (this.failWith !== undefined) throw this.failWith; const input = new FakeDeviceInput(this.config); this.opened.push(input); - return Promise.resolve(input); + return input; } } diff --git a/src/input/worker.test.ts b/src/input/worker.test.ts index 3cbf9e0fe..ba9a2e677 100644 --- a/src/input/worker.test.ts +++ b/src/input/worker.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import type { DeviceRecord, LeaseRecord } from "../core/index.js"; import { FakeInputFactory } from "../core/testing.js"; -import type { WorkerModule } from "../modules/index.js"; +import type { ModuleEndedLease, WorkerModule } from "../modules/index.js"; import { fakeWorkerContext } from "../modules/testing.js"; import { type FakeClock, MemoryLeaseSocket } from "../ports/index.js"; import { inputModule } from "./index.js"; @@ -18,13 +18,15 @@ async function setup(): Promise<{ readonly worker: WorkerModule; readonly factory: FakeInputFactory; readonly clock: FakeClock; + readonly leases: LeaseRecord[]; }> { const { clock, ctx } = await fakeWorkerContext("input", {}); const factory = new FakeInputFactory(SCREEN); + const leases = [{ deviceId: "dev_1", id: "L-1" } as unknown as LeaseRecord]; const registry = { snapshot: { devices: [{ id: "dev_1", spec: { platform: "android" } } as unknown as DeviceRecord], - leases: [{ deviceId: "dev_1", id: "L-1" } as unknown as LeaseRecord], + leases, }, }; const loaded = await inputModule.loadWorker(); @@ -33,7 +35,7 @@ async function setup(): Promise<{ inputs: { openInput: () => factory.open() }, registry, }); - return { clock, factory, worker }; + return { clock, factory, leases, worker }; } async function attach(worker: WorkerModule): Promise { @@ -53,6 +55,17 @@ async function admitted(worker: WorkerModule): Promise { return socket; } +function ended(reason: ModuleEndedLease["reason"]): ModuleEndedLease { + return { deviceId: "dev_1", leaseId: "L-1", ownerId: "tok_holder", platform: "android", reason }; +} + +function endLease( + worker: WorkerModule, + reason: ModuleEndedLease["reason"], +): Promise | undefined { + return worker.hooks?.beforeDeviceReclaim?.(ended(reason), new AbortController().signal); +} + const REFUSED = { code: 1013, reason: "Input is unavailable for this lease" }; describe("a worker's input socket", () => { @@ -139,4 +152,41 @@ describe("a worker's input socket", () => { await vi.waitFor(() => expect(later.closed).toEqual(REFUSED)); expect(factory.opens).toBe(opensBefore + 2); }); + + it("closes with 4001 and the reason when its lease ends, whether the socket is admitted, still opening or opens after the end", async () => { + for (const reason of ["explicit", "killed", "expired", "device-lost"] as const) { + const { factory, worker } = await setup(); + const sockets = [await admitted(worker), await admitted(worker)]; + await endLease(worker, reason); + expect(sockets.map((socket) => socket.closed)).toEqual([ + { code: 4001, reason }, + { code: 4001, reason }, + ]); + expect(factory.opened[0]?.closes).toBe(1); + } + + const opening = await setup(); + let open!: () => void; + opening.factory.gate = new Promise((resolve) => { + open = resolve; + }); + const pending = await attach(opening.worker); + await endLease(opening.worker, "expired"); + expect(pending.closed).toEqual({ code: 4001, reason: "expired" }); + open(); + await vi.waitFor(() => expect(opening.factory.opened[0]?.closes).toBe(1)); + expect(pending.sent).toEqual([]); + + const late = await setup(); + const accept = await late.worker.socket?.open(new Request("http://simlock/input"), IDENTITY, { + deviceId: "dev_1", + id: "L-1", + }); + late.leases.splice(0); + const after = new MemoryLeaseSocket(); + if (typeof accept !== "function") throw new Error("the socket was refused"); + accept(after); + expect(after.closed).toEqual({ code: 4001, reason: "lease ended" }); + expect(after.sent).toEqual([]); + }); }); From 2c2a2f8a584cd9469f573a9c3021dad493697292 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 26 Oct 2026 19:46:55 +0100 Subject: [PATCH 14/28] feat(input): a lease's end closes its input sockets 4001 with the reason, and a socket that opens after it is closed too (#1017) --- src/core/index.ts | 1 + src/core/input.ts | 13 +++++++++++ src/input/channel.ts | 7 ++++-- src/input/create.ts | 3 +++ src/input/framing.ts | 4 ++++ src/input/gateway-link.ts | 24 ++++++++++++++++++-- src/input/sessions.ts | 46 ++++++++++++++++++++++++++++++++------- src/input/worker-link.ts | 12 +++++++++- src/input/worker.ts | 1 + 9 files changed, 98 insertions(+), 13 deletions(-) diff --git a/src/core/index.ts b/src/core/index.ts index 225b4138f..b5686d022 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -103,6 +103,7 @@ export { UnknownPassthroughToolError, } from "./driver-catalog.js"; export { + closeReason, type DeviceInput, type DeviceInputFactory, encodeInputMessage, diff --git a/src/core/input.ts b/src/core/input.ts index ac4e986e6..ad9dc3dd6 100644 --- a/src/core/input.ts +++ b/src/core/input.ts @@ -16,6 +16,19 @@ export const INPUT_TAG = { // why: a client message is at most this many bytes with its tag; a longer one is dropped and the socket stays open. export const MAX_INPUT_MESSAGE_BYTES = 64 * 1024; +// why: `ws` throws on a close reason over 123 bytes, and a client may be shown one a peer chose; cut at a character boundary, never in the middle of one. +export function closeReason(reason: string): string { + const encoder = new TextEncoder(); + let cut = ""; + let bytes = 0; + for (const character of reason) { + bytes += encoder.encode(character).length; + if (bytes > 123) break; + cut += character; + } + return cut; +} + export const INPUT_BUTTONS = ["home", "app_switcher", "lock", "back"] as const; export type InputOrientation = diff --git a/src/input/channel.ts b/src/input/channel.ts index 781c65507..ece38329d 100644 --- a/src/input/channel.ts +++ b/src/input/channel.ts @@ -1,6 +1,6 @@ import type { ModuleTwoWayPipe } from "../modules/index.js"; import type { LeaseSocket } from "../ports/index.js"; -import { encodeFrame, readFrames } from "./framing.js"; +import { CHANNEL_TAG, encodeFrame, readFrames } from "./framing.js"; // why: a worker's input session talks to a gateway's client through a relay channel, and the session // takes a `LeaseSocket` whichever door the client came in by; this is the channel's end of that port, @@ -30,7 +30,10 @@ export function channelSocket(pipe: ModuleTwoWayPipe): LeaseSocket { })(); pipe.signal.addEventListener("abort", markClosed, { once: true }); return { - close: () => { + close: (code, reason) => { + write( + encodeFrame(CHANNEL_TAG.close, new TextEncoder().encode(JSON.stringify({ code, reason }))), + ); writes = writes.then(() => writer.close()).catch(() => undefined); markClosed(); }, diff --git a/src/input/create.ts b/src/input/create.ts index 24cd75f5c..141db742a 100644 --- a/src/input/create.ts +++ b/src/input/create.ts @@ -1,4 +1,5 @@ import type { + ModuleEndedLease, ModuleLeaseHandler, ModuleLeaseSocketHandler, ModuleRelayTwoWayTarget, @@ -23,6 +24,7 @@ export interface WorkerInput { read(): Promise<{ readonly state: "available" }>; }; readonly leaseRoute: ModuleLeaseHandler; + readonly leaseEnded: (ended: ModuleEndedLease) => Promise; readonly relayTwoWay: ModuleRelayTwoWayTarget; readonly socket: ModuleLeaseSocketHandler; } @@ -37,6 +39,7 @@ export function createWorkerInput(options: WorkerInputOptions): WorkerInput { }); return { capability: { name: options.name, read: () => Promise.resolve({ state: "available" }) }, + leaseEnded: link.leaseEnded, leaseRoute, relayTwoWay: link.relayTwoWay, socket: link.socket, diff --git a/src/input/framing.ts b/src/input/framing.ts index 0136867b5..0ecb8905d 100644 --- a/src/input/framing.ts +++ b/src/input/framing.ts @@ -2,6 +2,10 @@ // `len` counting the tag and the payload. Only the input module encodes and reads it; the relay moves bytes. import { MAX_INPUT_MESSAGE_BYTES } from "../core/index.js"; +// why: the two channel-only tags: a heartbeat the worker sends while it is alive, and the worker's close +// `{code, reason}`; neither reaches a client, and a client's tags are serve-sim's. +export const CHANNEL_TAG = { close: 0xf1, heartbeat: 0xf0 } as const; + export interface InputFrame { readonly tag: number; readonly payload: Uint8Array; diff --git a/src/input/gateway-link.ts b/src/input/gateway-link.ts index b2a107259..727e8c20c 100644 --- a/src/input/gateway-link.ts +++ b/src/input/gateway-link.ts @@ -1,4 +1,4 @@ -import { MAX_INPUT_MESSAGE_BYTES } from "../core/index.js"; +import { closeReason, MAX_INPUT_MESSAGE_BYTES } from "../core/index.js"; import type { ModuleIdentity, ModuleLease, @@ -6,7 +6,7 @@ import type { ModuleTwoWayRelay, } from "../modules/index.js"; import type { Clock, LeaseSocket } from "../ports/index.js"; -import { encodeFrame, readFrames } from "./framing.js"; +import { CHANNEL_TAG, encodeFrame, readFrames } from "./framing.js"; import { unavailable } from "./refusal.js"; export interface GatewayInputLinkOptions { @@ -100,6 +100,11 @@ function bridge(socket: LeaseSocket, relay: ModuleTwoWayRelay, controller: Abort socket.onClose(() => controller.abort()); void (async () => { for await (const frame of readFrames(relay.readable)) { + if (frame.tag === CHANNEL_TAG.close) { + const ended = closeOf(frame.payload); + if (ended !== undefined) socket.close(ended.code, ended.reason); + return; + } const message = new Uint8Array(1 + frame.payload.length); message[0] = frame.tag; message.set(frame.payload, 1); @@ -107,3 +112,18 @@ function bridge(socket: LeaseSocket, relay: ModuleTwoWayRelay, controller: Abort } })(); } + +// why: the worker's close is wire input from another process: a code and a reason a socket may close with, or nothing. +function closeOf(payload: Uint8Array): { code: number; reason: string } | undefined { + try { + const { code, reason } = JSON.parse(new TextDecoder().decode(payload)) as Record< + string, + unknown + >; + if (typeof code !== "number" || !Number.isInteger(code)) return undefined; + if (code !== 1013 && (code < 4000 || code > 4999)) return undefined; + return typeof reason === "string" ? { code, reason: closeReason(reason) } : undefined; + } catch { + return undefined; + } +} diff --git a/src/input/sessions.ts b/src/input/sessions.ts index 130a81eb3..f8362c135 100644 --- a/src/input/sessions.ts +++ b/src/input/sessions.ts @@ -18,6 +18,7 @@ const MAX_SOCKETS_PER_LEASE = 8; // reconnects every second does not start a controller on a broken device every second. const FAILED_OPEN_MEMORY_MS = 10_000; const CLOSE_FAILED = 1013; +const CLOSE_LEASE_ENDED = 4001; const REASON_FAILED = "Input is unavailable for this lease"; interface Attached { @@ -28,6 +29,8 @@ interface Attached { // why: one device input per lease, shared by every socket of it, so two people on one lease do not // start two controllers on one device. interface LeaseInput { + // why: set once the device input has opened, so an end that finds it open closes it at once instead of after a wait. + device: DeviceInput | undefined; readonly opening: Promise; readonly sockets: Set; config: InputScreenConfig | undefined; @@ -36,11 +39,13 @@ interface LeaseInput { export interface InputSessions { attach(leaseId: string, device: DeviceRecord, socket: LeaseSocket): void; + end(leaseId: string, reason: string): void; } export function createInputSessions(options: { readonly inputs: ModuleInputs; readonly clock: Clock; + readonly leaseExists: (leaseId: string) => boolean; }): InputSessions { const leases = new Map(); const failedOpens = new Map(); @@ -53,6 +58,24 @@ export function createInputSessions(options: { void opened.close(); } + function releaseWhenOpen(leaseId: string, entry: LeaseInput): void { + if (entry.device !== undefined) { + release(leaseId, entry, entry.device); + return; + } + void entry.opening.then( + (opened) => (isUnavailableInput(opened) ? undefined : release(leaseId, entry, opened)), + () => undefined, + ); + } + + function end(leaseId: string, reason: string): void { + const entry = leases.get(leaseId); + if (entry === undefined) return; + for (const attached of entry.sockets) attached.socket.close(CLOSE_LEASE_ENDED, reason); + releaseWhenOpen(leaseId, entry); + } + function openFor(leaseId: string, device: DeviceRecord): LeaseInput { const existing = leases.get(leaseId); if (existing !== undefined) return existing; @@ -60,11 +83,15 @@ export function createInputSessions(options: { const entry: LeaseInput = { closed: false, config: undefined, + device: undefined, opening: options.inputs .openInput(device.spec.platform, toDriverDevice(device), new AbortController().signal) .then( (opened) => { - if (!isUnavailableInput(opened)) wire(leaseId, entry, opened); + if (!isUnavailableInput(opened)) { + entry.device = opened; + wire(leaseId, entry, opened); + } return opened; }, (error: unknown) => { @@ -93,6 +120,11 @@ export function createInputSessions(options: { } async function attach(leaseId: string, device: DeviceRecord, socket: LeaseSocket): Promise { + // why: the lease can end between the HTTP layer's check and this socket's upgrade; the worker's own record decides. + if (!options.leaseExists(leaseId)) { + socket.close(CLOSE_LEASE_ENDED, "lease ended"); + return; + } const failedAt = failedOpens.get(leaseId); if (failedAt !== undefined && options.clock.now() - failedAt < FAILED_OPEN_MEMORY_MS) { socket.close(CLOSE_FAILED, REASON_FAILED); @@ -107,12 +139,7 @@ export function createInputSessions(options: { entry.sockets.add(attached); socket.onClose(() => { entry.sockets.delete(attached); - if (entry.sockets.size === 0) { - void entry.opening.then( - (opened) => (isUnavailableInput(opened) ? undefined : release(leaseId, entry, opened)), - () => undefined, - ); - } + if (entry.sockets.size === 0) releaseWhenOpen(leaseId, entry); }); let opened: DeviceInput | UnavailableDeviceInput; try { @@ -145,5 +172,8 @@ export function createInputSessions(options: { }); } - return { attach: (leaseId, device, socket) => void attach(leaseId, device, socket) }; + return { + attach: (leaseId, device, socket) => void attach(leaseId, device, socket), + end, + }; } diff --git a/src/input/worker-link.ts b/src/input/worker-link.ts index 9d5469ecc..6d6e8b74a 100644 --- a/src/input/worker-link.ts +++ b/src/input/worker-link.ts @@ -1,5 +1,6 @@ import type { DeviceRecord, LeaseRecord } from "../core/index.js"; import type { + ModuleEndedLease, ModuleInputs, ModuleLeaseSocketHandler, ModuleRelayTwoWayTarget, @@ -27,12 +28,21 @@ export interface WorkerInputLinkOptions { export function createWorkerInputLink(base: WorkerInputLinkOptions): { readonly relayTwoWay: ModuleRelayTwoWayTarget; readonly socket: ModuleLeaseSocketHandler; + readonly leaseEnded: (ended: ModuleEndedLease) => Promise; } { const options = { ...base, - sessions: createInputSessions({ clock: base.clock, inputs: base.inputs }), + sessions: createInputSessions({ + clock: base.clock, + inputs: base.inputs, + leaseExists: (leaseId) => base.registry.snapshot.leases.some((entry) => entry.id === leaseId), + }), }; return { + leaseEnded: (ended) => { + options.sessions.end(ended.leaseId, ended.reason); + return Promise.resolve(); + }, socket: { open: (_request, _identity, held) => { const lease = options.registry.snapshot.leases.find((entry) => entry.id === held.id); diff --git a/src/input/worker.ts b/src/input/worker.ts index 23377a2aa..8d44d15ae 100644 --- a/src/input/worker.ts +++ b/src/input/worker.ts @@ -12,6 +12,7 @@ export function createWorker(ctx: WorkerContext): WorkerModul }); return { capability: input.capability, + hooks: { beforeDeviceReclaim: (ended) => input.leaseEnded(ended) }, leaseRoute: input.leaseRoute, relayTwoWay: input.relayTwoWay, socket: input.socket, From 70912c5a9a382ea0d2cff930ff9560902ec80223 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 26 Oct 2026 19:47:40 +0100 Subject: [PATCH 15/28] test: through a gateway the worker's reason closes the socket 4001 and a reconnect gets 404 (#1017) --- src/daemon/input-lease.e2e.test.ts | 36 +++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/src/daemon/input-lease.e2e.test.ts b/src/daemon/input-lease.e2e.test.ts index 9e1993259..8ffd97bcf 100644 --- a/src/daemon/input-lease.e2e.test.ts +++ b/src/daemon/input-lease.e2e.test.ts @@ -61,6 +61,8 @@ interface OpenedSocket { readonly code: string | undefined; readonly messages: Buffer[]; readonly socket: WebSocket; + // why: how the socket closed, once it has; read it again after waiting, it changes. + readonly closed: { code: number; reason: string } | undefined; } async function openInput(url: string, token: string | undefined): Promise { @@ -69,7 +71,11 @@ async function openInput(url: string, token: string | undefined): Promise messages.push(data as Buffer)); + socket.on("close", (code, reason) => { + opened.closed = { code, reason: reason.toString("utf8") }; + }); const outcome = await new Promise<{ status: number; code: string | undefined }>( (resolve, reject) => { socket.once("open", () => resolve({ code: undefined, status: 101 })); @@ -91,7 +97,14 @@ async function openInput(url: string, token: string | undefined): Promise { { timeout: 10_000 }, ); return { + gateway, holder: holder.secret, inputUrl: (id = leaseId) => `ws://127.0.0.1:${String(port)}/v1/leases/${id}/input`, leaseId, operator: operator.secret, other: other.secret, + owner: { ...agent, principal: holder.token.id }, plainUrl: (id = leaseId) => `http://127.0.0.1:${String(port)}/v1/leases/${id}/input`, }; } @@ -340,4 +355,23 @@ describe("a lease's input socket", () => { await vi.waitFor(() => expect(input.opened[0]?.closes).toBe(1), { timeout: 10_000 }); }, ); + + it( + "closes with 4001 and the worker's reason through a gateway when the lease ends, and a reconnect gets 404", + { timeout: 60_000 }, + async () => { + const { gateway, holder, inputUrl, leaseId, owner } = await fleet({ + gatewayInput: true, + workerInput: true, + }); + const opened = await openInput(inputUrl(), holder); + await vi.waitFor(() => expect(tags(opened)).toEqual([0x83, 0x82]), { timeout: 10_000 }); + + await gateway.dispatch("lease.release", { leaseId }, owner); + await vi.waitFor(() => expect(opened.closed).toEqual({ code: 4001, reason: "explicit" }), { + timeout: 10_000, + }); + await expectRefused(inputUrl(), holder, { code: "UNKNOWN_LEASE", status: 404 }); + }, + ); }); From 0ae784635c3e8eff4cc43a6fe7cc5fa53a05553f Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 26 Oct 2026 19:48:56 +0100 Subject: [PATCH 16/28] test: an input socket closes when the worker goes quiet or away, the lease goes, or the daemon stops (#1017) --- src/input/gateway.test.ts | 88 ++++++++++++++++++++++++++++++++++++++- src/modules/testing.ts | 10 ++++- 2 files changed, 95 insertions(+), 3 deletions(-) diff --git a/src/input/gateway.test.ts b/src/input/gateway.test.ts index 94ff8a0ad..aa83f9045 100644 --- a/src/input/gateway.test.ts +++ b/src/input/gateway.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it, vi } from "vitest"; -import type { GatewayModule, ModuleLease } from "../modules/index.js"; +import type { GatewayModule, ModuleLease, ModuleLeaseSocketAccept } from "../modules/index.js"; import { fakeGatewayContext } from "../modules/testing.js"; +import { MemoryLeaseSocket } from "../ports/index.js"; import { inputModule } from "./index.js"; const IDENTITY = { principal: "tok_holder", role: "agent" } as const; @@ -24,7 +25,50 @@ async function gatewayWith(facts: Parameters[2]) { const loaded = await inputModule.loadGateway?.(); const gateway: GatewayModule | undefined = loaded?.createGateway(fake.ctx); if (gateway?.socket === undefined) throw new Error("the input module has no gateway socket"); - return { clock: fake.clock, socket: gateway.socket }; + return { clock: fake.clock, gateway, removeLease: fake.removeLease, socket: gateway.socket }; +} + +const REMOVED = { + gatewayLeaseId: "L-42", + grantedAt: 0, + ownerId: "tok_holder", + requesterId: "gw:gw-test:tok_holder", + workerId: "w1", + workerLeaseId: "L-7", +}; + +// why: the worker's end of a relay: `toGateway` writes what the worker sends, and the gateway's own writes go nowhere. +function relayPair() { + const toGateway = new TransformStream(); + const fromGateway = new TransformStream(); + return { + relay: { + headers: new Headers(), + readable: toGateway.readable, + status: 200, + writable: fromGateway.writable, + }, + worker: toGateway.writable.getWriter(), + }; +} + +// why: the worker's close frame written out by hand (`[len u32][0xF1][json]`), so the test does not read it with the code it tests. +function closeFrame(code: number, reason: string): Uint8Array { + const json = new TextEncoder().encode(JSON.stringify({ code, reason })); + return Uint8Array.from([0, 0, (json.length + 1) >> 8, (json.length + 1) & 0xff, 0xf1, ...json]); +} + +async function attached(workerId: string, options: { readonly leaseWorker?: string } = {}) { + const pair = relayPair(); + const built = await gatewayWith({ relayTwoWay: () => Promise.resolve(pair.relay) }); + const accept = await built.socket.open(new Request("http://simlock/input"), IDENTITY, { + ...LEASE, + workerId: options.leaseWorker ?? workerId, + }); + if (typeof accept !== "function") throw new Error("the socket was refused"); + const client = new MemoryLeaseSocket(); + (accept as ModuleLeaseSocketAccept)(client); + return { ...built, client, worker: pair.worker }; } describe("a gateway's input socket", () => { @@ -50,4 +94,44 @@ describe("a gateway's input socket", () => { }, }); }); + + it("closes the client's socket when the worker goes quiet or away, the lease goes, or the daemon stops", async () => { + const away = await attached("w1"); + await away.worker.abort(new Error("the uplink dropped")); + await vi.waitFor(() => + expect(away.client.closed).toEqual({ code: 1011, reason: "Worker w1 stopped answering" }), + ); + + const ended = await attached("w1"); + await ended.worker.close(); + await vi.waitFor(() => + expect(ended.client.closed).toEqual({ code: 1011, reason: "Worker w1 stopped answering" }), + ); + + const longId = "w".repeat(128); + const long = await attached(longId); + await long.worker.abort(new Error("the uplink dropped")); + await vi.waitFor(() => expect(long.client.closed?.code).toBe(1011)); + expect(long.client.closed?.reason).toBe(`Worker ${"w".repeat(116)}`); + + const reasoned = await attached("w1"); + reasoned.removeLease(REMOVED); + reasoned.clock.advance(1_999); + expect(reasoned.client.closed).toBeUndefined(); + await reasoned.worker.write(closeFrame(4001, "expired")); + await vi.waitFor(() => + expect(reasoned.client.closed).toEqual({ code: 4001, reason: "expired" }), + ); + + const silent = await attached("w1"); + silent.removeLease(REMOVED); + silent.clock.advance(1_999); + expect(silent.client.closed).toBeUndefined(); + silent.clock.advance(1); + expect(silent.client.closed).toEqual({ code: 4001, reason: "lease ended" }); + + const stopping = await attached("w1"); + await stopping.gateway.hooks?.beforeDrain?.(); + expect(stopping.client.closed).toEqual({ code: 1001, reason: "Simlock is stopping" }); + }); }); diff --git a/src/modules/testing.ts b/src/modules/testing.ts index 67c5cd217..e1e85cb39 100644 --- a/src/modules/testing.ts +++ b/src/modules/testing.ts @@ -206,6 +206,8 @@ export interface FakeGatewayContext { readonly clock: FakeClock; readonly ctx: GatewayContext; readonly relayed: { readonly workerId: string; readonly url: string; readonly tenant: string }[]; + // why: plays the gateway's index dropping a lease, as a relayed `lease.released` does. + removeLease(entry: FleetLease): void; } export async function fakeGatewayContext( @@ -226,9 +228,12 @@ export async function fakeGatewayContext( systemStats: new FakeSystemStats({ cpuCount: 4, totalRamBytes: 16 * 1024 ** 3 }), }); const serving = facts.serving ?? []; + const removedListeners: ((entry: FleetLease) => void)[] = []; const leases: FleetLeases = { all: (): readonly FleetLease[] => [], - onRemoved: () => undefined, + onRemoved: (listener) => { + removedListeners.push(listener); + }, resolve: () => undefined, }; const relayed: FakeGatewayContext["relayed"][number][] = []; @@ -258,5 +263,8 @@ export async function fakeGatewayContext( workers: () => serving, }, relayed, + removeLease: (entry) => { + for (const listener of removedListeners) listener(entry); + }, }; } From d81d9cc9dcfd1681cd31c1af09fb898925369d6e Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 26 Oct 2026 19:50:08 +0100 Subject: [PATCH 17/28] feat(input): the gateway closes a socket 1011 when its worker goes, 4001 when it sees the lease go, and 1001 when the daemon stops (#1017) --- src/input/create.ts | 4 +- src/input/gateway-link.ts | 105 +++++++++++++++++++++++++++++--------- src/input/gateway.ts | 7 ++- 3 files changed, 89 insertions(+), 27 deletions(-) diff --git a/src/input/create.ts b/src/input/create.ts index 141db742a..611304200 100644 --- a/src/input/create.ts +++ b/src/input/create.ts @@ -49,6 +49,8 @@ export function createWorkerInput(options: WorkerInputOptions): WorkerInput { export function createGatewayInput(options: GatewayInputLinkOptions): { readonly leaseRoute: ModuleLeaseHandler; readonly socket: ModuleLeaseSocketHandler; + readonly drain: () => void; } { - return { leaseRoute, socket: createGatewayInputLink(options) }; + const link = createGatewayInputLink(options); + return { drain: link.drain, leaseRoute, socket: { open: link.open } }; } diff --git a/src/input/gateway-link.ts b/src/input/gateway-link.ts index 727e8c20c..7a652ea0f 100644 --- a/src/input/gateway-link.ts +++ b/src/input/gateway-link.ts @@ -1,11 +1,12 @@ import { closeReason, MAX_INPUT_MESSAGE_BYTES } from "../core/index.js"; import type { + FleetLeases, ModuleIdentity, ModuleLease, ModuleLeaseSocketAccept, ModuleTwoWayRelay, } from "../modules/index.js"; -import type { Clock, LeaseSocket } from "../ports/index.js"; +import type { Clock, LeaseSocket, TimerHandle } from "../ports/index.js"; import { CHANNEL_TAG, encodeFrame, readFrames } from "./framing.js"; import { unavailable } from "./refusal.js"; @@ -18,20 +19,94 @@ export interface GatewayInputLinkOptions { ) => Promise; readonly requesterPrefix: string; readonly clock: Clock; + readonly leases: FleetLeases; } // why: the relay's own head deadline is 330 s, far too long for a person waiting on a socket; the // socket's open is bounded here and aborts the relay's open when it runs out. const OPEN_BOUND_MS = 10_000; +// why: the worker's `0xF1` usually follows the relayed lease event, and it carries the reason; the gateway +// waits this long for it before closing a socket whose lease it saw go. +const LEASE_END_WAIT_MS = 2_000; + export function createGatewayInputLink(options: GatewayInputLinkOptions): { open( request: Request, identity: ModuleIdentity, lease: ModuleLease, ): Promise; + drain(): void; } { + const bridges = new Map>(); + options.leases.onRemoved((entry) => { + for (const bridge of bridges.get(entry.gatewayLeaseId) ?? []) bridge.leaseGone(); + }); + + function bridge( + socket: LeaseSocket, + relay: ModuleTwoWayRelay, + controller: AbortController, + lease: ModuleLease, + ): void { + const writer = relay.writable.getWriter(); + let writes: Promise = Promise.resolve(); + let ended = false; + let wait: TimerHandle | undefined; + const mine: Bridge = { + leaseGone: () => { + wait ??= options.clock.setTimer(LEASE_END_WAIT_MS, () => stop(4001, "lease ended")); + }, + stop: (code, reason) => stop(code, reason), + }; + const set = bridges.get(lease.id) ?? new Set(); + set.add(mine); + bridges.set(lease.id, set); + function stop(code: number, reason: string): void { + if (ended) return; + ended = true; + if (wait !== undefined) options.clock.cancel(wait); + set.delete(mine); + if (set.size === 0) bridges.delete(lease.id); + socket.close(code, closeReason(reason)); + controller.abort(); + } + socket.onMessage((data) => { + // why: a frame over the limit would end the channel for everyone on the lease, and a message that long is dropped on every door. + if (data.length > MAX_INPUT_MESSAGE_BYTES) return; + const frame = encodeFrame(data[0] ?? 0, data.subarray(1)); + writes = writes.then(() => writer.write(frame)).catch(() => undefined); + }); + socket.onClose(() => { + ended = true; + if (wait !== undefined) options.clock.cancel(wait); + set.delete(mine); + controller.abort(); + }); + void (async () => { + for await (const frame of readFrames(relay.readable)) { + if (frame.tag === CHANNEL_TAG.close) { + const closing = closeOf(frame.payload); + if (closing !== undefined) stop(closing.code, closing.reason); + return; + } + if (frame.tag === CHANNEL_TAG.heartbeat) continue; + const message = new Uint8Array(1 + frame.payload.length); + message[0] = frame.tag; + message.set(frame.payload, 1); + socket.send(message); + } + // why: the worker's direction ended with no close frame, or broke: it is gone, and the relay's own silence limit and an uplink that dropped end up here too. + stop(1011, `Worker ${lease.workerId ?? ""} stopped answering`); + })(); + } + return { + drain: () => { + for (const set of bridges.values()) { + for (const each of set) each.stop(1001, "Simlock is stopping"); + } + }, open: async (_request, identity, lease) => { const controller = new AbortController(); const workerId = lease.workerId ?? ""; @@ -63,7 +138,7 @@ export function createGatewayInputLink(options: GatewayInputLinkOptions): { options.clock.cancel(timer); } if (relay instanceof Response) return relay; - return (socket) => bridge(socket, relay, controller); + return (socket) => bridge(socket, relay, controller, lease); }, }; } @@ -88,29 +163,9 @@ function refusalFor(error: unknown, workerId: string, leaseId: string): Response throw error; } -function bridge(socket: LeaseSocket, relay: ModuleTwoWayRelay, controller: AbortController): void { - const writer = relay.writable.getWriter(); - let writes: Promise = Promise.resolve(); - socket.onMessage((data) => { - // why: a frame over the limit would end the channel for everyone on the lease, and a message that long is dropped on every door. - if (data.length > MAX_INPUT_MESSAGE_BYTES) return; - const frame = encodeFrame(data[0] ?? 0, data.subarray(1)); - writes = writes.then(() => writer.write(frame)).catch(() => undefined); - }); - socket.onClose(() => controller.abort()); - void (async () => { - for await (const frame of readFrames(relay.readable)) { - if (frame.tag === CHANNEL_TAG.close) { - const ended = closeOf(frame.payload); - if (ended !== undefined) socket.close(ended.code, ended.reason); - return; - } - const message = new Uint8Array(1 + frame.payload.length); - message[0] = frame.tag; - message.set(frame.payload, 1); - socket.send(message); - } - })(); +interface Bridge { + readonly leaseGone: () => void; + readonly stop: (code: number, reason: string) => void; } // why: the worker's close is wire input from another process: a code and a reason a socket may close with, or nothing. diff --git a/src/input/gateway.ts b/src/input/gateway.ts index 67782c93a..46fd50e6b 100644 --- a/src/input/gateway.ts +++ b/src/input/gateway.ts @@ -5,9 +5,14 @@ import { createGatewayInput } from "./create.js"; export function createGateway(ctx: GatewayContext): GatewayModule { const input = createGatewayInput({ clock: ctx.clock, + leases: ctx.leases, relayTwoWay: (workerId, request, tenant, leaseId) => ctx.relayTwoWay(workerId, request, tenant, leaseId), requesterPrefix: ctx.requesterPrefix, }); - return { leaseRoute: input.leaseRoute, socket: input.socket }; + return { + hooks: { beforeDrain: () => Promise.resolve(input.drain()) }, + leaseRoute: input.leaseRoute, + socket: input.socket, + }; } From cdf04ea1ef70e8ca02c3bd0a1ee1c850932002be Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 26 Oct 2026 19:50:22 +0100 Subject: [PATCH 18/28] test: the worker sends a heartbeat every 5 s while its channel is open (#1017) --- src/input/worker.test.ts | 41 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/input/worker.test.ts b/src/input/worker.test.ts index ba9a2e677..81c8ab1e3 100644 --- a/src/input/worker.test.ts +++ b/src/input/worker.test.ts @@ -189,4 +189,45 @@ describe("a worker's input socket", () => { expect(after.closed).toEqual({ code: 4001, reason: "lease ended" }); expect(after.sent).toEqual([]); }); + + it("sends the gateway a heartbeat every 5 s while its channel is open and none after it closes", async () => { + const { clock, worker } = await setup(); + const toGateway = new TransformStream(); + const fromGateway = new TransformStream(); + const written: number[] = []; + void toGateway.readable.pipeTo( + new WritableStream({ + write: (chunk) => { + written.push(...chunk); + }, + }), + ); + const answer = await worker.relayTwoWay?.(new Request("http://simlock/input"), "gw:t", "L-1", { + readable: fromGateway.readable, + signal: new AbortController().signal, + writable: toGateway.writable, + }); + expect(answer?.status).toBe(200); + const heartbeat = [0, 0, 0, 1, 0xf0]; + const heartbeats = (): number => { + let count = 0; + for (let at = 0; at + 5 <= written.length; at += 1) { + if (heartbeat.every((byte, offset) => written[at + offset] === byte)) count += 1; + } + return count; + }; + await vi.waitFor(() => expect(written.slice(0, 5)).toEqual([0, 0, 0, 1, 0x83])); + + clock.advance(4_999); + expect(heartbeats()).toBe(0); + clock.advance(1); + await vi.waitFor(() => expect(heartbeats()).toBe(1)); + clock.advance(5_000); + await vi.waitFor(() => expect(heartbeats()).toBe(2)); + + await endLease(worker, "explicit"); + clock.advance(20_000); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(heartbeats()).toBe(2); + }); }); From b4fe3b9eaaf049e0fc053abac8bd9fa7ff6b044a Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 26 Oct 2026 19:50:52 +0100 Subject: [PATCH 19/28] feat(input): a worker's input session sends the gateway a heartbeat every 5 s (#1017) --- src/input/channel.ts | 15 +++++++++++++-- src/input/worker-link.ts | 2 +- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/input/channel.ts b/src/input/channel.ts index ece38329d..b7229556e 100644 --- a/src/input/channel.ts +++ b/src/input/channel.ts @@ -1,24 +1,35 @@ import type { ModuleTwoWayPipe } from "../modules/index.js"; -import type { LeaseSocket } from "../ports/index.js"; +import type { Clock, LeaseSocket, TimerHandle } from "../ports/index.js"; import { CHANNEL_TAG, encodeFrame, readFrames } from "./framing.js"; +const HEARTBEAT_MS = 5_000; + // why: a worker's input session talks to a gateway's client through a relay channel, and the session // takes a `LeaseSocket` whichever door the client came in by; this is the channel's end of that port, // a socket whose messages are frames of the channel. -export function channelSocket(pipe: ModuleTwoWayPipe): LeaseSocket { +export function channelSocket(pipe: ModuleTwoWayPipe, clock: Clock): LeaseSocket { const writer = pipe.writable.getWriter(); const messageListeners: ((data: Uint8Array) => void)[] = []; const closeListeners: (() => void)[] = []; let writes: Promise = Promise.resolve(); let closed = false; + let beat: TimerHandle | undefined; const markClosed = (): void => { if (closed) return; closed = true; + if (beat !== undefined) clock.cancel(beat); for (const listener of closeListeners) listener(); }; const write = (frame: Uint8Array): void => { writes = writes.then(() => writer.write(frame)).catch(() => undefined); }; + // why: the gateway ends a relay whose worker direction is silent for 15 s, and a session with nothing to say still has to be seen alive. + const heartbeat = (): void => { + if (closed) return; + write(encodeFrame(CHANNEL_TAG.heartbeat, new Uint8Array())); + beat = clock.setTimer(HEARTBEAT_MS, heartbeat); + }; + beat = clock.setTimer(HEARTBEAT_MS, heartbeat); void (async () => { for await (const frame of readFrames(pipe.readable)) { const message = new Uint8Array(1 + frame.payload.length); diff --git a/src/input/worker-link.ts b/src/input/worker-link.ts index 6d6e8b74a..945d7a9d2 100644 --- a/src/input/worker-link.ts +++ b/src/input/worker-link.ts @@ -53,7 +53,7 @@ export function createWorkerInputLink(base: WorkerInputLinkOptions): { relayTwoWay: (_request, _tenant, leaseId, pipe) => { const lease = options.registry.snapshot.leases.find((entry) => entry.id === leaseId); if (lease === undefined) return Promise.resolve(unknownLease(leaseId ?? "")); - serve(options, lease, channelSocket(pipe)); + serve(options, lease, channelSocket(pipe, options.clock)); return Promise.resolve(new Response(null, { status: 200 })); }, }; From f68eeab5e6394ee9924d34e5766d32b42dfac92e Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 26 Oct 2026 19:51:49 +0100 Subject: [PATCH 20/28] feat(input): a stopping worker closes its input sockets, and the gateway reports a worker that stops as 1011 (#1017) --- src/daemon/input-lease.e2e.test.ts | 61 ++++++++++++++++++++++++++++-- src/input/create.ts | 2 + src/input/gateway-link.ts | 11 ++++-- src/input/sessions.ts | 11 ++++++ src/input/worker-link.ts | 5 +++ src/input/worker.ts | 5 ++- 6 files changed, 88 insertions(+), 7 deletions(-) diff --git a/src/daemon/input-lease.e2e.test.ts b/src/daemon/input-lease.e2e.test.ts index 8ffd97bcf..818a8dcb5 100644 --- a/src/daemon/input-lease.e2e.test.ts +++ b/src/daemon/input-lease.e2e.test.ts @@ -7,7 +7,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { WebSocket } from "ws"; import { FakeDriver, FakeInputFactory, fakeDeviceDataTables } from "../core/testing.js"; -import { MemoryFilesystem, NoopLogger, SystemClock } from "../ports/index.js"; +import { FakeClock, MemoryFilesystem, NoopLogger, SystemClock } from "../ports/index.js"; import type { DispatchSession } from "./dispatch.js"; import { startDaemon } from "./main.js"; import type { DaemonServer } from "./server.js"; @@ -65,8 +65,13 @@ interface OpenedSocket { readonly closed: { code: number; reason: string } | undefined; } -async function openInput(url: string, token: string | undefined): Promise { +async function openInput( + url: string, + token: string | undefined, + options: { readonly autoPong?: boolean } = {}, +): Promise { const socket = new WebSocket(url, { + autoPong: options.autoPong ?? true, headers: token === undefined ? {} : { authorization: `Bearer ${token}` }, }); sockets.push(socket); @@ -205,6 +210,7 @@ describe("a lease's input socket", () => { return { gateway, holder: holder.secret, + worker, inputUrl: (id = leaseId) => `ws://127.0.0.1:${String(port)}/v1/leases/${id}/input`, leaseId, operator: operator.secret, @@ -214,9 +220,10 @@ describe("a lease's input socket", () => { }; } - async function standalone() { + async function standalone(clock?: FakeClock) { const port = await freeLoopbackPort(); const worker = await startDaemon({ + ...(clock === undefined ? {} : { clock }), configOverrides: { capabilities: { input: { enabled: true } }, http: { enabled: true, host: "127.0.0.1", port }, @@ -232,6 +239,7 @@ describe("a lease's input socket", () => { const { holder, operator, other } = await tokens(worker); const leaseId = await lease(worker, holder.token.id); return { + daemon: worker, holder: holder.secret, inputUrl: (id = leaseId) => `ws://127.0.0.1:${String(port)}/v1/leases/${id}/input`, leaseId, @@ -374,4 +382,51 @@ describe("a lease's input socket", () => { await expectRefused(inputUrl(), holder, { code: "UNKNOWN_LEASE", status: 404 }); }, ); + + it( + "closes the socket when the client misses its pong, the worker stops, or the daemon stops", + { timeout: 60_000 }, + async () => { + const clock = new FakeClock(); + const direct = await standalone(clock); + const quiet = await openInput(direct.inputUrl(), direct.holder, { autoPong: false }); + const alive = await openInput(direct.inputUrl(), direct.holder); + await vi.waitFor(() => expect(tags(alive)).toEqual([0x83, 0x82]), { timeout: 10_000 }); + clock.advance(15_000); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(quiet.closed).toBeUndefined(); + clock.advance(15_000); + await vi.waitFor(() => expect(quiet.closed?.code).toBe(1006), { timeout: 10_000 }); + expect(alive.closed).toBeUndefined(); + + await direct.daemon.stop("test"); + await vi.waitFor( + () => expect(alive.closed).toEqual({ code: 1001, reason: "Simlock is stopping" }), + { timeout: 10_000 }, + ); + + const through = await fleet({ gatewayInput: true, workerInput: true }); + const viaGateway = await openInput(through.inputUrl(), through.holder); + await vi.waitFor(() => expect(tags(viaGateway)).toEqual([0x83, 0x82]), { timeout: 10_000 }); + const { workers } = await through.gateway.dispatch("worker.list", {}, admin); + await through.worker.stop("test"); + await vi.waitFor( + () => + expect(viaGateway.closed).toEqual({ + code: 1011, + reason: `Worker ${workers[0]?.id ?? ""} stopped answering`, + }), + { timeout: 10_000 }, + ); + + const second = await fleet({ gatewayInput: true, workerInput: true }); + const stopped = await openInput(second.inputUrl(), second.holder); + await vi.waitFor(() => expect(tags(stopped)).toEqual([0x83, 0x82]), { timeout: 10_000 }); + await second.gateway.stop("test"); + await vi.waitFor( + () => expect(stopped.closed).toEqual({ code: 1001, reason: "Simlock is stopping" }), + { timeout: 10_000 }, + ); + }, + ); }); diff --git a/src/input/create.ts b/src/input/create.ts index 611304200..21e3596d1 100644 --- a/src/input/create.ts +++ b/src/input/create.ts @@ -25,6 +25,7 @@ export interface WorkerInput { }; readonly leaseRoute: ModuleLeaseHandler; readonly leaseEnded: (ended: ModuleEndedLease) => Promise; + readonly drain: () => Promise; readonly relayTwoWay: ModuleRelayTwoWayTarget; readonly socket: ModuleLeaseSocketHandler; } @@ -39,6 +40,7 @@ export function createWorkerInput(options: WorkerInputOptions): WorkerInput { }); return { capability: { name: options.name, read: () => Promise.resolve({ state: "available" }) }, + drain: link.drain, leaseEnded: link.leaseEnded, leaseRoute, relayTwoWay: link.relayTwoWay, diff --git a/src/input/gateway-link.ts b/src/input/gateway-link.ts index 7a652ea0f..b778aee5e 100644 --- a/src/input/gateway-link.ts +++ b/src/input/gateway-link.ts @@ -62,6 +62,9 @@ export function createGatewayInputLink(options: GatewayInputLinkOptions): { const set = bridges.get(lease.id) ?? new Set(); set.add(mine); bridges.set(lease.id, set); + function stopped(): void { + stop(1011, `Worker ${lease.workerId ?? ""} stopped answering`); + } function stop(code: number, reason: string): void { if (ended) return; ended = true; @@ -87,7 +90,9 @@ export function createGatewayInputLink(options: GatewayInputLinkOptions): { for await (const frame of readFrames(relay.readable)) { if (frame.tag === CHANNEL_TAG.close) { const closing = closeOf(frame.payload); - if (closing !== undefined) stop(closing.code, closing.reason); + // why: a worker that is stopping says 1001, which is a statement about the gateway to a client; to the client the worker is gone. + if (closing === undefined || closing.code === 1001) stopped(); + else stop(closing.code, closing.reason); return; } if (frame.tag === CHANNEL_TAG.heartbeat) continue; @@ -97,7 +102,7 @@ export function createGatewayInputLink(options: GatewayInputLinkOptions): { socket.send(message); } // why: the worker's direction ended with no close frame, or broke: it is gone, and the relay's own silence limit and an uplink that dropped end up here too. - stop(1011, `Worker ${lease.workerId ?? ""} stopped answering`); + stopped(); })(); } @@ -176,7 +181,7 @@ function closeOf(payload: Uint8Array): { code: number; reason: string } | undefi unknown >; if (typeof code !== "number" || !Number.isInteger(code)) return undefined; - if (code !== 1013 && (code < 4000 || code > 4999)) return undefined; + if (code !== 1001 && code !== 1013 && (code < 4000 || code > 4999)) return undefined; return typeof reason === "string" ? { code, reason: closeReason(reason) } : undefined; } catch { return undefined; diff --git a/src/input/sessions.ts b/src/input/sessions.ts index f8362c135..5c162b10f 100644 --- a/src/input/sessions.ts +++ b/src/input/sessions.ts @@ -19,6 +19,7 @@ const MAX_SOCKETS_PER_LEASE = 8; const FAILED_OPEN_MEMORY_MS = 10_000; const CLOSE_FAILED = 1013; const CLOSE_LEASE_ENDED = 4001; +const CLOSE_GOING_AWAY = 1001; const REASON_FAILED = "Input is unavailable for this lease"; interface Attached { @@ -40,6 +41,7 @@ interface LeaseInput { export interface InputSessions { attach(leaseId: string, device: DeviceRecord, socket: LeaseSocket): void; end(leaseId: string, reason: string): void; + drain(): void; } export function createInputSessions(options: { @@ -76,6 +78,14 @@ export function createInputSessions(options: { releaseWhenOpen(leaseId, entry); } + function drain(): void { + for (const [leaseId, entry] of leases) { + for (const attached of entry.sockets) + attached.socket.close(CLOSE_GOING_AWAY, "Simlock is stopping"); + releaseWhenOpen(leaseId, entry); + } + } + function openFor(leaseId: string, device: DeviceRecord): LeaseInput { const existing = leases.get(leaseId); if (existing !== undefined) return existing; @@ -174,6 +184,7 @@ export function createInputSessions(options: { return { attach: (leaseId, device, socket) => void attach(leaseId, device, socket), + drain, end, }; } diff --git a/src/input/worker-link.ts b/src/input/worker-link.ts index 945d7a9d2..49fed2ecf 100644 --- a/src/input/worker-link.ts +++ b/src/input/worker-link.ts @@ -29,6 +29,7 @@ export function createWorkerInputLink(base: WorkerInputLinkOptions): { readonly relayTwoWay: ModuleRelayTwoWayTarget; readonly socket: ModuleLeaseSocketHandler; readonly leaseEnded: (ended: ModuleEndedLease) => Promise; + readonly drain: () => Promise; } { const options = { ...base, @@ -39,6 +40,10 @@ export function createWorkerInputLink(base: WorkerInputLinkOptions): { }), }; return { + drain: () => { + options.sessions.drain(); + return Promise.resolve(); + }, leaseEnded: (ended) => { options.sessions.end(ended.leaseId, ended.reason); return Promise.resolve(); diff --git a/src/input/worker.ts b/src/input/worker.ts index 8d44d15ae..cf22c104a 100644 --- a/src/input/worker.ts +++ b/src/input/worker.ts @@ -12,7 +12,10 @@ export function createWorker(ctx: WorkerContext): WorkerModul }); return { capability: input.capability, - hooks: { beforeDeviceReclaim: (ended) => input.leaseEnded(ended) }, + hooks: { + beforeDeviceReclaim: (ended) => input.leaseEnded(ended), + beforeDrain: () => input.drain(), + }, leaseRoute: input.leaseRoute, relayTwoWay: input.relayTwoWay, socket: input.socket, From d85ec2b5defd6135be2127b74ecaf57a1d461ba4 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 26 Oct 2026 19:56:17 +0100 Subject: [PATCH 21/28] feat(input): the input socket's OpenAPI route, website pages, manual and boundary rules (#1017) --- .dependency-cruiser.mjs | 12 ++++ docs/internal/ARCHITECTURE.md | 3 + src/core/AGENTS.md | 58 ++++++++-------- src/daemon/AGENTS.md | 2 +- src/http/AGENTS.md | 8 ++- src/http/core-routes.test.ts | 3 +- src/http/openapi.test.ts | 16 ++++- src/input/AGENTS.md | 66 +++++++++++++++++++ src/input/CLAUDE.md | 1 + src/input/definition.ts | 26 ++++++++ src/modules/AGENTS.md | 10 +-- src/ports/AGENTS.md | 63 +++++++++--------- .../docs/docs/gateway/access-control.mdx | 2 +- .../using-devices/watching-the-screen.mdx | 21 +++++- 14 files changed, 219 insertions(+), 72 deletions(-) create mode 100644 src/input/AGENTS.md create mode 100644 src/input/CLAUDE.md diff --git a/.dependency-cruiser.mjs b/.dependency-cruiser.mjs index b835f91d9..c2b3a66bd 100644 --- a/.dependency-cruiser.mjs +++ b/.dependency-cruiser.mjs @@ -138,6 +138,18 @@ export default { "^src/stream/", { path: "^src/(http|gateway|drivers|daemon|cli|mcp|leasing)/" }, ), + direction( + "not-input", + "core, leasing, the drivers, the gateway and the contract import nothing from the input module", + "^src/(core|leasing|drivers|gateway|contract)/", + { path: "^src/input/" }, + ), + direction( + "input-not-edges", + "the input module is a module the host loads: it imports no frontend, gateway, driver or leasing", + "^src/input/", + { path: "^src/(http|gateway|drivers|daemon|cli|mcp|leasing)/" }, + ), direction( "host-load-imports-ports-only", "host-load reads the machine through the HostLoadProbe port and imports no other component", diff --git a/docs/internal/ARCHITECTURE.md b/docs/internal/ARCHITECTURE.md index ab9add4fc..c760a427b 100644 --- a/docs/internal/ARCHITECTURE.md +++ b/docs/internal/ARCHITECTURE.md @@ -49,6 +49,7 @@ worker daemon ── uplink (WebSocket to /v1/uplink, worker dials) ──┘ | link | Byte channels over the uplink | [link](../../src/link/AGENTS.md) | | agent-device | Optional edge: the two agent-device daemons (simulators, phones) beside a worker, its HTTP front | [agent-device](../../src/agent-device/AGENTS.md) | | stream | Optional edge: a running device's live screen over HTTP, relayed by a gateway | [stream](../../src/stream/AGENTS.md) | +| input | Optional edge: a lease holder's taps and keys over a WebSocket, relayed by a gateway | [input](../../src/input/AGENTS.md) | | auth | Credential → identity: tokens, admin secret, `hello`, uplink join token, HTTP strategies | [auth](../../src/auth/AGENTS.md) | | http | HTTP frontend: routes, SSE | [http](../../src/http/AGENTS.md) | | cli | Operator command surface | [cli](../../src/cli/AGENTS.md) | @@ -86,6 +87,7 @@ Arrows point from the optional to the required (architecture rule 15). | `host-load-imports-ports-only`, `not-host-load` | host-load stands alone | | `alerts-imports-bus-ports-core-only`, `not-alerts` | alerts imports the bus, ports and core only; only `src/daemon/main.ts` imports it | | `not-stream`, `stream-not-edges` | Nothing required imports stream; stream imports no frontend, gateway or driver | +| `not-input`, `input-not-edges` | Nothing required imports input; input imports no frontend, gateway or driver | | `gateway-not-edges`, `gateway-only-dispatch` | Gateway imports no driver or frontend, and from daemon only `src/daemon/dispatch.ts` | | `only-main-knows-gateway` | In daemon, only `src/daemon/main.ts` imports the gateway | | `link-is-portable`, `link-imports-port-types-only` | The link uses no Node module or package, and only the types of ports | @@ -105,6 +107,7 @@ never import each other, directly or through a third. | `device.exec`, local and through a gateway | [device-exec](flows/device-exec.md) | | agent-device through simlock | [agent-device](../../src/agent-device/AGENTS.md) | | A screen stream, local and through a gateway | [stream](../../src/stream/AGENTS.md) | +| A lease's input socket, local and through a gateway | [input](../../src/input/AGENTS.md) | ## Rules that cut across modules diff --git a/src/core/AGENTS.md b/src/core/AGENTS.md index 3b8a9987b..d7e164bfb 100644 --- a/src/core/AGENTS.md +++ b/src/core/AGENTS.md @@ -9,35 +9,35 @@ See also: [leasing](../leasing/AGENTS.md) (lease flow, when reclaim runs), [warm ## Files -| File | Role | -| ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Wiring and state** | | -| `create-core.ts`, `core-ports.ts` | `createCore`, `Core`, `CorePorts`; read ports (`CapacityReader`, `WarmPoolReader`, …) | -| `registry.ts`, `registry-rows.ts`, `registry-schema.ts` | `Registry`: the one writer of the registry tables of `state.db`; the rows, the `registry` schema owner | -| `domain.ts` | Records, specs, `transition`, `fits`, `mayBeGranted`, `sameSpec` | -| `serialized-decision.ts`, `device-operation-claims.ts` | The lock; one in-memory claim per device | -| `config.ts`, `validation.ts` | `loadConfig({ role })`, `loadConfigWithDefaults` (also the module names whose `enabled` came from an `enabledByDefault` default) (the role comes from the daemon, never a config key; a `mode` key is refused; with the optional modules' `ModuleConfigEntry` list: `capabilities.` sections, `enabled` owned here), defaults, validators, `redactConfig`, `effectiveAllowDownload` | -| `lease-request-store.ts` | Pure lease-request record helpers, shared with leasing | -| **Device verbs** | | -| `managed-device-lifecycle.ts` | `ManagedDeviceLifecycle`: every driver verb on a registered device | -| `device-provisioner.ts` | `DeviceProvisioner`: the one place a device record is created | -| `reclaim-coordinator.ts` | `ReclaimCoordinator`: wipe after a lease, spent delete, interrupted reclaim | -| `quarantine-coordinator.ts` | `QuarantineCoordinator`: entry, retry on backoff, give up | -| `nuke-service.ts`, `nuke.ts` | `NukeService` (operator reset); `Nuke` (command facade) | -| `idle-order.ts` | LRU order and eviction victim choice | -| **Background** | | -| `warm-pool/`, `capacity/` | What stays warm; limits, RAM budget, reservations (own manuals) | -| `reaper.ts`, `cleanup/`, `cleanup-executor.ts` | `CleanupReaper`, pure rules, `CleanupExecutor` | -| **Startup, health, drivers** | | -| `startup-read.ts`, `startup-converger.ts` | `readStartup`; `StartupConverger`, the device half of startup | -| `write-leftovers.ts` | `sweepWriteLeftovers`: removes dead writers' `.tmp` files and the `.staging` dirs of the device roots it is given; logs pid-less legacy ones, never deletes them | -| `doctor.ts`, `host-facts.ts` | `Doctor` findings and fixes (`service-broken` from an injected `serviceCheck`, never drift); `HostFactsReader` for status | -| `driver.ts`, `driver-catalog.ts`, `screen.ts` | `Driver`, errors, `DiskSpaceGuard`; `DriverCatalog`; `ScreenCapture`, frame tags | -| `catalog-match.ts` | Model name, class and paired runtimes against a catalog entry | -| `component-installer.ts` | `ComponentInstaller`: install, list, remove | -| `physical-enrollment.ts`, `physical-presence.ts` | `device add`/`remove`; every presence read, `latestPresence` | -| `device-root.ts`, `instance-identity.ts` | `ensureOwnedRoot`; `loadInstanceId` | -| `stable-error.ts`, `testing.ts`, `fake-driver.ts` | `stableError`; test-only wiring and `FakeDriver` | +| File | Role | +| --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Wiring and state** | | +| `create-core.ts`, `core-ports.ts` | `createCore`, `Core`, `CorePorts`; read ports (`CapacityReader`, `WarmPoolReader`, …) | +| `registry.ts`, `registry-rows.ts`, `registry-schema.ts` | `Registry`: the one writer of the registry tables of `state.db`; the rows, the `registry` schema owner | +| `domain.ts` | Records, specs, `transition`, `fits`, `mayBeGranted`, `sameSpec` | +| `serialized-decision.ts`, `device-operation-claims.ts` | The lock; one in-memory claim per device | +| `config.ts`, `validation.ts` | `loadConfig({ role })`, `loadConfigWithDefaults` (also the module names whose `enabled` came from an `enabledByDefault` default) (the role comes from the daemon, never a config key; a `mode` key is refused; with the optional modules' `ModuleConfigEntry` list: `capabilities.` sections, `enabled` owned here), defaults, validators, `redactConfig`, `effectiveAllowDownload` | +| `lease-request-store.ts` | Pure lease-request record helpers, shared with leasing | +| **Device verbs** | | +| `managed-device-lifecycle.ts` | `ManagedDeviceLifecycle`: every driver verb on a registered device | +| `device-provisioner.ts` | `DeviceProvisioner`: the one place a device record is created | +| `reclaim-coordinator.ts` | `ReclaimCoordinator`: wipe after a lease, spent delete, interrupted reclaim | +| `quarantine-coordinator.ts` | `QuarantineCoordinator`: entry, retry on backoff, give up | +| `nuke-service.ts`, `nuke.ts` | `NukeService` (operator reset); `Nuke` (command facade) | +| `idle-order.ts` | LRU order and eviction victim choice | +| **Background** | | +| `warm-pool/`, `capacity/` | What stays warm; limits, RAM budget, reservations (own manuals) | +| `reaper.ts`, `cleanup/`, `cleanup-executor.ts` | `CleanupReaper`, pure rules, `CleanupExecutor` | +| **Startup, health, drivers** | | +| `startup-read.ts`, `startup-converger.ts` | `readStartup`; `StartupConverger`, the device half of startup | +| `write-leftovers.ts` | `sweepWriteLeftovers`: removes dead writers' `.tmp` files and the `.staging` dirs of the device roots it is given; logs pid-less legacy ones, never deletes them | +| `doctor.ts`, `host-facts.ts` | `Doctor` findings and fixes (`service-broken` from an injected `serviceCheck`, never drift); `HostFactsReader` for status | +| `driver.ts`, `driver-catalog.ts`, `screen.ts`, `input.ts` | `Driver`, errors, `DiskSpaceGuard`; `DriverCatalog` (`openScreen`, `openInput`); `ScreenCapture`, frame tags; `DeviceInput`, the input messages and their parser | +| `catalog-match.ts` | Model name, class and paired runtimes against a catalog entry | +| `component-installer.ts` | `ComponentInstaller`: install, list, remove | +| `physical-enrollment.ts`, `physical-presence.ts` | `device add`/`remove`; every presence read, `latestPresence` | +| `device-root.ts`, `instance-identity.ts` | `ensureOwnedRoot`; `loadInstanceId` | +| `stable-error.ts`, `testing.ts`, `fake-driver.ts` | `stableError`; test-only wiring and `FakeDriver` | ## Wiring diff --git a/src/daemon/AGENTS.md b/src/daemon/AGENTS.md index e00d2ae8d..a4bc7f726 100644 --- a/src/daemon/AGENTS.md +++ b/src/daemon/AGENTS.md @@ -43,7 +43,7 @@ The role (`StartDaemonOptions.role`; the entry reads it from `--gateway` through (requester prefix `gw::`) → `GatewayService` (uplink listener, drained workers in `state.db`) → `FleetLeaseCoordinator` → `createAlerts` when `gateway.alerts.destinations` is not empty (before `start()`; stopped first in `stopAuxiliary`) → `DaemonServer` with `GatewayDispatcher`. - HTTP always starts; the uplink upgrades on the same listener. + HTTP always starts; the uplink and lease input sockets upgrade on the same listener, through one upgrade router. Startup waits for the HTTP listener: a bind failure fails the start (health `failed`, no `daemon.started`) and stops the daemon with `http-start-failed`. diff --git a/src/http/AGENTS.md b/src/http/AGENTS.md index cec3fea6f..ed465f574 100644 --- a/src/http/AGENTS.md +++ b/src/http/AGENTS.md @@ -72,7 +72,11 @@ agent-device front and the stream (`/v1/stream`). auth. The route resolves the lease with `lease.get` (the owner or an operator; anyone else, and an unknown id, get 404 `UNKNOWN_LEASE`), then calls the handler with `(request, identity, lease)`; the lease carries `workerId` and `workerLeaseId` on a gateway. The module never checks an owner, and http knows a module only by its name. Today: the -stream (`/v1/leases/{id}/stream`). +stream (`/v1/leases/{id}/stream`) and the input module's plain GET (426). + +A route with a `socket` handler also takes a WebSocket upgrade: `app.leaseSocket(request)` (called by the ports +upgrade router with the upgrade request) runs the same auth and lease resolution, then `socket.open`, and answers the refusal +`Response` or the accept the router runs on the upgraded socket. A route with no `socket` answers 404 to an upgrade. ## Lease requests @@ -125,7 +129,7 @@ until `POST /v1/leases/{id}/renew` drains them into `notices`. `lease_lost` clea - The listener starts right after the socket claim (`onSocketClaimed`), before startup convergence. Early requests park in the dispatcher. On a gateway the worker uplink - upgrades on the same listener (`onServerCreated`). + upgrades on the same listener (`onServerCreated`), and a lease's input socket upgrades there on both a gateway and a worker. - `tokens` table of `state.db`: `id` (`tok_…`), `hash`, `role`, `label` (NULL when absent), `created_at`. `src/auth` owns it: the dispatcher's `token.*` handlers write it through `Auth.tokens`, and every verify reads the table. The secret is shown once, by `token.create`. diff --git a/src/http/core-routes.test.ts b/src/http/core-routes.test.ts index 4408769dd..94858f6f2 100644 --- a/src/http/core-routes.test.ts +++ b/src/http/core-routes.test.ts @@ -58,7 +58,7 @@ describe("the routes createHttpApp registers", () => { expect(segments.filter((segment) => !CORE_ROUTE_SEGMENTS.includes(segment))).toEqual([]); }); - it("the stream mount counts as covered because its definition carries http, the agent-device mount as left out because it does not, and no undeclared list remains", () => { + it("the stream mount and the input socket count as covered because their definitions carry http, the agent-device mount as left out because it does not, and no undeclared list remains", () => { const mounts = optionalModules.map((definition) => ({ prefix: definition.name, handle: () => Promise.resolve(new Response()), @@ -86,6 +86,7 @@ describe("the routes createHttpApp registers", () => { expect(registered.some((route) => route.path.startsWith("/v1/agent-device"))).toBe(true); expect(coverageProblems(core, declared)).toEqual([]); expect(moduleRoutes).toEqual([ + "GET /v1/leases/{id}/input", "GET /v1/stream/workers/{workerId}/devices/{deviceId}", "GET /v1/leases/{id}/stream", ]); diff --git a/src/http/openapi.test.ts b/src/http/openapi.test.ts index 028ab310d..cbc11b3b3 100644 --- a/src/http/openapi.test.ts +++ b/src/http/openapi.test.ts @@ -395,7 +395,21 @@ describe("buildOpenApiDocument with a module that carries http", () => { (path) => path.startsWith("/v1/agent-device") || path === "/v1/uplink", ), ).toEqual([]); - expect((document.tags ?? []).map((tag) => tag.name)).toEqual(["stream"]); + expect((document.tags ?? []).map((tag) => tag.name)).toEqual(["input", "stream"]); + }); +}); + +describe("buildOpenApiDocument with the input module", () => { + it("an integrator reads GET /v1/leases/{id}/input under input with 101, 401, 404, 426 and 503 and their codes", () => { + const document = buildOpenApiDocument(optionalModules); + const input = document.paths?.["/v1/leases/{id}/input"]?.get; + + expect(input?.tags).toEqual(["input"]); + expect(sortedKeys(input?.responses)).toEqual(["101", "401", "403", "404", "426", "503"]); + expect(errorCodes(input, "401")).toEqual(["UNAUTHENTICATED"]); + expect(errorCodes(input, "404")).toEqual(["UNKNOWN_LEASE"]); + expect(errorCodes(input, "426")).toEqual(["BAD_REQUEST"]); + expect(errorCodes(input, "503")).toEqual(["INPUT_UNAVAILABLE", "WORKER_UNREACHABLE"]); }); }); diff --git a/src/input/AGENTS.md b/src/input/AGENTS.md new file mode 100644 index 000000000..c19e85bed --- /dev/null +++ b/src/input/AGENTS.md @@ -0,0 +1,66 @@ +# Input + +Does: lets a lease's holder (or an operator) send taps, keys and buttons to its device over `WS /v1/leases/{id}/input`, on a worker and through a gateway, in expo serve-sim's messages. +Entry: `index.ts` → `inputModule`, an optional module (ADR 0030, ADR 0049): `worker.ts` `createWorker`, +`gateway.ts` `createGateway`, loaded by the module host only when turned on. +Uses: core (`DeviceInput`, the message types), modules (the context, socket and relay types), ports (`LeaseSocket`). +Never imports: http, gateway, drivers, daemon, leasing, `fs`, `child_process`. Imported by: `module-registry` only. +Off unless `capabilities.input.enabled` (false). Off: nothing loaded, and the `input` capability reads `off`. + +## Files + +| File | Role | +| ----------------- | ---------------------------------------------------------------------------------------------- | +| `definition.ts` | `inputModule`: name `input`, empty config section, `http` route data, the two loaders | +| `worker.ts` | The worker side from the host's context: capability, hooks, lease route, socket, relay | +| `gateway.ts` | The gateway side: lease route, socket, `beforeDrain` | +| `create.ts` | The two factories; the 426 lease route | +| `worker-link.ts` | Worker: finds the lease in the registry; the socket door and the relay door, both to a session | +| `sessions.ts` | Worker: one device input per lease, its sockets, config, failure, lease end, drain | +| `channel.ts` | Worker: a relay channel as a `LeaseSocket`; heartbeat, `0xF1` close | +| `gateway-link.ts` | Gateway: the relay open, refusals, one bridge per socket, lease-gone wait, drain | +| `framing.ts` | `[len u32][tag u8][payload]` frames, `readFrames`, the `0xF0` and `0xF1` tags | +| `refusal.ts` | The 404, 426 and 503 bodies | + +## How it works + +Two doors reach one session. A standalone worker: the HTTP layer resolves the lease for the caller, then +`socket.open` answers an accept and the upgrade router hands it the upgraded socket. Through a gateway the +same `open` on the gateway calls `relayTwoWay` (`input` target) with `/input/leases/` and the worker's +lease id; the worker's `relayTwoWay` finds the lease and attaches a `channelSocket` to the same session. + +1. Worker session (`sessions.ts` `attach`): a lease gone from the registry → close 4001 `lease ended`; a failed + open within 10 s → close 1013; 8 sockets already → close 1013. Else it opens the lease's one device input + (`ctx.inputs.openInput`, shared by every socket) and, once open, sends `0x83` then `0x82`. +2. `0x82` is the device input's `onConfig` (called at once, then on each change) and goes to every admitted socket. + An unavailable device input sends `{"width":1,"height":1,"orientation":"portrait","inputUnavailable":true}` and drops messages. +3. Client messages go through `parseClientMessage` (core): bad JSON, an unknown tag, a position outside 0..1, + `0x07` and anything over 64 KiB are dropped, the socket stays. `0x10` is answered `0x90 {ok:false}`. +4. Ends: the last socket leaves → the device input closes once; `onFailed` → every socket 1013; the + `beforeDeviceReclaim` hook → every socket 4001 with the lease-end reason; `beforeDrain` → every socket 1001. +5. Gateway bridge (`gateway-link.ts`): client frames (≤ 64 KiB) → channel; channel → client except `0xF0`. + `0xF1 {code, reason}` closes the client (1001 from a stopping worker is reported as 1011). A channel that + ends or breaks → 1011 `Worker stopped answering`. `leases.onRemoved` → wait 2 s for that socket's + `0xF1`, then 4001 `lease ended`. `beforeDrain` → 1001 `Simlock is stopping`. +6. Refusals before the upgrade (gateway): `RELAY_TARGET_MISSING` → 503 `INPUT_UNAVAILABLE`; no head in 10 s → + 503 `WORKER_UNREACHABLE`; another coded relay error → 503 with its code; a worker's own refusal is forwarded. + A plain GET to the path is the module's lease route: 426. + +## State and timers + +Memory only: a device input per lease, its sockets, its config, the failed-open memory. + +| What | Default | Key | +| ---------------------------- | ------- | -------------------------------- | +| Sockets per lease | 8 | fixed, `sessions.ts` | +| Failed-open memory | 10 s | fixed, `sessions.ts` | +| Gateway relay open | 10 s | fixed, `gateway-link.ts` | +| Worker heartbeat (`0xF0`) | 5 s | fixed, `channel.ts` | +| Wait for the worker's `0xF1` | 2 s | fixed, `gateway-link.ts` | +| Client ping (ports router) | 15 s | fixed, `ports/upgrade-router.ts` | + +## Rules + +- No module here imports `ws`: a socket is a `LeaseSocket`. Lease ownership is checked by the HTTP layer only. +- A close reason is cut to 123 bytes (`closeReason`, core). +- Each Tests line has one test: `daemon/input-lease.e2e.test.ts` (wiring), `worker.test.ts`, `gateway.test.ts`. diff --git a/src/input/CLAUDE.md b/src/input/CLAUDE.md new file mode 100644 index 000000000..43c994c2d --- /dev/null +++ b/src/input/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/src/input/definition.ts b/src/input/definition.ts index fec6ad34f..718a89174 100644 --- a/src/input/definition.ts +++ b/src/input/definition.ts @@ -9,6 +9,32 @@ export const inputModule: OptionalModuleDefinition = { gatewayKeys: [], validator: objectValidator({}), }, + http: { + routes: [ + { + method: "get", + path: "/v1/leases/{id}/input", + description: + "Send taps, swipes, keys and buttons to a lease's device over a WebSocket, in expo serve-sim's messages: the lease's holder or an operator. Each message is one tag byte and UTF-8 JSON. The server sends 0x83 (input admitted), then 0x82 with the screen's {width, height, orientation} (and inputUnavailable or keyboardUnavailable when the device cannot take that input). The socket closes 4001 with the lease-end reason (explicit, killed, expired or device-lost) when the lease ends, 1011 when the lease's worker stops answering, 1001 when the daemon stops, and 1013 when the device input failed or 8 sockets are already open. A lease the caller does not own answers 404, as an unknown one does. A plain GET without an upgrade answers 426. Served only when capabilities.input.enabled is true on the daemon that answers; through a gateway the lease's worker needs it too.", + statuses: { + 101: { description: "The upgrade to a WebSocket." }, + 404: { + description: "No such lease, or not the caller's.", + errorCodes: ["UNKNOWN_LEASE"], + }, + 426: { + description: "The request is not a WebSocket upgrade.", + errorCodes: ["BAD_REQUEST"], + }, + 503: { + description: + "A gateway's worker does not take input, or does not answer the socket within 10 s.", + errorCodes: ["INPUT_UNAVAILABLE", "WORKER_UNREACHABLE"], + }, + }, + }, + ], + }, loadGateway: () => import("./gateway.js"), loadWorker: () => import("./worker.js"), name: "input", diff --git a/src/modules/AGENTS.md b/src/modules/AGENTS.md index 3c5b7684c..fd888657c 100644 --- a/src/modules/AGENTS.md +++ b/src/modules/AGENTS.md @@ -7,11 +7,11 @@ Imported by: `daemon/main.ts` (the factory and types); on a gateway it is handed ## Files -| File | Role | -| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `types.ts` | `OptionalModuleDefinition` (with optional `http` route data, read only by the OpenAPI document, and optional `localProfile` data), `ModuleTokens`, `ModuleConfig`, `WorkerModule`, `WorkerHooks`, `ModuleLease`, `ModuleLeaseHandler` (a module's lease handler, `leaseRoute` on both sides), `WorkerContext`, the by-shape copies | -| `host.ts` | `createModuleHost`: checks names, loads turned-on modules (`role: "worker"` or `"gateway"`), runs hooks, collects each module's `mount` and `leaseRoute`, and on a worker `prepareLocalProfiles` (below); a gateway host runs `beforeDrain` under the same 30 s deadline | -| `testing.ts` | Fake module and context for tests and the conformance suite | +| File | Role | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `types.ts` | `OptionalModuleDefinition` (with optional `http` route data, read only by the OpenAPI document, and optional `localProfile` data), `ModuleTokens`, `ModuleConfig`, `WorkerModule`, `WorkerHooks`, `ModuleLease`, `ModuleLeaseHandler` (a module's lease handler, `leaseRoute` on both sides), `ModuleLeaseSocketHandler` (`socket` on both sides: `open` answers a refusal `Response` or an accept that gets the socket), `ModuleInputs` (`WorkerContext.inputs`), `WorkerContext`, the by-shape copies | +| `host.ts` | `createModuleHost`: checks names, loads turned-on modules (`role: "worker"` or `"gateway"`), runs hooks, collects each module's `mount` and `leaseRoute`, and on a worker `prepareLocalProfiles` (below); a gateway host runs `beforeDrain` under the same 30 s deadline | +| `testing.ts` | Fake module and context for tests and the conformance suite | ## Rules diff --git a/src/ports/AGENTS.md b/src/ports/AGENTS.md index 273372898..0158bbc35 100644 --- a/src/ports/AGENTS.md +++ b/src/ports/AGENTS.md @@ -1,40 +1,41 @@ # Ports Does: one interface per external API, each with a Node adapter and a fake. -Entry: `index.ts` (barrel). The WebSocket uplink adapters are not in it: `uplink-websocket.ts`, -and `testing.ts` for tests. +Entry: `index.ts` (barrel). The WebSocket adapters are not in it: `uplink-websocket.ts` (the uplink; it also +re-exports `createUpgradeRouter` from `upgrade-router.ts`, the one `upgrade` listener of an HTTP server), and `testing.ts` for tests. Uses: nothing from other components. Imported by: every component that touches the machine. ## Ports -| Port | File | Node adapter | Fake | What it covers | -| ------------------------------------------ | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `Clock` | `clock.ts` | `SystemClock` | `FakeClock` | `now`, `setTimer`, `cancel` | -| `Filesystem` | `filesystem.ts` | `NodeFilesystem` | `MemoryFilesystem` | Read, atomic write, copy, stat, delete, disk free; `mkdirp(path, { mode })` gives every directory it creates, parents included, and none that exists, exactly that mode (chmod past the umask) | -| `ProcessRunner` | `process-runner.ts` | `NodeProcessRunner` | `ScriptedProcessRunner` | Run or stream a command, capture output | -| (wrapper) | `logging-process-runner.ts` | `LoggingProcessRunner` | — | One `process` log line per command, at `debug` only | -| `ProcessSupervisor` | `process-supervisor.ts` | `NodeProcessSupervisor` | `FakeProcessSupervisor` | Is a pid alive; signal it | -| `ParentWatch` | `parent-watch.ts` | `NodeParentWatch` | `FakeParentWatch` | Call once when a pid exits (polls) | -| `DaemonLauncher` | `daemon-launcher.ts` | `NodeDaemonLauncher` | `FakeDaemonLauncher` | Detached daemon, its log (and the data directory, `0700`, when missing), `DAEMON_STARTUP_TIMEOUT_MS`; `launch()` answers a `LaunchedDaemon` (`exited`, `readOutput` of the log since the launch); `startErrorFrom` | -| `NpmCli` | `npm-cli.ts` | `NodeNpmCli` | `FakeNpmCli` | `view`, `install`, `globalRoot`: runs `process.execPath` with the real path of the `npm` beside it, in the daemon's data directory; 30 s for `view` and `globalRoot`, 10 min for `install`, the child killed at the limit; an error is the last 2000 characters of stderr | -| `UpdaterLauncher` | `updater-launcher.ts` | `NodeUpdaterLauncher` | `FakeUpdaterLauncher` | `launch` spawns the built entry of `src/updater/main.ts` detached with the Node that runs it: stdout a pipe (`lines`), stderr appended to the log, `exited` the exit code | -| `ServiceManager` | `service-manager.ts` | `LaunchdServiceManager`, `SystemdServiceManager` (`productionServiceManager(platform)`: `darwin`, `linux`; `undefined` elsewhere) | `FakeServiceManager` | A per-user service for one role: `state` (`not-installed`, `installed`, `not-loaded`, `broken`), `install`, `uninstall`, `start` (kickstart); every `launchctl`/`plutil`/`systemctl --user` call has a 10 s limit; systemd writes `$XDG_CONFIG_HOME/systemd/user/.service` (absolute only, else `~/.config`); `serviceDefinition` builds the name, path, command and saved variables | -| (factory) | `service-launcher.ts` | `launcherFor` | — | The one start choice for the CLI and the MCP server: kickstart a loaded service (`readOutput` is `daemon.log` since the start), else notice and spawn through `NodeDaemonLauncher`; `startTimeoutMessage` adds those lines at the deadline | -| `IpcConnector`, `IpcListenerFactory` | `ipc.ts` | `NodeIpcTransport` | `MemoryIpcTransport` | The daemon's unix socket | -| `UplinkConnector`, `UplinkListenerFactory` | `uplink.ts` | `WebSocketUplinkConnector`, `WebSocketUplinkListenerFactory` | `MemoryUplinkTransport` | A worker's uplink to its gateway | -| `Logger`, `LogSink` | `logger.ts` | `JsonLinesLogger`, `NodeFileLogSink` | `NoopLogger`, `MemoryLogSink` | JSON-line logs, `child(module)`, rotation | -| `SystemStats` | `system-stats.ts` | `NodeSystemStats` | `FakeSystemStats` | CPU count, total RAM | -| `HostInfo` | `host-info.ts` | `NodeHostInfo` | `FakeHostInfo` | OS, version (`sw_vers` on macOS), arch | -| `HostLoadProbe` | `host-load.ts` | `DarwinHostLoadProbe`, `LinuxHostLoadProbe` | `FakeHostLoadProbe` | CPU, memory, disk counters | -| `TcpProbe` | `tcp-probe.ts` | `NodeTcpProbe` | `FakeTcpProbe` | Is a loopback port listening; send bytes | -| `TcpStreamConnector` | `tcp-stream.ts` | `NodeTcpStreamConnector` | `FakeTcpStreamConnector` | A long-lived loopback connection, bytes both ways | -| `BytePipeLauncher` | `byte-pipe.ts` | `NodeBytePipeLauncher` | `FakeBytePipeLauncher` | A child with binary stdout and stdin as its liveness | -| `KeySetFetcher` | `key-set-fetcher.ts` | `NodeKeySetFetcher` | `FakeKeySetFetcher` | One GET of a key-set URL: no redirects, body capped at 64 KiB | -| `SqliteOpener` | `sqlite.ts` | `NodeSqliteOpener` | none; tests use the adapter on a temp file | Open a SQLite file (`:memory:` is not pre-created); `exec`, `prepare`, `close`. `sqlite.ts` also holds `SqliteSchemaOwner` (`name`, `version`, `migrations[i]` takes version `i` to `i + 1`), `passesQuickCheck` and `isLockError` | -| `WebhookPoster` | `webhook-poster.ts` | `FetchWebhookPoster` | `FakeWebhookPoster` (`testing.ts`) | POST to an operator-named URL; a redirect is not followed | -| `IdGenerator` | `id-generator.ts` | `CryptoIdGenerator` | none; tests inline one | Random ids | -| `TokenSecrets` | `token-secrets.ts` | `CryptoTokenSecrets` | none; tests inline one | New secret, its hash | -| Paths | `paths.ts` | `resolveSimlockHome`, `daemonDataDirectory`, `DATA_DIRECTORY_MODE` (`0700`), `resolveDaemonHome`, `resolveDaemonSocketPath` | — | `SIMLOCK_HOME` (test root; default `~/.simlock`), a gateway in `/gateway`, socket path length; `resolveDaemonHome` picks the folder from the flag (`gateway`, `local`) or, with none, the gateway only when `/gateway/{instance,config}.json` exists and `/instance.json` does not | +| Port | File | Node adapter | Fake | What it covers | +| ------------------------------------------ | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `Clock` | `clock.ts` | `SystemClock` | `FakeClock` | `now`, `setTimer`, `cancel` | +| `Filesystem` | `filesystem.ts` | `NodeFilesystem` | `MemoryFilesystem` | Read, atomic write, copy, stat, delete, disk free; `mkdirp(path, { mode })` gives every directory it creates, parents included, and none that exists, exactly that mode (chmod past the umask) | +| `ProcessRunner` | `process-runner.ts` | `NodeProcessRunner` | `ScriptedProcessRunner` | Run or stream a command, capture output | +| (wrapper) | `logging-process-runner.ts` | `LoggingProcessRunner` | — | One `process` log line per command, at `debug` only | +| `ProcessSupervisor` | `process-supervisor.ts` | `NodeProcessSupervisor` | `FakeProcessSupervisor` | Is a pid alive; signal it | +| `ParentWatch` | `parent-watch.ts` | `NodeParentWatch` | `FakeParentWatch` | Call once when a pid exits (polls) | +| `DaemonLauncher` | `daemon-launcher.ts` | `NodeDaemonLauncher` | `FakeDaemonLauncher` | Detached daemon, its log (and the data directory, `0700`, when missing), `DAEMON_STARTUP_TIMEOUT_MS`; `launch()` answers a `LaunchedDaemon` (`exited`, `readOutput` of the log since the launch); `startErrorFrom` | +| `NpmCli` | `npm-cli.ts` | `NodeNpmCli` | `FakeNpmCli` | `view`, `install`, `globalRoot`: runs `process.execPath` with the real path of the `npm` beside it, in the daemon's data directory; 30 s for `view` and `globalRoot`, 10 min for `install`, the child killed at the limit; an error is the last 2000 characters of stderr | +| `UpdaterLauncher` | `updater-launcher.ts` | `NodeUpdaterLauncher` | `FakeUpdaterLauncher` | `launch` spawns the built entry of `src/updater/main.ts` detached with the Node that runs it: stdout a pipe (`lines`), stderr appended to the log, `exited` the exit code | +| `ServiceManager` | `service-manager.ts` | `LaunchdServiceManager`, `SystemdServiceManager` (`productionServiceManager(platform)`: `darwin`, `linux`; `undefined` elsewhere) | `FakeServiceManager` | A per-user service for one role: `state` (`not-installed`, `installed`, `not-loaded`, `broken`), `install`, `uninstall`, `start` (kickstart); every `launchctl`/`plutil`/`systemctl --user` call has a 10 s limit; systemd writes `$XDG_CONFIG_HOME/systemd/user/.service` (absolute only, else `~/.config`); `serviceDefinition` builds the name, path, command and saved variables | +| (factory) | `service-launcher.ts` | `launcherFor` | — | The one start choice for the CLI and the MCP server: kickstart a loaded service (`readOutput` is `daemon.log` since the start), else notice and spawn through `NodeDaemonLauncher`; `startTimeoutMessage` adds those lines at the deadline | +| `IpcConnector`, `IpcListenerFactory` | `ipc.ts` | `NodeIpcTransport` | `MemoryIpcTransport` | The daemon's unix socket | +| `UplinkConnector`, `UplinkListenerFactory` | `uplink.ts` | `WebSocketUplinkConnector`, `WebSocketUplinkListenerFactory` | `MemoryUplinkTransport` | A worker's uplink to its gateway | +| `LeaseSocket` | `lease-socket.ts` | the upgrade router (`upgrade-router.ts`): `/v1/uplink` to the uplink, `/v1/leases/{id}/` to the app's lease socket resolution, else 404; a ping every 15 s, no pong ends the socket | `MemoryLeaseSocket` | A module's end of a lease WebSocket: `send`, `close(code, reason)`, `onMessage`, `onClose` | +| `Logger`, `LogSink` | `logger.ts` | `JsonLinesLogger`, `NodeFileLogSink` | `NoopLogger`, `MemoryLogSink` | JSON-line logs, `child(module)`, rotation | +| `SystemStats` | `system-stats.ts` | `NodeSystemStats` | `FakeSystemStats` | CPU count, total RAM | +| `HostInfo` | `host-info.ts` | `NodeHostInfo` | `FakeHostInfo` | OS, version (`sw_vers` on macOS), arch | +| `HostLoadProbe` | `host-load.ts` | `DarwinHostLoadProbe`, `LinuxHostLoadProbe` | `FakeHostLoadProbe` | CPU, memory, disk counters | +| `TcpProbe` | `tcp-probe.ts` | `NodeTcpProbe` | `FakeTcpProbe` | Is a loopback port listening; send bytes | +| `TcpStreamConnector` | `tcp-stream.ts` | `NodeTcpStreamConnector` | `FakeTcpStreamConnector` | A long-lived loopback connection, bytes both ways | +| `BytePipeLauncher` | `byte-pipe.ts` | `NodeBytePipeLauncher` | `FakeBytePipeLauncher` | A child with binary stdout and stdin as its liveness | +| `KeySetFetcher` | `key-set-fetcher.ts` | `NodeKeySetFetcher` | `FakeKeySetFetcher` | One GET of a key-set URL: no redirects, body capped at 64 KiB | +| `SqliteOpener` | `sqlite.ts` | `NodeSqliteOpener` | none; tests use the adapter on a temp file | Open a SQLite file (`:memory:` is not pre-created); `exec`, `prepare`, `close`. `sqlite.ts` also holds `SqliteSchemaOwner` (`name`, `version`, `migrations[i]` takes version `i` to `i + 1`), `passesQuickCheck` and `isLockError` | +| `WebhookPoster` | `webhook-poster.ts` | `FetchWebhookPoster` | `FakeWebhookPoster` (`testing.ts`) | POST to an operator-named URL; a redirect is not followed | +| `IdGenerator` | `id-generator.ts` | `CryptoIdGenerator` | none; tests inline one | Random ids | +| `TokenSecrets` | `token-secrets.ts` | `CryptoTokenSecrets` | none; tests inline one | New secret, its hash | +| Paths | `paths.ts` | `resolveSimlockHome`, `daemonDataDirectory`, `DATA_DIRECTORY_MODE` (`0700`), `resolveDaemonHome`, `resolveDaemonSocketPath` | — | `SIMLOCK_HOME` (test root; default `~/.simlock`), a gateway in `/gateway`, socket path length; `resolveDaemonHome` picks the folder from the flag (`gateway`, `local`) or, with none, the gateway only when `/gateway/{instance,config}.json` exists and `/instance.json` does not | `productionHostLoadProbe` picks the Darwin or Linux probe. `NodeFileLogSink` rotates to `.1` and keeps that one generation; the files it opens, and the log `NodeDaemonLauncher` @@ -50,7 +51,7 @@ loaded with a dynamic `import()` on first open (Node 22.13 or later; below it `o `fs` or `child_process` import (`no-fs-or-process` in `.dependency-cruiser.mjs`). - The real adapters are built once, in the composition root (`src/daemon/main.ts`, or the CLI and MCP environment builders), and injected. -- Only `uplink-websocket.ts` imports `ws`, so the CLI and MCP never load it. +- Only `uplink-websocket.ts` and `upgrade-router.ts` import `ws`, so the CLI and MCP never load it. - A port is as small as its callers need. What the bytes mean is the caller's business (`TcpProbe.send` knows nothing of adb). diff --git a/website/src/content/docs/docs/gateway/access-control.mdx b/website/src/content/docs/docs/gateway/access-control.mdx index b007af42a..543a48b54 100644 --- a/website/src/content/docs/docs/gateway/access-control.mdx +++ b/website/src/content/docs/docs/gateway/access-control.mdx @@ -13,7 +13,7 @@ This page uses the gateway in its examples. Tokens work the same way on a worker | Role | Made for | May | | --- | --- | --- | -| `agent` | A coding agent that leases devices over HTTP. | Read the catalog and status (only its own leases and waiting requests), list installed runtimes, request, renew and release its own leases, cancel its own waiting requests, run commands on the devices it leases, and watch the screen of its own leases (see [Watching the screen](/simlock/docs/using-devices/watching-the-screen#stream-from-your-own-tool)). | +| `agent` | A coding agent that leases devices over HTTP. | Read the catalog and status (only its own leases and waiting requests), list installed runtimes, request, renew and release its own leases, cancel its own waiting requests, run commands on the devices it leases, watch the screen of its own leases (see [Watching the screen](/simlock/docs/using-devices/watching-the-screen#stream-from-your-own-tool)), and open the input socket of its own leases (see [Watching the screen](/simlock/docs/using-devices/watching-the-screen#send-input-to-your-own-lease)). | | `operator` | A person, or a script that manages the fleet. | Everything an agent may, for every requester's leases, plus the console, events, usage figures, tokens, worker commands and component installs. | | `worker` | A worker machine joining a gateway. | Open one worker's connection to the gateway, and nothing else. The first worker that joins with it is paired with it; another worker with the same token, and any other request with it, is refused with `403`. | diff --git a/website/src/content/docs/docs/using-devices/watching-the-screen.mdx b/website/src/content/docs/docs/using-devices/watching-the-screen.mdx index 2580dd580..54d1ded84 100644 --- a/website/src/content/docs/docs/using-devices/watching-the-screen.mdx +++ b/website/src/content/docs/docs/using-devices/watching-the-screen.mdx @@ -5,7 +5,7 @@ description: Watch a leased simulator, emulator or physical device live in the c import { Steps, Aside } from '@astrojs/starlight/components'; -The console can show the live screen of any running device, leased or not: an iOS simulator, an Android emulator, a physical Android phone, or a physical iPhone. You watch; you cannot tap or type. A coding agent can also watch its own lease's screen over HTTP (see [Stream from your own tool](#stream-from-your-own-tool)). Nothing is captured until someone opens a device's **Screen** tab, and the capture stops when they leave the tab, close the drawer, or the device stops running. +The console can show the live screen of any running device, leased or not: an iOS simulator, an Android emulator, a physical Android phone, or a physical iPhone. You watch; you cannot tap or type in the console. A coding agent can also watch its own lease's screen over HTTP (see [Stream from your own tool](#stream-from-your-own-tool)), and open an input socket for it (see [Send input to your own lease](#send-input-to-your-own-lease)). Nothing is captured until someone opens a device's **Screen** tab, and the capture stops when they leave the tab, close the drawer, or the device stops running. ## Before you start @@ -106,6 +106,25 @@ A lease's holder can read its own lease's screen at `GET /v1/leases//s The body is Simlock's own framing of H.264 frames, made for the console's player, and it runs until you close the request. When a lease end stops a stream, its last frame has tag `0x07` and holds JSON like `{"reason":"explicit"}`, then the body ends. The reason is `explicit` (released), `expired`, `killed` (ended by an operator) or `device-lost`. Any other end sends no such frame. Most users watch in the console instead. +## Send input to your own lease + +A lease's holder can open a WebSocket for its lease at `GET /v1/leases//input` (with an `Upgrade: websocket` request), on a worker or on a gateway, with its agent token in an `Authorization: Bearer` header. An operator token can open any lease's. Turn it on with `capabilities.input.enabled` (off by default; see [Configuration reference](/simlock/docs/reference/configuration)); through a gateway, the gateway and the lease's worker both need it on. + +The messages are expo serve-sim's: each is one tag byte and UTF-8 JSON, so a client that already drives serve-sim (T3 Code's device panel) needs nothing Simlock-specific. The server sends `0x83` (input is accepted from now on), then `0x82` with the screen as it is now, `{"width":1179,"height":2556,"orientation":"portrait"}`, and again whenever the size changes. A device that takes no input gets `0x82` `{"width":1,"height":1,"orientation":"portrait","inputUnavailable":true}`, and the socket stays open. At this release no device takes input, so every socket gets that answer. + +The client sends `0x03` touch (`{"type":"begin","x":0.4,"y":0.7}`, `type` is `begin`, `move` or `end`, positions are fractions from 0 to 1), `0x04` button (`home`, `app_switcher`, `lock` or `back`), `0x06` key (`{"type":"down","usage":4}`, a USB HID usage from 0 to 255) and `0x0b` scroll. A message that is not valid JSON, has an unknown tag, a position outside 0 to 1, or is over 64 KiB is dropped and the socket stays open. `0x07` (orientation) is always dropped. `0x10` is answered `0x90` `{"requestId":7,"ok":false}`. + +Up to 8 sockets share one lease's device input. The socket closes with: + +| Code | Reason | When | +| --- | --- | --- | +| 4001 | `explicit`, `killed`, `expired` or `device-lost` | The lease ended. Through a gateway, `lease ended` if the worker did not say why within 2 seconds. | +| 1011 | `Worker stopped answering` | Through a gateway, the worker stopped answering for 15 seconds or its uplink dropped. | +| 1001 | `Simlock is stopping` | The daemon is stopping. | +| 1013 | `Input is unavailable for this lease` | A 9th socket, a device input that failed, or a reconnect within 10 seconds of a failed open. | + +A socket that answers no ping within 15 seconds is closed. A lease that is not the caller's, or does not exist, answers 404 `UNKNOWN_LEASE`, no token answers 401, and a plain `GET` without an upgrade answers 426. With input off on the daemon that answers, the path is a plain 404. Through a gateway, a worker with input off answers 503 `INPUT_UNAVAILABLE` ("Worker w1 does not take input for lease L-42; turn on capabilities.input.enabled there"), and a worker that gives no answer within 10 seconds answers 503 `WORKER_UNREACHABLE`. + ## When it does not work | What you see | Why | What to do | From 1854aaafbd42d458cb199b8acec41e90f4e884ba Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 26 Oct 2026 19:58:01 +0100 Subject: [PATCH 22/28] refactor(input): keep the input surface to what a caller reads, and test the catalog's openInput (#1017) --- src/core/fake-device-input.ts | 2 +- src/core/index.ts | 4 +--- src/core/input.test.ts | 25 +++++++++++++++++++++++++ src/core/input.ts | 2 +- src/core/testing.ts | 2 +- src/ports/uplink-websocket.ts | 2 +- 6 files changed, 30 insertions(+), 7 deletions(-) create mode 100644 src/core/input.test.ts diff --git a/src/core/fake-device-input.ts b/src/core/fake-device-input.ts index 774f464ab..24849529a 100644 --- a/src/core/fake-device-input.ts +++ b/src/core/fake-device-input.ts @@ -7,7 +7,7 @@ import type { } from "./input.js"; // why: a device input for tests: it records what reached it and plays the device's changes. -export class FakeDeviceInput implements DeviceInput { +class FakeDeviceInput implements DeviceInput { readonly sent: InputMessage[] = []; closes = 0; readonly #configListeners = new Set<(config: InputScreenConfig) => void>(); diff --git a/src/core/index.ts b/src/core/index.ts index b5686d022..db40c9af4 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -105,15 +105,13 @@ export { export { closeReason, type DeviceInput, + // fallow-ignore-next-line unused-type -- the port the Android and iOS drivers implement (#1018, #1019); no in-tree driver names it yet. type DeviceInputFactory, encodeInputMessage, INPUT_TAG, MAX_INPUT_MESSAGE_BYTES, isUnavailableInput, parseClientMessage, - type InputControlRequest, - type InputMessage, - type InputOrientation, type InputScreenConfig, type UnavailableDeviceInput, UNAVAILABLE_INPUT_CONFIG, diff --git a/src/core/input.test.ts b/src/core/input.test.ts new file mode 100644 index 000000000..2533c85a4 --- /dev/null +++ b/src/core/input.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; + +import { FakeClock } from "../ports/index.js"; +import { DriverCatalog } from "./index.js"; +import { FakeDriver, FakeInputFactory } from "./testing.js"; + +const device = { address: "a", deviceId: "d", driverData: {}, physical: false } as const; +const screen = { height: 2, orientation: "portrait", width: 1 } as const; + +describe("DriverCatalog input", () => { + it("opens a device input through the driver of the device's platform, and says a platform whose driver has none takes no input", async () => { + const factory = new FakeInputFactory(screen); + const catalog = new DriverCatalog([ + new FakeDriver({ clock: new FakeClock(), input: factory, platform: "ios" }), + new FakeDriver({ clock: new FakeClock(), platform: "android" }), + ]); + const signal = new AbortController().signal; + + const opened = await catalog.openInput("ios", device, signal); + expect(opened).toBe(factory.opened[0]); + expect(await catalog.openInput("android", device, signal)).toEqual({ + unavailable: "android devices take no input", + }); + }); +}); diff --git a/src/core/input.ts b/src/core/input.ts index ad9dc3dd6..cc8eafdb0 100644 --- a/src/core/input.ts +++ b/src/core/input.ts @@ -29,7 +29,7 @@ export function closeReason(reason: string): string { return cut; } -export const INPUT_BUTTONS = ["home", "app_switcher", "lock", "back"] as const; +const INPUT_BUTTONS = ["home", "app_switcher", "lock", "back"] as const; export type InputOrientation = | "portrait" diff --git a/src/core/testing.ts b/src/core/testing.ts index 7e31de1da..bcb69dc8b 100644 --- a/src/core/testing.ts +++ b/src/core/testing.ts @@ -32,7 +32,7 @@ export { type ResourceStrategyOptions, } from "./capacity/index.js"; export { readyTransitionUpdate } from "./driver.js"; -export { FakeDeviceInput, FakeInputFactory } from "./fake-device-input.js"; +export { FakeInputFactory } from "./fake-device-input.js"; export { FakeDriver, fakeDeviceDataTable } from "./fake-driver.js"; export type { FakeDriverOptions } from "./fake-driver.js"; export { QuarantineCoordinator } from "./quarantine-coordinator.js"; diff --git a/src/ports/uplink-websocket.ts b/src/ports/uplink-websocket.ts index 7e2c821dc..7b90c58fe 100644 --- a/src/ports/uplink-websocket.ts +++ b/src/ports/uplink-websocket.ts @@ -38,7 +38,7 @@ import { } from "./uplink.js"; // why: the daemon entry imports the router through this file, the one `ws` file it is already allowed to reach into. -export { createUpgradeRouter, type LeaseSocketOutcome } from "./upgrade-router.js"; +export { createUpgradeRouter } from "./upgrade-router.js"; /** why: a ping that finds no pong since the previous one terminates the link, so a gateway that * vanished without closing is noticed within two of these. */ From c39c658f7fc2b799d1a5246ee8001acb380d8cd1 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 26 Oct 2026 20:02:41 +0100 Subject: [PATCH 23/28] feat(http): a proxy-signed identity needs Sec-Fetch-Site same-origin to open a lease socket (#1017) --- src/http/AGENTS.md | 3 +- src/http/app.test.ts | 38 +++++++++++++++++++ src/http/auth.ts | 5 ++- .../docs/docs/gateway/access-control.mdx | 2 +- 4 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/http/AGENTS.md b/src/http/AGENTS.md index ed465f574..25bfb7b6f 100644 --- a/src/http/AGENTS.md +++ b/src/http/AGENTS.md @@ -41,7 +41,8 @@ Surface: [remote-agents.mdx](../../website/src/content/docs/docs/using-devices/r headers: a bearer token, or the JWT the sign-in proxy adds when `http.proxyAuth` is set. Neither valid → 401 `UNAUTHENTICATED`. A `worker` join token → 403 on every route. A proxy-signed (`ambient`) identity on any method but GET, HEAD or OPTIONS needs - `Sec-Fetch-Site: same-origin` exactly, else 403 `FORBIDDEN` (mounts included). Tokens are exempt. + `Sec-Fetch-Site: same-origin` exactly, else 403 `FORBIDDEN` (mounts included); so does a WebSocket upgrade + (`Upgrade: websocket`, a lease's input socket). Tokens are exempt. 4. Input — a declared route's query, path and JSON body schemas run before the handler; a failure is 400 `BAD_REQUEST` (`validationHook`). A JSON body without a JSON content type is 415 `UNSUPPORTED_MEDIA_TYPE`, malformed JSON 400 `Invalid JSON body` (`mapError` reads the diff --git a/src/http/app.test.ts b/src/http/app.test.ts index 71a936005..fda78c736 100644 --- a/src/http/app.test.ts +++ b/src/http/app.test.ts @@ -61,6 +61,7 @@ function buildHarness( readonly identities?: Readonly>; readonly auth?: Auth; readonly mounts?: HttpGatewayDeps["mounts"]; + readonly leaseRoutes?: HttpGatewayDeps["leaseRoutes"]; } = {}, ) { const clock = new FakeClock(1_000); @@ -118,6 +119,7 @@ function buildHarness( registry: overrides.registry ?? registry, auth, ...(overrides.mounts === undefined ? {} : { mounts: overrides.mounts }), + ...(overrides.leaseRoutes === undefined ? {} : { leaseRoutes: overrides.leaseRoutes }), ...(overrides.answerDownloadsEarly === undefined ? {} : { answerDownloadsEarly: overrides.answerDownloadsEarly }), @@ -3202,6 +3204,42 @@ describe("a person the sign-in proxy signed in", () => { }); }); + describe("a lease socket upgrade", () => { + const sites: ReadonlyArray = [ + ["same-origin", 204], + ["cross-site", 403], + ["same-site", 403], + [undefined, 403], + ]; + + it.each(sites)("with Sec-Fetch-Site %s answers %s", async (site, status) => { + const accept = () => undefined; + const { app, registry } = buildHarness({ + identities: { slk_proxy: proxyPerson(signedInAt + 120_000) }, + leaseRoutes: [ + { + handle: () => Promise.resolve(new Response(null, { status: 426 })), + name: "input", + socket: { open: () => Promise.resolve(accept) }, + }, + ], + }); + registry.devices = [makeDevice({ id: "dev_1" })]; + registry.leases = [makeLease({ deviceId: "dev_1", id: "lse_1", ownerId: "tok_other" })]; + + const outcome = await app.leaseSocket( + new Request("http://gw/v1/leases/lse_1/input", { + headers: { ...withSite(site), upgrade: "websocket" }, + }), + ); + + expect("accept" in outcome ? 204 : outcome.response.status).toBe(status); + expect("accept" in outcome ? undefined : await outcome.response.json()).toEqual( + status === 204 ? undefined : CSRF_BODY, + ); + }); + }); + describe("to a mount", () => { function mountedHarness() { const seen: string[] = []; diff --git a/src/http/auth.ts b/src/http/auth.ts index 3571062c8..25e87f56c 100644 --- a/src/http/auth.ts +++ b/src/http/auth.ts @@ -32,7 +32,10 @@ export function requireAuth(auth: Pick): Middleware const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); function refuseCrossSiteChange(c: Context): void { - if (!c.get("identity").ambient || SAFE_METHODS.has(c.req.method)) return; + if (!c.get("identity").ambient) return; + // why: a WebSocket upgrade is a GET a browser opens cross-site with the person's cookies, so it needs the header like a change. + const upgrade = c.req.header("upgrade")?.toLowerCase() === "websocket"; + if (SAFE_METHODS.has(c.req.method) && !upgrade) return; if (c.req.header("sec-fetch-site") === "same-origin") return; throw new HttpApiError( 403, diff --git a/website/src/content/docs/docs/gateway/access-control.mdx b/website/src/content/docs/docs/gateway/access-control.mdx index 543a48b54..e620f4f25 100644 --- a/website/src/content/docs/docs/gateway/access-control.mdx +++ b/website/src/content/docs/docs/gateway/access-control.mdx @@ -118,7 +118,7 @@ Your proxy must also: - **Let the worker connections through without a sign-in.** Workers connect to `/v1/uplink` with their join token, and carry no proxy JWT. - **Let `/v1/healthz` through**, if anything checks the gateway's health. - **Let agents through**, by whatever means your proxy offers, such as a bypass rule or service credentials. Agents send their Simlock token, which Simlock checks as usual. When a request carries both a Simlock token and a JWT, the token wins. -- **Serve the console over HTTPS.** A browser marks its requests as coming from the console only over `https://` or on the local machine. Without that mark, Simlock refuses every request that changes something and is signed in through the proxy. +- **Serve the console over HTTPS.** A browser marks its requests as coming from the console only over `https://` or on the local machine. Without that mark, Simlock refuses every request that changes something, and every screen-input socket, signed in through the proxy. An operator token pasted into the console still works behind the proxy. From a3307da589c68628644c29bf1f3c0c903f8dbdb7 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 26 Oct 2026 20:16:59 +0100 Subject: [PATCH 24/28] test(input): probe-driven fixes: ambient upgrade proved end to end, boundaries straddled, unavailable inputs forgotten (#1017) --- ...d-way-to-sign-in-and-auth-is-one-module.md | 4 +- src/core/input.test.ts | 25 ----- src/daemon/input-lease.e2e.test.ts | 95 +++++++++++++++++-- src/http/app.test.ts | 38 -------- src/input/gateway.test.ts | 17 +++- src/input/sessions.ts | 9 +- src/input/worker.test.ts | 14 ++- 7 files changed, 122 insertions(+), 80 deletions(-) delete mode 100644 src/core/input.test.ts diff --git a/docs/internal/adr/0033-a-sign-in-proxy-is-a-second-way-to-sign-in-and-auth-is-one-module.md b/docs/internal/adr/0033-a-sign-in-proxy-is-a-second-way-to-sign-in-and-auth-is-one-module.md index c37e44ff5..208b4d332 100644 --- a/docs/internal/adr/0033-a-sign-in-proxy-is-a-second-way-to-sign-in-and-auth-is-one-module.md +++ b/docs/internal/adr/0033-a-sign-in-proxy-is-a-second-way-to-sign-in-and-auth-is-one-module.md @@ -187,7 +187,9 @@ by one. A request whose identity is `ambient`, with a method other than `GET`, `HEAD` or `OPTIONS`, must carry `Sec-Fetch-Site: same-origin`. Any other -value, or no header, is `403 FORBIDDEN`. A bearer token is not ambient, so +value, or no header, is `403 FORBIDDEN`. A WebSocket upgrade (a `GET` with +`Upgrade: websocket`) is held to the same rule, since a page on another site +can open one with the person's cookies. A bearer token is not ambient, so agents and scripts are not affected. Browsers send the header only to an `https://` address or to loopback, so behind a proxy served over plain HTTP on a network name every such request is refused; the console sends none diff --git a/src/core/input.test.ts b/src/core/input.test.ts deleted file mode 100644 index 2533c85a4..000000000 --- a/src/core/input.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { FakeClock } from "../ports/index.js"; -import { DriverCatalog } from "./index.js"; -import { FakeDriver, FakeInputFactory } from "./testing.js"; - -const device = { address: "a", deviceId: "d", driverData: {}, physical: false } as const; -const screen = { height: 2, orientation: "portrait", width: 1 } as const; - -describe("DriverCatalog input", () => { - it("opens a device input through the driver of the device's platform, and says a platform whose driver has none takes no input", async () => { - const factory = new FakeInputFactory(screen); - const catalog = new DriverCatalog([ - new FakeDriver({ clock: new FakeClock(), input: factory, platform: "ios" }), - new FakeDriver({ clock: new FakeClock(), platform: "android" }), - ]); - const signal = new AbortController().signal; - - const opened = await catalog.openInput("ios", device, signal); - expect(opened).toBe(factory.opened[0]); - expect(await catalog.openInput("android", device, signal)).toEqual({ - unavailable: "android devices take no input", - }); - }); -}); diff --git a/src/daemon/input-lease.e2e.test.ts b/src/daemon/input-lease.e2e.test.ts index 818a8dcb5..54986a25f 100644 --- a/src/daemon/input-lease.e2e.test.ts +++ b/src/daemon/input-lease.e2e.test.ts @@ -1,11 +1,13 @@ import { mkdtemp, rm } from "node:fs/promises"; -import { createServer } from "node:net"; +import { createServer as createHttpServer, type Server } from "node:http"; +import { createServer, type AddressInfo } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { WebSocket } from "ws"; +import { createTestKey, keySetBody, type TestKey } from "../auth/testing.js"; import { FakeDriver, FakeInputFactory, fakeDeviceDataTables } from "../core/testing.js"; import { FakeClock, MemoryFilesystem, NoopLogger, SystemClock } from "../ports/index.js"; import type { DispatchSession } from "./dispatch.js"; @@ -68,11 +70,14 @@ interface OpenedSocket { async function openInput( url: string, token: string | undefined, - options: { readonly autoPong?: boolean } = {}, + options: { readonly autoPong?: boolean; readonly headers?: Record } = {}, ): Promise { const socket = new WebSocket(url, { autoPong: options.autoPong ?? true, - headers: token === undefined ? {} : { authorization: `Bearer ${token}` }, + headers: { + ...(token === undefined ? {} : { authorization: `Bearer ${token}` }), + ...options.headers, + }, }); sockets.push(socket); const messages: Buffer[] = []; @@ -117,8 +122,18 @@ const sockets: WebSocket[] = []; describe("a lease's input socket", () => { const daemons: DaemonServer[] = []; const directories: string[] = []; + const keyServers: Server[] = []; afterEach(async () => { + await Promise.all( + keyServers.splice(0).map( + (server) => + new Promise((resolve) => { + server.closeAllConnections(); + server.close(() => resolve()); + }), + ), + ); for (const socket of sockets.splice(0)) socket.terminate(); await Promise.all(daemons.splice(0).map((daemon) => daemon.stop("test"))); await Promise.all( @@ -220,13 +235,30 @@ describe("a lease's input socket", () => { }; } - async function standalone(clock?: FakeClock) { + async function keyServer(key: TestKey): Promise { + const server = createHttpServer((_request, response) => { + response.writeHead(200, { "content-type": "application/json" }).end(keySetBody(key)); + }); + keyServers.push(server); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + return `http://127.0.0.1:${String((server.address() as AddressInfo).port)}/keys`; + } + + async function standalone( + options: { readonly clock?: FakeClock; readonly proxyAuth?: Record } = {}, + ) { + const { clock, proxyAuth } = options; const port = await freeLoopbackPort(); const worker = await startDaemon({ ...(clock === undefined ? {} : { clock }), configOverrides: { - capabilities: { input: { enabled: true } }, - http: { enabled: true, host: "127.0.0.1", port }, + capabilities: { input: { enabled: true }, stream: { enabled: true } }, + http: { + enabled: true, + host: "127.0.0.1", + port, + ...(proxyAuth === undefined ? {} : { proxyAuth }), + }, }, dataDirectory: await directory("standalone"), deviceDataTables: fakeDeviceDataTables, @@ -291,6 +323,42 @@ describe("a lease's input socket", () => { await expectAdmitted(direct.inputUrl(), direct.operator); await expectRefused(direct.inputUrl(), direct.other, unknown); await expectRefused(direct.inputUrl(), undefined, { code: "UNAUTHENTICATED", status: 401 }); + // why: the stream's lease route exists here, and only the input route takes a socket. + await expectRefused(direct.inputUrl().replace(/input$/, "stream"), direct.holder, { + code: undefined, + status: 404, + }); + + const key = await createTestKey("ES256", "k1"); + const proxied = await standalone({ + proxyAuth: { + audience: "aud", + header: "cf-access-jwt-assertion", + issuer: "https://team.example", + jwksUrl: await keyServer(key), + }, + }); + const jwt = await key.sign({ + aud: "aud", + email: "alice@team.dev", + exp: Math.floor(Date.now() / 1000) + 300, + iss: "https://team.example", + sub: "7f3a", + }); + const signedIn = { "cf-access-jwt-assertion": jwt }; + for (const site of [undefined, "cross-site", "same-site"]) { + const refused = await openInput(proxied.inputUrl(), undefined, { + headers: { ...signedIn, ...(site === undefined ? {} : { "sec-fetch-site": site }) }, + }); + expect({ code: refused.code, status: refused.status }).toEqual({ + code: "FORBIDDEN", + status: 403, + }); + } + const sameOrigin = await openInput(proxied.inputUrl(), undefined, { + headers: { ...signedIn, "sec-fetch-site": "same-origin" }, + }); + expect(sameOrigin.status).toBe(101); const gatewayOff = await fleet({ gatewayInput: false, workerInput: true }); await expectRefused(gatewayOff.inputUrl(), gatewayOff.holder, { @@ -388,14 +456,23 @@ describe("a lease's input socket", () => { { timeout: 60_000 }, async () => { const clock = new FakeClock(); - const direct = await standalone(clock); + const direct = await standalone({ clock }); const quiet = await openInput(direct.inputUrl(), direct.holder, { autoPong: false }); const alive = await openInput(direct.inputUrl(), direct.holder); await vi.waitFor(() => expect(tags(alive)).toEqual([0x83, 0x82]), { timeout: 10_000 }); - clock.advance(15_000); + let pings = 0; + quiet.socket.on("ping", () => { + pings += 1; + }); + clock.advance(14_999); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(pings).toBe(0); + clock.advance(1); + await vi.waitFor(() => expect(pings).toBe(1), { timeout: 10_000 }); + clock.advance(14_999); await new Promise((resolve) => setTimeout(resolve, 100)); expect(quiet.closed).toBeUndefined(); - clock.advance(15_000); + clock.advance(1); await vi.waitFor(() => expect(quiet.closed?.code).toBe(1006), { timeout: 10_000 }); expect(alive.closed).toBeUndefined(); diff --git a/src/http/app.test.ts b/src/http/app.test.ts index fda78c736..71a936005 100644 --- a/src/http/app.test.ts +++ b/src/http/app.test.ts @@ -61,7 +61,6 @@ function buildHarness( readonly identities?: Readonly>; readonly auth?: Auth; readonly mounts?: HttpGatewayDeps["mounts"]; - readonly leaseRoutes?: HttpGatewayDeps["leaseRoutes"]; } = {}, ) { const clock = new FakeClock(1_000); @@ -119,7 +118,6 @@ function buildHarness( registry: overrides.registry ?? registry, auth, ...(overrides.mounts === undefined ? {} : { mounts: overrides.mounts }), - ...(overrides.leaseRoutes === undefined ? {} : { leaseRoutes: overrides.leaseRoutes }), ...(overrides.answerDownloadsEarly === undefined ? {} : { answerDownloadsEarly: overrides.answerDownloadsEarly }), @@ -3204,42 +3202,6 @@ describe("a person the sign-in proxy signed in", () => { }); }); - describe("a lease socket upgrade", () => { - const sites: ReadonlyArray = [ - ["same-origin", 204], - ["cross-site", 403], - ["same-site", 403], - [undefined, 403], - ]; - - it.each(sites)("with Sec-Fetch-Site %s answers %s", async (site, status) => { - const accept = () => undefined; - const { app, registry } = buildHarness({ - identities: { slk_proxy: proxyPerson(signedInAt + 120_000) }, - leaseRoutes: [ - { - handle: () => Promise.resolve(new Response(null, { status: 426 })), - name: "input", - socket: { open: () => Promise.resolve(accept) }, - }, - ], - }); - registry.devices = [makeDevice({ id: "dev_1" })]; - registry.leases = [makeLease({ deviceId: "dev_1", id: "lse_1", ownerId: "tok_other" })]; - - const outcome = await app.leaseSocket( - new Request("http://gw/v1/leases/lse_1/input", { - headers: { ...withSite(site), upgrade: "websocket" }, - }), - ); - - expect("accept" in outcome ? 204 : outcome.response.status).toBe(status); - expect("accept" in outcome ? undefined : await outcome.response.json()).toEqual( - status === 204 ? undefined : CSRF_BODY, - ); - }); - }); - describe("to a mount", () => { function mountedHarness() { const seen: string[] = []; diff --git a/src/input/gateway.test.ts b/src/input/gateway.test.ts index aa83f9045..78430e889 100644 --- a/src/input/gateway.test.ts +++ b/src/input/gateway.test.ts @@ -80,7 +80,7 @@ describe("a gateway's input socket", () => { }); clock.advance(9_999); - await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 20)); expect(outcome).toBeUndefined(); clock.advance(1); @@ -108,6 +108,21 @@ describe("a gateway's input socket", () => { expect(ended.client.closed).toEqual({ code: 1011, reason: "Worker w1 stopped answering" }), ); + for (const [code, reason] of [ + [1001, "Simlock is stopping"], + [1000, "done"], + [5000, "out of range"], + ] as const) { + const closing = await attached("w1"); + await closing.worker.write(closeFrame(code, reason)); + await vi.waitFor(() => + expect(closing.client.closed).toEqual({ + code: 1011, + reason: "Worker w1 stopped answering", + }), + ); + } + const longId = "w".repeat(128); const long = await attached(longId); await long.worker.abort(new Error("the uplink dropped")); diff --git a/src/input/sessions.ts b/src/input/sessions.ts index 5c162b10f..a4464af11 100644 --- a/src/input/sessions.ts +++ b/src/input/sessions.ts @@ -60,18 +60,25 @@ export function createInputSessions(options: { void opened.close(); } + // why: an unavailable device input has nothing to close, so its entry is dropped at once instead of staying for the daemon's lifetime. + function forget(leaseId: string, entry: LeaseInput): void { + if (leases.get(leaseId) === entry) leases.delete(leaseId); + } + function releaseWhenOpen(leaseId: string, entry: LeaseInput): void { if (entry.device !== undefined) { release(leaseId, entry, entry.device); return; } void entry.opening.then( - (opened) => (isUnavailableInput(opened) ? undefined : release(leaseId, entry, opened)), + (opened) => + isUnavailableInput(opened) ? forget(leaseId, entry) : release(leaseId, entry, opened), () => undefined, ); } function end(leaseId: string, reason: string): void { + failedOpens.delete(leaseId); const entry = leases.get(leaseId); if (entry === undefined) return; for (const attached of entry.sockets) attached.socket.close(CLOSE_LEASE_ENDED, reason); diff --git a/src/input/worker.test.ts b/src/input/worker.test.ts index 81c8ab1e3..39318f3e0 100644 --- a/src/input/worker.test.ts +++ b/src/input/worker.test.ts @@ -99,16 +99,19 @@ describe("a worker's input socket", () => { message(0x03, null), message(0x04, { button: "volume_up" }), message(0x06, { type: "down", usage: 300 }), - Uint8Array.from([ - 0x03, - ...new TextEncoder().encode(`{"x":0.5,"pad":"${"a".repeat(65_536)}"}`), - ]), + message(0x06, { type: "down", usage: -1 }), + message(0x06, { type: "up", usage: 4.5 }), + message(0x0b, { dx: 1.5, dy: 0 }), + message(0x0b, { dx: 0, dy: -1.5 }), + message(0x0b, { dx: 0, dy: 0, x: 2, y: 0.5 }), + message(0x03, { pad: "a".repeat(65_536), type: "end", x: 0.5, y: 0.5 }), ]; + const sentBefore = socket.sent.length; for (const refused of dropped) socket.receive(refused); expect(device?.sent).toHaveLength(valid.length); expect(socket.closed).toBeUndefined(); + expect(socket.sent).toHaveLength(sentBefore); - const sentBefore = socket.sent.length; socket.receive(message(0x10, { command: "set-pose", requestId: 7 })); expect(socket.sent).toHaveLength(sentBefore + 1); const answer = socket.sent.at(-1); @@ -219,6 +222,7 @@ describe("a worker's input socket", () => { await vi.waitFor(() => expect(written.slice(0, 5)).toEqual([0, 0, 0, 1, 0x83])); clock.advance(4_999); + await new Promise((resolve) => setTimeout(resolve, 20)); expect(heartbeats()).toBe(0); clock.advance(1); await vi.waitFor(() => expect(heartbeats()).toBe(1)); From 7a0f7157c1281ed4b8c53f6d51a7e411bcaae1c0 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 26 Oct 2026 20:17:32 +0100 Subject: [PATCH 25/28] fix(input): the worker hands modules an explicit openInput, and the core barrel drops the unused factory type (#1017) --- src/core/index.ts | 2 -- src/daemon/main.ts | 4 +++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/core/index.ts b/src/core/index.ts index db40c9af4..9f8ff1a55 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -105,8 +105,6 @@ export { export { closeReason, type DeviceInput, - // fallow-ignore-next-line unused-type -- the port the Android and iOS drivers implement (#1018, #1019); no in-tree driver names it yet. - type DeviceInputFactory, encodeInputMessage, INPUT_TAG, MAX_INPUT_MESSAGE_BYTES, diff --git a/src/daemon/main.ts b/src/daemon/main.ts index 1d632a46f..aca62e621 100644 --- a/src/daemon/main.ts +++ b/src/daemon/main.ts @@ -523,7 +523,9 @@ async function composeDaemon( processRunner, processSupervisor, registry, - inputs: catalog, + inputs: { + openInput: (platform, device, signal) => catalog.openInput(platform, device, signal), + }, screens: catalog, tcpProbe, }, From 6afe7802a9d634fd0dd9a72bd5ed4870cae0e959 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 26 Oct 2026 20:33:48 +0100 Subject: [PATCH 26/28] fix(input): a last socket leaving does not close an input a joiner shares, and a refused upgrade releases the worker relay (#1017) --- src/http/app.ts | 4 +- src/input/AGENTS.md | 3 +- src/input/gateway-link.ts | 4 +- src/input/gateway.test.ts | 12 +----- src/input/sessions.ts | 12 ++++-- src/input/worker.test.ts | 18 +++++++++ src/modules/types.ts | 4 +- src/ports/index.ts | 2 +- src/ports/lease-socket.ts | 6 +++ src/ports/upgrade-router.test.ts | 66 ++++++++++++++++++++++++++++++++ src/ports/upgrade-router.ts | 14 ++++++- 11 files changed, 121 insertions(+), 24 deletions(-) create mode 100644 src/ports/upgrade-router.test.ts diff --git a/src/http/app.ts b/src/http/app.ts index 03e47fe28..5160a0852 100644 --- a/src/http/app.ts +++ b/src/http/app.ts @@ -14,7 +14,7 @@ import { } from "../contract/index.js"; import type { Config, DeviceRecord } from "../core/index.js"; import type { OwnerRoutedFacts } from "../daemon/owner-routed-facts.js"; -import type { Clock, IdGenerator, LeaseSocket, Logger } from "../ports/index.js"; +import type { Clock, IdGenerator, LeaseSocketAccept, Logger } from "../ports/index.js"; import { type AuthEnv, requireAuth } from "./auth.js"; import { buildHttpSession, type HttpDispatch } from "./dispatcher-session.js"; import { @@ -149,7 +149,7 @@ interface HttpLeaseSocketLease { readonly workerLeaseId?: string; } -export type HttpLeaseSocketAccept = (socket: LeaseSocket) => void; +export type HttpLeaseSocketAccept = LeaseSocketAccept; // why: what the upgrade router needs of a lease socket request: the refusal to send before the // upgrade, or the accept to run on the upgraded socket. diff --git a/src/input/AGENTS.md b/src/input/AGENTS.md index c19e85bed..f13c4c46b 100644 --- a/src/input/AGENTS.md +++ b/src/input/AGENTS.md @@ -27,7 +27,8 @@ Off unless `capabilities.input.enabled` (false). Off: nothing loaded, and the `i Two doors reach one session. A standalone worker: the HTTP layer resolves the lease for the caller, then `socket.open` answers an accept and the upgrade router hands it the upgraded socket. Through a gateway the same `open` on the gateway calls `relayTwoWay` (`input` target) with `/input/leases/` and the worker's -lease id; the worker's `relayTwoWay` finds the lease and attaches a `channelSocket` to the same session. +lease id; the worker's `relayTwoWay` finds the lease and attaches a `channelSocket` to the same session. The +gateway's accept carries `abandon` (`gateway-link.ts`), which the router calls when the handshake is refused after the relay opened. 1. Worker session (`sessions.ts` `attach`): a lease gone from the registry → close 4001 `lease ended`; a failed open within 10 s → close 1013; 8 sockets already → close 1013. Else it opens the lease's one device input diff --git a/src/input/gateway-link.ts b/src/input/gateway-link.ts index b778aee5e..74c77a7a7 100644 --- a/src/input/gateway-link.ts +++ b/src/input/gateway-link.ts @@ -143,7 +143,9 @@ export function createGatewayInputLink(options: GatewayInputLinkOptions): { options.clock.cancel(timer); } if (relay instanceof Response) return relay; - return (socket) => bridge(socket, relay, controller, lease); + return Object.assign((socket: LeaseSocket) => bridge(socket, relay, controller, lease), { + abandon: () => controller.abort(), + }); }, }; } diff --git a/src/input/gateway.test.ts b/src/input/gateway.test.ts index 78430e889..65dfaaa33 100644 --- a/src/input/gateway.test.ts +++ b/src/input/gateway.test.ts @@ -95,13 +95,7 @@ describe("a gateway's input socket", () => { }); }); - it("closes the client's socket when the worker goes quiet or away, the lease goes, or the daemon stops", async () => { - const away = await attached("w1"); - await away.worker.abort(new Error("the uplink dropped")); - await vi.waitFor(() => - expect(away.client.closed).toEqual({ code: 1011, reason: "Worker w1 stopped answering" }), - ); - + it("closes the client's socket when the worker relay ends or says something it may not, or the lease goes", async () => { const ended = await attached("w1"); await ended.worker.close(); await vi.waitFor(() => @@ -144,9 +138,5 @@ describe("a gateway's input socket", () => { expect(silent.client.closed).toBeUndefined(); silent.clock.advance(1); expect(silent.client.closed).toEqual({ code: 4001, reason: "lease ended" }); - - const stopping = await attached("w1"); - await stopping.gateway.hooks?.beforeDrain?.(); - expect(stopping.client.closed).toEqual({ code: 1001, reason: "Simlock is stopping" }); }); }); diff --git a/src/input/sessions.ts b/src/input/sessions.ts index a4464af11..4e4e933cc 100644 --- a/src/input/sessions.ts +++ b/src/input/sessions.ts @@ -65,14 +65,18 @@ export function createInputSessions(options: { if (leases.get(leaseId) === entry) leases.delete(leaseId); } - function releaseWhenOpen(leaseId: string, entry: LeaseInput): void { + // why: `unlessJoined` is the last socket leaving: a socket that joined while the input was still opening keeps it, so the close that waited for the open drops out. + function releaseWhenOpen(leaseId: string, entry: LeaseInput, unlessJoined = false): void { if (entry.device !== undefined) { release(leaseId, entry, entry.device); return; } void entry.opening.then( - (opened) => - isUnavailableInput(opened) ? forget(leaseId, entry) : release(leaseId, entry, opened), + (opened) => { + if (unlessJoined && entry.sockets.size > 0) return; + if (isUnavailableInput(opened)) forget(leaseId, entry); + else release(leaseId, entry, opened); + }, () => undefined, ); } @@ -156,7 +160,7 @@ export function createInputSessions(options: { entry.sockets.add(attached); socket.onClose(() => { entry.sockets.delete(attached); - if (entry.sockets.size === 0) releaseWhenOpen(leaseId, entry); + if (entry.sockets.size === 0) releaseWhenOpen(leaseId, entry, true); }); let opened: DeviceInput | UnavailableDeviceInput; try { diff --git a/src/input/worker.test.ts b/src/input/worker.test.ts index 39318f3e0..b88ab2482 100644 --- a/src/input/worker.test.ts +++ b/src/input/worker.test.ts @@ -193,6 +193,24 @@ describe("a worker's input socket", () => { expect(after.sent).toEqual([]); }); + it("keeps the device input open for a socket that joins while it is still opening after the first one left", async () => { + const { factory, worker } = await setup(); + let open!: () => void; + factory.gate = new Promise((resolve) => { + open = resolve; + }); + const first = await attach(worker); + first.drop(); + const second = await attach(worker); + open(); + await vi.waitFor(() => expect(second.sent.map((sent) => sent[0])).toEqual([0x83, 0x82])); + expect(factory.opened[0]?.closes).toBe(0); + expect(factory.opens).toBe(1); + + second.drop(); + await vi.waitFor(() => expect(factory.opened[0]?.closes).toBe(1)); + }); + it("sends the gateway a heartbeat every 5 s while its channel is open and none after it closes", async () => { const { clock, worker } = await setup(); const toGateway = new TransformStream(); diff --git a/src/modules/types.ts b/src/modules/types.ts index 61bde9e40..63a443d72 100644 --- a/src/modules/types.ts +++ b/src/modules/types.ts @@ -24,7 +24,7 @@ import type { import type { Clock, Filesystem, - LeaseSocket, + LeaseSocketAccept, Logger, ProcessRunner, ProcessSupervisor, @@ -129,7 +129,7 @@ export type ModuleLeaseHandler = ( // why: a lease socket handler has two steps so a refusal is a plain `Response` sent before the upgrade // (a gateway's 503 for a worker that cannot take the socket), and only an accept ever sees a socket. -export type ModuleLeaseSocketAccept = (socket: LeaseSocket) => void; +export type ModuleLeaseSocketAccept = LeaseSocketAccept; export interface ModuleLeaseSocketHandler { open( diff --git a/src/ports/index.ts b/src/ports/index.ts index 9a696a8ea..4d1a09b13 100644 --- a/src/ports/index.ts +++ b/src/ports/index.ts @@ -51,7 +51,7 @@ export { type ServiceManagerName, type ServiceState, } from "./service-manager.js"; -export { type LeaseSocket, MemoryLeaseSocket } from "./lease-socket.js"; +export { type LeaseSocket, type LeaseSocketAccept, MemoryLeaseSocket } from "./lease-socket.js"; export { type Clock, FakeClock, SystemClock, type TimerHandle } from "./clock.js"; /** * why: ADR 0005 §33's uplink port and its in-memory adapter. The WebSocket adapters are diff --git a/src/ports/lease-socket.ts b/src/ports/lease-socket.ts index 985ea53f0..bd56a8f3b 100644 --- a/src/ports/lease-socket.ts +++ b/src/ports/lease-socket.ts @@ -8,6 +8,12 @@ export interface LeaseSocket { onClose(listener: () => void): void; } +// why: what a module hands the upgrade router once it has opened what a socket needs (a gateway's relay +// to the worker): `abandon` releases it when the handshake fails and no socket ever arrives. +export type LeaseSocketAccept = ((socket: LeaseSocket) => void) & { + readonly abandon?: () => void; +}; + // why: the in-memory end of a `LeaseSocket` for tests: `sent` records what the module wrote and // `closed` how it closed; `receive` and `drop` play the client. export class MemoryLeaseSocket implements LeaseSocket { diff --git a/src/ports/upgrade-router.test.ts b/src/ports/upgrade-router.test.ts new file mode 100644 index 000000000..e855f4bfd --- /dev/null +++ b/src/ports/upgrade-router.test.ts @@ -0,0 +1,66 @@ +import { createServer, request, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { WebSocket } from "ws"; + +import type { LeaseSocketAccept } from "./lease-socket.js"; +import { createUpgradeRouter } from "./upgrade-router.js"; + +let server: Server | undefined; + +afterEach(async () => { + const closing = server; + server = undefined; + closing?.closeAllConnections(); + await new Promise((resolve) => closing?.close(() => resolve())); +}); + +async function listen(accept: LeaseSocketAccept): Promise { + const listening = createServer(); + createUpgradeRouter({ leaseSocket: () => Promise.resolve({ accept }) }).attach(listening); + server = listening; + await new Promise((resolve) => listening.listen(0, "127.0.0.1", resolve)); + return (listening.address() as AddressInfo).port; +} + +function upgradeWithVersion(port: number, version: string): Promise { + return new Promise((resolve) => { + const upgrading = request({ + headers: { + connection: "Upgrade", + "sec-websocket-key": "dGhlIHNhbXBsZSBub25jZQ==", + "sec-websocket-version": version, + upgrade: "websocket", + }, + host: "127.0.0.1", + path: "/v1/leases/L-1/input", + port, + }); + upgrading.on("response", (response) => { + response.resume(); + resolve(response.statusCode ?? 0); + }); + upgrading.end(); + }); +} + +// why: `upgrade-router.ts` is not exported through `ports/index.ts` (the CLI must not load `ws`), and the claim is an ordering the surface cannot force: what `ws` does on a refused handshake -- it answers and destroys the socket without calling the accept back. +describe("the upgrade router's lease socket", () => { + it("abandons what the module opened when the handshake is refused, and not when it succeeds", async () => { + const abandon = vi.fn(); + const accepted = vi.fn(); + const port = await listen(Object.assign(accepted, { abandon })); + + expect(await upgradeWithVersion(port, "99")).toBe(400); + await vi.waitFor(() => expect(abandon).toHaveBeenCalledTimes(1)); + expect(accepted).not.toHaveBeenCalled(); + + const client = new WebSocket(`ws://127.0.0.1:${String(port)}/v1/leases/L-1/input`); + await new Promise((resolve) => client.once("open", resolve)); + client.close(); + await new Promise((resolve) => client.once("close", resolve)); + expect(accepted).toHaveBeenCalledTimes(1); + expect(abandon).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/ports/upgrade-router.ts b/src/ports/upgrade-router.ts index 07d519c57..13de47416 100644 --- a/src/ports/upgrade-router.ts +++ b/src/ports/upgrade-router.ts @@ -8,12 +8,12 @@ import type { Duplex } from "node:stream"; import { WebSocketServer, type RawData, type WebSocket } from "ws"; import { type Clock, SystemClock } from "./clock.js"; -import type { LeaseSocket } from "./lease-socket.js"; +import type { LeaseSocket, LeaseSocketAccept } from "./lease-socket.js"; import { UPLINK_PATH } from "./uplink.js"; export type LeaseSocketOutcome = | { readonly response: Response } - | { readonly accept: (socket: LeaseSocket) => void }; + | { readonly accept: LeaseSocketAccept }; export interface UpgradeRouterOptions { readonly uplinks?: { @@ -76,7 +76,17 @@ async function leaseUpgrade( return; } const { accept } = outcome; + // why: `ws` answers a handshake it refuses (a bad version, a bad key) itself and never calls back, so what the module opened for this socket is released when the connection ends unaccepted. + let upgraded = false; + socket.once("close", () => { + if (!upgraded) accept.abandon?.(); + }); + if (socket.destroyed) { + accept.abandon?.(); + return; + } sockets.handleUpgrade(request, socket, head, (client) => { + upgraded = true; accept(wrap(client, clock)); }); } From 635b72184986ef77ec04207e4d41a1b71e17dc1b Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 26 Oct 2026 20:48:07 +0100 Subject: [PATCH 27/28] fix(ports): a client reset while the module resolves no longer crashes the daemon, and the abandon paths are proven (#1017) --- src/input/gateway.test.ts | 17 ++++++++++ src/ports/upgrade-router.test.ts | 54 ++++++++++++++++++++++++++++++-- src/ports/upgrade-router.ts | 2 ++ 3 files changed, 70 insertions(+), 3 deletions(-) diff --git a/src/input/gateway.test.ts b/src/input/gateway.test.ts index 65dfaaa33..efc1a449b 100644 --- a/src/input/gateway.test.ts +++ b/src/input/gateway.test.ts @@ -139,4 +139,21 @@ describe("a gateway's input socket", () => { silent.clock.advance(1); expect(silent.client.closed).toEqual({ code: 4001, reason: "lease ended" }); }); + + it("aborts the worker relay of a socket that never reaches the client", async () => { + const pair = relayPair(); + let signal: AbortSignal | undefined; + const { socket } = await gatewayWith({ + relayTwoWay: (request) => { + signal = request.signal; + return Promise.resolve(pair.relay); + }, + }); + const accept = await socket.open(new Request("http://simlock/input"), IDENTITY, LEASE); + if (typeof accept !== "function") throw new Error("the socket was refused"); + expect(signal?.aborted).toBe(false); + + accept.abandon?.(); + expect(signal?.aborted).toBe(true); + }); }); diff --git a/src/ports/upgrade-router.test.ts b/src/ports/upgrade-router.test.ts index e855f4bfd..e3aeeb11c 100644 --- a/src/ports/upgrade-router.test.ts +++ b/src/ports/upgrade-router.test.ts @@ -1,5 +1,5 @@ import { createServer, request, type Server } from "node:http"; -import type { AddressInfo } from "node:net"; +import { connect, type AddressInfo, type Socket } from "node:net"; import { afterEach, describe, expect, it, vi } from "vitest"; import { WebSocket } from "ws"; @@ -16,14 +16,33 @@ afterEach(async () => { await new Promise((resolve) => closing?.close(() => resolve())); }); -async function listen(accept: LeaseSocketAccept): Promise { +// why: `ended` settles when each connection the server saw has closed, so a test reads what the router did on the close after it fired, not before. +const ended: Promise[] = []; + +async function listen( + accept: LeaseSocketAccept, + gate: Promise = Promise.resolve(), +): Promise { const listening = createServer(); - createUpgradeRouter({ leaseSocket: () => Promise.resolve({ accept }) }).attach(listening); + listening.on("connection", (connection) => { + ended.push(new Promise((resolve) => connection.once("close", () => resolve()))); + }); + createUpgradeRouter({ leaseSocket: () => gate.then(() => ({ accept })) }).attach(listening); server = listening; await new Promise((resolve) => listening.listen(0, "127.0.0.1", resolve)); return (listening.address() as AddressInfo).port; } +function rawUpgrade(port: number): Socket { + const raw = connect(port, "127.0.0.1"); + raw.on("error", () => undefined); + raw.write( + "GET /v1/leases/L-1/input HTTP/1.1\r\nHost: x\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n" + + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n", + ); + return raw; +} + function upgradeWithVersion(port: number, version: string): Promise { return new Promise((resolve) => { const upgrading = request({ @@ -60,7 +79,36 @@ describe("the upgrade router's lease socket", () => { await new Promise((resolve) => client.once("open", resolve)); client.close(); await new Promise((resolve) => client.once("close", resolve)); + await Promise.all(ended); expect(accepted).toHaveBeenCalledTimes(1); expect(abandon).toHaveBeenCalledTimes(1); }); + + it("abandons what the module opened, and survives, when the client resets before the module answers", async () => { + const abandon = vi.fn(); + let release: () => void = () => undefined; + const port = await listen( + Object.assign(vi.fn(), { abandon }), + new Promise((resolve) => { + release = resolve; + }), + ); + const crashes: unknown[] = []; + const saved = process.listeners("uncaughtException"); + process.removeAllListeners("uncaughtException"); + process.on("uncaughtException", (error) => crashes.push(error)); + try { + const raw = rawUpgrade(port); + await new Promise((resolve) => raw.once("connect", resolve)); + await new Promise((resolve) => setTimeout(resolve, 50)); + raw.resetAndDestroy(); + await Promise.all(ended); + release(); + await vi.waitFor(() => expect(abandon).toHaveBeenCalledTimes(1)); + expect(crashes).toEqual([]); + } finally { + process.removeAllListeners("uncaughtException"); + for (const listener of saved) process.on("uncaughtException", listener); + } + }); }); diff --git a/src/ports/upgrade-router.ts b/src/ports/upgrade-router.ts index 13de47416..ee823e5a6 100644 --- a/src/ports/upgrade-router.ts +++ b/src/ports/upgrade-router.ts @@ -64,6 +64,8 @@ async function leaseUpgrade( socket: Duplex, head: Buffer, ): Promise { + // why: a client that resets while the module resolves would emit an `error` on a raw socket nothing listens to yet, and that ends the process; `ws` takes the socket's errors over once it upgrades. + socket.on("error", () => undefined); let outcome: LeaseSocketOutcome; try { outcome = await options.leaseSocket(toRequest(request)); From 2cca75a5ca16b676bc7c2cab5e1e3e5c0f228b46 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 26 Oct 2026 22:07:01 +0100 Subject: [PATCH 28/28] fix(daemon): the uplink comment starts with why: (#1017) --- src/daemon/main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/daemon/main.ts b/src/daemon/main.ts index a66d5ab4d..80e7b2dea 100644 --- a/src/daemon/main.ts +++ b/src/daemon/main.ts @@ -1120,7 +1120,7 @@ async function startGatewayDaemon(options: GatewayDaemonOptions): Promise createUpgradeRouter({