Skip to content

Input socket for a lease #1017

Open
Open

Description

@V3RON

Part of #1013.

Scope

After this PR, a client with an agent token opens WS /v1/leases/{id}/input on a gateway or a standalone worker. It gets 0x83, then 0x82, and its serve-sim messages reach the worker that holds the lease. No device takes input yet: every device reports "inputUnavailable":true until #1018 and #1019 add the Android and iOS device inputs. The socket ends when the lease ends, when either end goes quiet, and when the daemon stops.

This task adds the new optional module input, the upgrade routing in ports, the lease socket seam and the device input port. It is the feature's widest task (five Behaviours rows, about 18 modules); it does not split further, because the socket, its gateway relay and its lifecycle are one path a test drives end to end.

In short

A lease holder's app opens one WebSocket for its lease and gets 0x83 (input admitted), then 0x82 {"width":1179,"height":2556,"orientation":"portrait","inputUnavailable":true}. Through a gateway, the socket rides #1016's two-way relay to the lease's worker. It closes with 4001 and the lease-end reason (explicit, killed, expired, device-lost) when the lease ends.

New term: device input, what turns messages into taps on one device. One per lease, shared by up to 8 sockets (Shared values, "Device input port"). This task adds the port and a placeholder that answers inputUnavailable on every platform.

flowchart LR
  App[App] -- WS upgrade --> P[ports upgrade router NEW]
  P --> H[http: lease resolve for the caller]
  H --> GI[input module, gateway side NEW]
  GI -- two-way relay #1016 --> WI[input module, worker side NEW]
  H --> WI
  WI -- WorkerContext.inputs NEW --> D[device input: none yet → inputUnavailable]
Loading

Messages a client sees, word for word: the refusal for a 9th socket is close 1013 with reason Input is unavailable for this lease; lease end is close 4001 with the reason text explicit, killed, expired or device-lost; worker unreachable is close 1011 Worker <workerId> stopped answering; daemon stop is close 1001 Simlock is stopping.

Input turned off on the lease's worker behind a gateway, before the upgrade:

$ websocat -H "Authorization: Bearer $AGENT_TOKEN" wss://gw:7480/v1/leases/L-42/input
websocat: WebSocketError: Received unexpected status code (503 Service Unavailable)
# body: {"error":{"code":"INPUT_UNAVAILABLE","message":"Worker w1 does not take input for lease L-42; turn on capabilities.input.enabled there"}}
Request Today After
upgrade /v1/leases/L-42/input, owner, input on 404, socket destroyed 101, then 0x83, 0x82
upgrade, another agent's lease 404 404 UNKNOWN_LEASE
upgrade, no token 404 401 UNAUTHENTICATED
upgrade, input off on the answering daemon 404 404
upgrade /v1/uplink with a worker token, on a gateway 101 101 (unchanged)
upgrade /v1/leases/L-42/stream 404 404 (only input takes sockets)

If this goes wrong, a user sees T3 Code's device panel say "input socket refused" or never mark input as connected.

Technical spec

Facts

Commit e1f2f43.

# Fact Source
F1 The gateway's only upgrade listener answers 404 and destroys the socket for any path but /v1/uplink. src/ports/uplink-websocket.ts:214-227
F2 It is attached to the gateway's HTTP server through onServerCreated; a worker's HttpGateway gets no such hook, so a worker upgrades nothing. src/daemon/main.ts:1158-1165, src/daemon/main.ts:807-811, src/http/server.ts:41-49
F3 Only uplink-websocket.ts imports ws (8.18.3), with new WebSocketServer({ noServer: true }). src/ports/AGENTS.md:53, package.json:82, src/ports/uplink-websocket.ts:181
F4 The uplink authenticates the bearer token before handleUpgrade, answering 401 or 403 and destroying the socket on refusal. src/ports/uplink-websocket.ts:249-264
F5 The uplink's worker side pings every 15 s and terminates when no pong came since the last ping. src/ports/uplink-websocket.ts:39-41, src/ports/uplink-websocket.ts:325-340
F6 HTTP auth resolves a bearer token from request headers through Auth.authenticateRequest(headers). src/auth/index.ts:46-47
F7 A module mount is (Request, identity) → Promise<Response>, mounted under /v1/<module name>; it cannot take an upgrade. src/modules/types.ts:85-90, src/modules/host.ts:377-381
F8 A worker module returns capability, relayTarget, mount, findings, hooks; the host collects relay targets by module name. src/modules/types.ts:103-109, src/modules/host.ts:307-310
F9 ModuleScreens hands a module the drivers' capture, routed by platform, without the drivers; the worker builds it from DriverCatalog. src/modules/types.ts:120-129, src/daemon/main.ts:519
F10 beforeDeviceReclaim is how a worker module hears a lease end, with ModuleEndedLease.reason explicit, killed, expired or device-lost. src/modules/types.ts:44-50, src/modules/types.ts:96-101, src/modules/host.ts:312-318
F11 A gateway module reads the fleet's leases through FleetLeases.resolve(gatewayLeaseId) and hears removals through onRemoved. src/modules/types.ts:164-177
F12 Both daemons run moduleHost.beforeDrain() before stopping the HTTP gateway, which destroys open sockets without a close frame. src/daemon/main.ts:741-744, src/daemon/main.ts:1102-1107, src/http/server.ts:206-221
F13 The stream's gateway side maps RELAY_TARGET_MISSING to 503 with a "turn on capabilities.stream.enabled there" message. src/stream/gateway-link.ts:68-72
F14 A module's error codes are declared in its definition.ts statuses, not in the contract (STREAM_UNAVAILABLE). src/stream/definition.ts:58-65
F15 Optional modules are listed in one registry array; a module's capability name is its config section capabilities.<name>.enabled. src/module-registry/index.ts:1-7, src/core/config.ts:256-258
F16 The stream envelope is a 4-byte big-endian length, a tag byte, then the payload. src/core/screen.ts:71-77
F17 Expo serve-sim refuses a socket past 8 with close 1013, sends 0x83 on attach, then the 0x82 config. expo/expo-device-hub v0.19.0-canary-20261023-1357114:packages/serve-sim/packages/serve-sim/src/device-session.ts:136, :1384-1392, :1449-1453
F18 Expo serve-sim answers 0x10 with 0x90 and JSON {requestId, ok, error?}. expo/expo-device-hub v0.19.0-canary-20261023-1357114:packages/serve-sim/packages/serve-sim/src/device-session.ts:1781-1806
F19 Expo serve-sim's 0x82 carries inputUnavailable and keyboardUnavailable. expo/expo-device-hub v0.19.0-canary-20261023-1357114:packages/serve-sim/packages/serve-sim/src/device-session.ts:2808-2826
F20 T3 Code opens /helper/ws?device=<id>, sends nothing first, marks input admitted on 0x83 and blocked by inputUnavailable in 0x82. pingdotgg/t3code v1316d48:packages/client-runtime/src/device/stream.ts:1075-1102
F21 T3 Code sends input only after 0x83 and while 0x82 does not say unavailable; earlier input is dropped. pingdotgg/t3code v1316d48:packages/client-runtime/src/device/stream.ts:1275-1282
F22 T3 Code treats close 1008 and 4401 as unauthorized and retries any other close after 1 s. pingdotgg/t3code v1316d48:packages/client-runtime/src/device/stream.ts:1133-1151
F23 The stream module's worker and gateway files show the module shape this one copies. src/stream/worker.ts:4-21, src/stream/gateway.ts:4-14
F24 capabilities.stream.enabled is documented on the configuration page; access control lists what an agent token may do. website/src/content/docs/docs/reference/configuration.mdx:314, website/astro.config.mjs:102, website/astro.config.mjs:131

Modules touched

Searches: upgrade, WebSocketServer, onServerCreated, ModuleMount, relayTarget, ModuleScreens, screens:, beforeDeviceReclaim, optionalModules, capabilities.stream over src/, website/, ui/src.

  • src/input/ (new): definition.ts, index.ts, worker.ts, gateway.ts, the session (messages, sharing, heartbeat, lease end) and its manual AGENTS.md.
  • src/core/input.ts (new), src/core/index.ts: message tags and JSON shapes from the feature's Shared values "Input messages".
  • src/ports/: an upgrade router beside the uplink (uplink-websocket.ts and a new file), its index.ts and AGENTS.md.
  • src/modules/types.ts, src/modules/host.ts: the lease socket handler on WorkerModule and GatewayModule, WorkerContext.inputs.
  • src/http/: the lease socket resolution next to A lease holder streams its lease's screen #1015's lease route.
  • src/daemon/main.ts: router on both listeners, inputs from DriverCatalog.
  • src/drivers/ catalog: an openInput that answers "unavailable" for every platform.
  • src/module-registry/index.ts: add inputModule.
  • website/src/content/docs/docs/using-devices/watching-the-screen.mdx, .../reference/configuration.mdx, .../gateway/access-control.mdx.
flowchart LR
  daemon[daemon/main.ts *] --> ports[ports upgrade router *]
  daemon --> host[modules/host.ts *]
  ports --> http[http lease resolution *]
  http --> input[input module NEW]
  host --> input
  input --> core[core/input.ts NEW]
  input -- GatewayContext.relayTwoWay --> gw[gateway relay #1016]
  input -- WorkerContext.inputs --> drivers[driver catalog *]
Loading
sequenceDiagram
  participant App
  participant GW as Gateway (ports, http, input)
  participant W as Worker (input)
  App->>GW: GET /v1/leases/L-42/input, Upgrade: websocket
  GW->>GW: authenticate, resolve L-42 for the caller
  alt not theirs or unknown
    GW-->>App: 404 UNKNOWN_LEASE, socket destroyed
  else
    GW->>W: two-way relay, target input, lease L-42
    alt input off on the worker
      W-->>GW: RELAY_TARGET_MISSING
      GW-->>App: 503 INPUT_UNAVAILABLE, socket destroyed
    else
      GW-->>App: 101 Switching Protocols
      W-->>App: 0x83, 0x82
      App->>W: 0x03 touch
      W-->>GW: 0xF0 every 5 s (dropped)
      Note over GW,W: 15 s with no bytes from W
      GW-->>App: close 1011
    end
  end
Loading

Surface and extension points

  • src/input/index.ts exports inputModule, an OptionalModuleDefinition (F15, F23). Its worker factory returns a WorkerModule with capability, a lease socket handler, relayTwoWay (the worker end of Two-way relay over the uplink #1016's two-way relay, see the feature's Two-way relay row), and hooks.beforeDeviceReclaim and hooks.beforeDrain. Its gateway factory returns a lease socket handler and hooks.beforeDrain.
  • Lease socket seam: WorkerModule.socket and GatewayModule.socket, a handler (socket, identity, lease), written to the feature's Shared values "Lease socket". The socket type is a small port interface (send, close(code, reason), onMessage, onClose), so no module imports ws (F3).
  • ports gains one upgrade router per HTTP server: /v1/uplink goes to the uplink as today; /v1/leases/{id}/<segment> goes to http's lease resolution; anything else gets 404 and is destroyed (F1). A worker's HttpGateway gets the router through onServerCreated too (F2).
  • WorkerContext.inputs: ModuleInputs, modeled on ModuleScreens (F9): openInput(platform, device, signal) → a device input (send(message), onConfig(listener), close()). This task's driver side answers "unavailable" for every platform; Android input through scrcpy's control channel #1018 and iOS simulator input through the HID injector #1019 are the two implementations, so the port is added now (architecture rule 19: "when a second implementation exists or is being built").

Contract and event changes

none. Searched src/contract/ for relay, capability and error shapes: #1016 makes the relay change and bumps the protocol; INPUT_UNAVAILABLE is a module error code declared in src/input/definition.ts (F14); the capability input uses the existing {name, state, reason?} shape. No event is added; opening and refusing sockets is logged only.

New wire fields, all from the client: each WebSocket message is one tag byte and UTF-8 JSON (feature Shared values "Input messages"); a message over 64 KiB, bad JSON, an unknown tag, or a position outside 0..1 is dropped and the socket stays open (F17's server does the same). Config capabilities.input.enabled: boolean, default false.

Other code on the same state

Searches: on("upgrade" (1 hit, F1), handleUpgrade (1), beforeDeviceReclaim (2: stream, agent-device none), beforeDrain (module host, 2 daemons), leases.onRemoved (1: agent-device relay-host-link).

  • The uplink's upgrade handling (F1): now behind the router; an uplink upgrade must behave byte for byte as today. src/ports/uplink-websocket.test.ts already proves it and must stay green unchanged.
  • The stream module's beforeDeviceReclaim (F10): both modules hear the same lease end; neither waits for the other, and each closes only its own sockets.
  • A lease holder streams its lease's screen #1015's lease route (feature Shared values "Lease route"): this task reuses its lease resolution for upgrades; a plain GET to /v1/leases/{id}/input with no upgrade answers 426.
  • HttpGateway.stop (F12): the input module closes its sockets with 1001 in beforeDrain, which runs first, so no socket is destroyed without a close frame.

Failure modes

Call Outcome What happens Limit, counted from
Upgrade auth (F4, F6) no or unknown token 401, socket destroyed none, synchronous
Lease resolution not theirs, unknown 404 UNKNOWN_LEASE, socket destroyed none
Gateway → worker two-way relay ok 101, then the worker's 0x83 and 0x82 #1016's channel accept, 30 s from the open
Gateway → worker two-way relay RELAY_TARGET_MISSING 503 INPUT_UNAVAILABLE before the upgrade (F13) same
Gateway → worker two-way relay WORKER_UNREACHABLE, no answer 503 WORKER_UNREACHABLE before the upgrade same
Worker heartbeat silent close 1011, relay ended 15 s from the last byte from the worker
Uplink closed every socket on that worker closes 1011 at once
Client ping no pong socket terminated, its share of the device input released 15 s from the ping, pings every 15 s
inputs.openInput unavailable (every platform in this task) 0x83, then 0x82 with "inputUnavailable":true; messages dropped none
inputs.openInput throws close 1013 Input is unavailable for this lease the open's own bound, set by #1018/#1019
Device input send throws every socket of the lease closes 1013; the device input closes at once
Socket count 9th socket close 1013 before 0x83 (F17) none
Lease end (F10) any reason 0xF1 {code:4001, reason} on the channel, then close 4001 with the reason the participant's own signal
Gateway leases.onRemoved (F11) lease gone, socket still open close 4001 with reason explicit at once
Daemon stop (F12) beforeDrain close 1001 Simlock is stopping on every socket before the HTTP gateway stops

State: the device input per lease and its socket set, in memory only. A worker restart drops them; through a gateway the uplink closes, so its sockets close 1011; standalone, the TCP connection closes. Two sockets opening at once for one lease share one device input: the first open runs, the second waits for it.

Rules in play

  • Architecture rule 11, "Every wait that crosses a process boundary is bounded": the relay open (30 s), the worker silence (15 s) and the client pong (15 s) are each counted from a fixed point.
  • Architecture rule 10, "A rule is enforced in exactly one place": lease ownership is checked only by http's lease resolution; the module trusts the lease it is handed.
  • Architecture rule 15, "Dependencies point from the optional to the required": input imports modules, core and contract; nothing required imports input.
  • Architecture rule 18, "A module hands out a capability, not its parts": index.ts exports only inputModule.
  • Architecture rule 19, "An extension point is added when a second implementation is named": ModuleInputs has Android input through scrcpy's control channel #1018 and iOS simulator input through the HID injector #1019 as its implementations.
  • src/ports/AGENTS.md:53, "Only uplink-websocket.ts imports ws": the router lives beside it in ports and owns the only other ws import; the rule's wording changes to name both files.
  • Documentation rule 9, "A change to the public surface updates the website in the same change": the route, capabilities.input.enabled and INPUT_UNAVAILABLE go on the three pages under Modules touched.
  • ADR 0049 §1–§6: lease routes, the module lease route, expo serve-sim messages, the shared device input, the two-way relay's heartbeat and lease end.

Tests

Seam: the gateway and standalone worker HTTP listeners with a faked device input, in a new src/daemon/input-lease.e2e.test.ts modeled on src/daemon/stream-gateway.e2e.test.ts; the session's message handling in src/input/ modeled on src/stream/worker-link.test.ts.

  • A caller opens WS /v1/leases/{id}/input and gets 0x83 then 0x82, or a refused upgrade: own lease; another's 404; unknown 404; operator; input off on the answering daemon 404; off on the lease's worker 503 INPUT_UNAVAILABLE; no token 401; a 9th socket closed 1013; a platform with no device input gets "inputUnavailable":true.
  • A client following T3 Code's order drives a faked device through a gateway: waits for 0x83; touch begin/move/end, key, button arrive in order.
  • A client sending a bad message keeps its socket and nothing reaches the device: bad JSON; unknown tag; x outside 0..1; 0x10 answered 0x90 {ok:false}.
  • An input socket closes with 4001 and the reason when the lease ends, and a reconnect gets 404: explicit, expired, killed, device-lost.
  • An input socket closes when either end goes quiet or away: client misses its pong (worker and gateway); worker heartbeat stops (1011); uplink drops (1011); daemon stops (1001); the gateway sees the lease go (4001).

The uplink's upgrade, unchanged, stays proven by src/ports/uplink-websocket.test.ts.

Done when

  • pnpm test passes, including src/daemon/input-lease.e2e.test.ts and src/ports/uplink-websocket.test.ts unchanged.
  • With capabilities.input.enabled true on a standalone worker, an agent token's upgrade to its own lease's /v1/leases/{id}/input gets 0x83 then 0x82 with "inputUnavailable":true; another token gets 404.
  • pnpm openapi lists GET /v1/leases/{id}/input with 101, 401, 404, 426 and 503 INPUT_UNAVAILABLE.
  • The three website pages under Modules touched describe the input socket, capabilities.input.enabled and INPUT_UNAVAILABLE.

Out of scope

Depends on

Written by an agent.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    task:draftScope written; technical spec or its spec check missing.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions