Repository navigation
feat(fleet): a gateway relays an HTTP exchange to a worker module over the uplink #514
Description
Part of #511.
Scope
After this PR, a gateway can hand one HTTP exchange to a module on a worker
and get the response back, body included, over the worker's uplink. A worker
module registers itself as a relay target under its capability name.
The gateway relays to it with a deadline, cancels it when its caller goes
away, and fails it with WORKER_UNREACHABLE when the uplink drops.
Nobody uses it yet: no target is registered in a real daemon. #515
registers agent-device. Tests use a fake target.
The task is at the size limit (about 30 Tests lines) and stays whole: the
worker's and the gateway's halves of the relay speak one protocol, and
neither half can be tested end to end without the other.
In short
New terms:
- relay target: a function on a worker, registered under a capability
name, that takes aRequest, a tenant name and a lease id, and answers a
Response. Example: the targetagent-device(feat(agent-device): open, commands, heartbeats and close through a gateway #515) hands the request
to the agent-device host. - relay call: one exchange through the uplink: a
relay.requestcall,
its request body asrelay.bodycalls, and its response as pushes.
sequenceDiagram
participant G as gateway: GatewayRelay (new)
participant S as worker: DaemonServer *
participant T as relay target (registered)
G->>S: relay.request {relayId, target, method, url, headers, tenant, leaseId, hasBody}
S->>T: Request (body is a stream fed by relay.body)
G->>S: relay.body {relayId, seq, chunk} ×n (at most 4 unanswered)
G->>S: relay.body {relayId, end: true}
T-->>S: Response
S-->>G: push relay-head {status, headers}
S-->>G: push relay-chunk ×n
S-->>G: relay.request result {} (body ended)
alt caller gone / deadline
G->>S: relay.cancel {relayId}
S->>T: abort the Request's signal
S-->>G: relay.request result {cancelled: true}
else uplink drops
G-->>G: fail the call: WORKER_UNREACHABLE
S->>T: abort every relay of that connection
end
If this goes wrong, a relayed upload arrives cut short or in the wrong
order, a relayed request runs on after its caller is gone, or a large
transfer holds the uplink and other calls on it time out.
Technical spec
Modules touched
flowchart LR
OP["contract: operations, pushes, errors *"]
GR["gateway: relay.ts GatewayRelay (new)"] --> FP["gateway: fleet-ports liveClient, worker error translation *"]
FC["gateway: fleet-coordinator *"] --> FP
CR["gateway: component-relay *"] --> FP
GR --> AC["simlock-client: admin client *, wire *"]
AC -->|uplink| SV["daemon: server.ts *"]
SV --> RR["daemon: relay.ts relay targets (new)"]
M["daemon: main.ts StartDaemonOptions, gateway composition *"] --> RR
M --> GR
(* = changed, new = new file.)
Contract (src/contract/operations.ts, pushes.ts, errors.ts). Every
field is bounded by schema, since each arrives over the wire:
| Field | Bound |
|---|---|
relayId |
1–64 characters of [A-Za-z0-9-]; the gateway mints a random UUID |
target |
capabilityNameSchema (#519) |
method |
GET, HEAD, POST, PUT, PATCH, DELETE |
url |
an absolute http: or https: URL of at most 4096 characters: the URL the caller received, so a target that builds links from the origin (an upload ticket) names the gateway |
headers (request and relay-head) |
at most 100 [name, value] pairs; name 1–256 characters, an HTTP token (^[!#$%&'*+.^_ |
tenant |
1–300 characters (#515 passes gw:<gateway instance id>:<principal>) |
leaseId |
optional, the lease id pattern lease.request uses |
hasBody |
boolean; true with GET or HEAD fails BAD_REQUEST |
seq |
integer from 0 |
chunk |
standard base64 (A–Z a–z 0–9 + /, = padding, length a multiple of 4), at most 349 528 characters, and at most 262 144 bytes once decoded (checked after decoding) |
status (relay-head) |
200–599 |
Headers are filtered both ways before the bounds apply. The gateway drops
the caller's authorization and x-agent-device-token, so a worker never
holds a gateway credential (#515 puts them back into an upload ticket on the
gateway side). Both sides drop the framing and hop-by-hop headers, which the
relay re-creates: connection, keep-alive, transfer-encoding,
content-length, upgrade, te, trailer, proxy-authorization and
proxy-authenticate.
relay.request, roleadmin: input above. Output{}once the response
body has ended, or{ cancelled: true }once a cancel took effect. A
worker holds at most 16 active relays per connection; a 17th fails
BAD_REQUEST.relay.body, roleadmin:relayId, then eitherseqandchunk, or
end: true. Output{}.relay.cancel, roleadmin:relayId. Output{}.- Pushes
relay-head(requestId,status,headers) andrelay-chunk
(requestId,chunk), keyed by therelay.requestcall's frame id like
the four request-scoped pushes today (progress,output,started,
component-progress). - New error code
RELAY_TARGET_MISSING: kinddomain, HTTP 503, CLI
exit 1, details{ target }.
Worker (src/daemon/server.ts, new src/daemon/relay.ts):
- All three operations are refused
FORBIDDENon any session that is not
the gateway's uplink, likelease.request'sowner. - Relay operations wait behind the worker's startup gate like every other
operation. Arelay.requestregisters itsrelayIdwhen its frame is
read, before any later frame on the connection is handled, so its
relay.bodyframes always find it. relay.requestnaming arelayIdalready active on that connection fails
BAD_REQUEST. One naming atargetno module registered fails
RELAY_TARGET_MISSINGbefore anything is called.- It builds a
Requestforurlwith the method and headers. With
hasBody, the body is a stream thatrelay.bodycalls feed inseq
order. Arelay.bodyis answered once the stream has handed that chunk to
the target, so the gateway's window holds the sender back. Aseqout of
order or repeated, a chunk afterend, or anyrelay.bodyfor a relay
opened withhasBody: false, fails that call withBAD_REQUESTand
aborts the relay. Arelay.bodyfor a relay id the worker does not hold
(never opened, failed, ended or cancelled) answers{}and is dropped:
the gateway learns the outcome fromrelay.request. - It calls the target with the
Request,tenantandleaseId, sends
relay-head, then the response body asrelay-chunkpushes of at most
256 KiB each, then answers the call. It reads the next part of the
target's body only once the previous push has been written to the socket,
asdevice.execoutput does, so the worker never queues a whole response.
For a status of 204, 205 or 304, or aHEAD, it sends no chunk. - A target
Responsewhose headers break the bounds (after filtering) fails
the call withINTERNAL, and the worker logs which bound. - A target that throws before its
Responsefails the call withINTERNAL,
and the worker logs the error's message. A response body that errors
afterrelay-headfails the call withINTERNALafter the chunks sent so
far. - When the call settles for any reason, every
relay.bodystill unanswered
is answered{}, so the gateway's sender never waits on a target that
stopped reading. relay.cancel, and the close of the connection the relay came on, abort
theRequest's signal. After a cancel, the worker sends no more pushes for
that relay and answersrelay.request{ cancelled: true }.relay.cancel
for a relay id the worker does not hold answers{}.- Relay targets come from a new
relayTargetsoption ofstartDaemon
(capability name → target). Nothing passes one in this PR outside tests.
Client (src/simlock-client/wire.ts, client.ts). CallHooks gain
onRelayHead and onRelayChunk, routed by frame id like onOutput. The
client class gains relayRequest, relayBody and relayCancel, declared
on a new interface GatewayWorkerClient in src/gateway/ (the
SimlockAdminClient methods the gateway uses, plus these three). The
gateway's worker links hold a GatewayWorkerClient; SimlockAdminClient,
which simlock/admin exports, does not change.
Gateway (new src/gateway/relay.ts, exported from the gateway's
index.ts). A class GatewayRelay, built in startGatewayDaemon from the
worker directory, with a deadlineMs constructor option (default 330 000:
agent-device's proxy allows 300 s per exchange, plus a margin; no config
key). Tests reach it through a gatewayRelay callback option of
StartDaemonOptions, which the gateway composition calls with it, test-only
like agentDeviceUpstream. One method:
relay(workerId, { target, request, tenant, leaseId? }) → Promise<Response>.
- The worker's live client comes from
liveClient(fleet-ports.ts); none
→WORKER_UNREACHABLE, before anything is sent. - It sends
relay.request, then reads the request body in chunks of at
most 256 KiB and sends each asrelay.body, with at most 4 sent and not
yet answered, thenend. Once the call settles it stops reading the
caller's body and cancels it. - It resolves with a
Responseoncerelay-headarrives: a null body for
204, 205, 304 orHEAD, else a stream fed byrelay-chunkpushes and
closed when the call answers{}. - The client's wire routes
relay-headandrelay-chunkbyrequestId
before checking the rest of the push, so a push that fails its schema
reaches its relay (asonRelayInvalid) and is not dropped. The relay then
fails withINTERNAL(messageworker <id> sent an invalid relay response) and sendsrelay.cancel. relay.requestanswering{}before anyrelay-head, or
{ cancelled: true }when this side sent no cancel, fails the relay with
INTERNAL(messageworker <id> ended the relay without a response).- A worker's
INTERNALafter the head errors the caller's body stream with
that message. - At most 8 relays run at once per worker; the next waits for one to end,
and the wait counts against its deadline. - It buffers at most 64 MiB of
relay-chunkdata the caller has not read
yet. Past that it sendsrelay.canceland errors the body stream with a
message saying the reader fell behind. - The request's
signalaborting, or the caller's request body erroring,
sendsrelay.cancel. Before the head, the call rejects with the abort
reason or the body's error; after it, the response body stream errors with
it. - The deadline runs from the
relay()call and covers the whole exchange,
both bodies included. Passing it sendsrelay.canceland fails the call
withWORKER_UNREACHABLE, messageworker <id> did not finish the relayed request within <n> ms,<n>the deadline in milliseconds, or errors the
body stream with that message if the head already came. - The uplink dropping fails the call, or errors the body stream, with
WORKER_UNREACHABLE. Which client errors mean "the uplink is lost" is
decided in one function infleet-ports.ts: an error of kind
transport.FleetLeaseCoordinator#classifyRelayedError, the relay and
the component relay'sfromErrorall call it (the component relay keeps
answering such a loss as an install result ofunknown). relayRequest,relayBodyandrelayCancelare only on
GatewayWorkerClient;simlock/admindoes not export them, and
docs/CLIENT.mddoes not list them.
Contract and event changes
- New operations
relay.request,relay.body,relay.cancel; new pushes
relay-head,relay-chunk; new error codeRELAY_TARGET_MISSING. - This PR bumps the protocol by one.
- No events: a relay is transport, not a business fact.
Other code on the same state
The state is the uplink connection and the calls in flight on it. Every
call shares the one connection in order, so a large relay delays the
others; #511 accepts this (no flow control).
- Worker link refresh (
worker-link.ts):status.getand the other
refresh calls have a 10 s timeout; two timeouts in a row close the link,
and the closed link wins: the relay fails as an uplink drop. - Forwarded
lease.request(gateway.leaseRequestTimeoutMs) and
device.exec(gateway.execTimeoutMs) infleet-coordinator.ts: a
delayed answer can time out asWORKER_UNREACHABLEwhile the worker
granted the lease, which the gateway already handles as "Dispatched, then
the uplink drops". - Worker events (
events.subscribepushes) that drive view refreshes,
and the component relay'sworker.install-component: delayed in the
same way; neither has a deadline the relay can break. - Request-scoped pushes (
server.ts): each routes by frame id, so relay
pushes never mix with another call's. - Connection close (
server.ts#closeConnection): today it silences
pushes; it now also aborts that connection's relays. A command run by
device.execstill keeps running (pinned today bysrc/daemon/server.test.ts
"stops pushing when the connection dies, and lets the command finish
anyway"). - The component relay (
component-relay.ts): now asks the shared
function whether the uplink is lost, soDAEMON_STOPPINGand the other
transport errors read asunknownthere too, not only
DAEMON_CONNECTION_LOST. - Forwarded
lease.renew,lease.releaseandlease.cancel
(#forwardToWorker): have no timeout. A renewal delayed behind a large
relay can arrive after the lease's deadline, and the lease then expires on
the worker. Drive simlock devices with agent-device through a gateway #511 accepts this with "no flow control"; a worker link that
misses twostatus.getreads in a row closes, which fails the relay and
every call on it withWORKER_UNREACHABLE.
Rules in play
architecture.md: the core names no relay target; targets register
through an option. Every cross-process wait is bounded (deadline, window,
buffer). One translation of a lost uplink.safety.md: every relay field is wire input, bounded before use, in both
directions.testing.md: order, cancel and drop each fail a named assertion.
Tests
Seam: real gateway and worker daemons in one process over a real uplink,
modelled on src/daemon/gateway-fleet.e2e.test.ts. The worker gets a fake
relay target through relayTargets; the test gets the gateway's
GatewayRelay through gatewayRelay; schema bounds through
src/contract/operations.test.ts.
- a
GETrelayed to the fake target arrives with its URL (origin, path and query), method, headers, tenant and lease id, and its status, headers and body come back unchanged - a 20 MiB
PUTbody arrives at the target byte for byte, and a 20 MiB response body comes back byte for byte inrelay-chunkpushes of at most 256 KiB - while a 20 MiB relay runs, a
lease.renewforwarded to the same worker answers - the gateway never has more than 4
relay.bodycalls unanswered, checked with a target that reads its body slowly - a target that answers 401 without reading its body: the caller gets the 401, every
relay.bodyis answered, and the gateway stops reading the caller's body - a response the target streams in parts reaches the gateway caller part by part, before the target's body ends
- 204, 205 and 304 answers and a
HEADanswer reach the caller with a null body, and the worker sends norelay-chunkfor them - a caller that reads nothing while the target streams 80 MiB slowly gets its body stream errored, with a message saying the reader fell behind, once 64 MiB is buffered, and the target's request signal aborts before it has sent all 80 MiB
- aborting the caller's request signal before the head comes fails the call; after the head, it errors the caller's body stream; either way the target's request signal aborts and
relay.requestanswers{ cancelled: true }with no push after the cancel - a caller's request body that errors mid-upload aborts the target's request signal
- a target that never answers is cancelled after a 200 ms deadline, and the caller gets
WORKER_UNREACHABLEwith messageworker <id> did not finish the relayed request within 200 ms; one that stalls after its head gets its body stream errored with that message - closing the uplink mid-relay fails the caller with
WORKER_UNREACHABLE, or errors its body stream after the head, and aborts the target's request signal - a relay to a worker with no uplink fails with
WORKER_UNREACHABLEand the target sees nothing - a relay naming a target nobody registered fails with
RELAY_TARGET_MISSING - a target that throws fails the call with
INTERNALand the worker logs its message; a response body that errors after the head fails the call withINTERNALafter the chunks already sent - a relay and a
device.execwith output running at once on one worker each get only their own pushes relay.request,relay.bodyandrelay.cancelfrom an admin session that is not the gateway's uplink answerFORBIDDEN- a
relay.requestreusing an activerelayIdfailsBAD_REQUEST; arelay.bodyfor an unknown relay id answers{};relay.cancelfor an unknown relay id answers{} - a
relay.bodywith aseqout of order, a repeatedseq, a chunk afterend, or any chunk for a relay opened withhasBody: falsefailsBAD_REQUESTand aborts the target's request - each bound in the field table is refused
BAD_REQUESTone past its limit (a 65-character relay id, an upper-case target, an unknown method, a relative orfile:URL, a 4097-character URL, 101 headers, a header name with a space, a value with a newline, an 8193-character value, a 301-character tenant, a bad lease id,GETwithhasBody), and passes at its limit - the caller's
authorization,x-agent-device-token,connectionandtransfer-encodingheaders never reach the target, and a target'scontent-lengthandconnectionheaders never reach the caller - a target answering 101 headers fails the call with
INTERNAL, and the worker logs it - a 17th relay on one connection fails
BAD_REQUEST; the gateway's 9th relay to one worker starts only after one of 8 ends - a
relay.bodyframe sent right behind itsrelay.requestframe is never dropped (checked with seq 0 plusendsent back to back, 50 times) - a chunk that is not standard base64, and one that decodes to 262 145 bytes, fail
BAD_REQUEST
Gateway-side protocol violations are driven by writing raw frames into the
client's wire (src/simlock-client/wire.ts) over an in-memory connection,
modelled on the wire's existing tests, with the gateway's GatewayRelay on
top:
- a
relay-headwith status 101, and arelay-chunkthat is not base64, fail the relay withINTERNALnaming the worker, and the gateway sendsrelay.cancel relay.requestanswering{}with no head, and{ cancelled: true }with no cancel sent, fail the relay withINTERNALsaying the worker ended it without a response- a worker
INTERNALafter the head errors the caller's body stream with that message DAEMON_STOPPINGfrom a worker reads as a lost uplink in the relay, the coordinator and the component relaySimlockAdminClientassimlock/adminexports it has no relay methodsRELAY_TARGET_MISSINGmaps to HTTP 503 and CLI exit 1
Done when
- Every Tests line passes.
docs/internal/ARCHITECTURE.md's gateway section names the relay
operations, anddocs/internal/COMPONENTS.mdlistsGatewayRelayand the
worker's relay targets.
Out of scope
- Registering agent-device as a target, and any HTTP route that uses the
relay (feat(agent-device): open, commands, heartbeats and close through a gateway #515). - Binary frames and flow control for the uplink as a whole.
Depends on
Approval
- Approved for delivery
Written by an agent.
Activity
[-]feat(fleet): a lease request may require a capability[/-][+]feat(fleet): a gateway relays an HTTP exchange to a worker module over the uplink[/+]feat: a gateway relays one HTTP exchange to a module on a worker (#514)
feat: a gateway relays one HTTP exchange to a module on a worker (#514)
chore: fallow entries for the relay (#514)
docs: relay pushes and uplink-lost wording (#514)
fix: relay head, cancel and close edges; sharper relay tests (#514)
fix: the stack bumps the protocol once, to 33 (#514)
fix: the 32 row names what 32 cannot read (#514)
feat(gateway): relay one HTTP exchange to a module on a worker (#528)
chore(agents): specs cite their facts, and a split ends with tasks re…