Two-way relay over the uplink
I maintainer di solito rispondono entro 1 giorno
Valutazione
- Difficoltà
- 4/5
- Tempo stimato
- 3-5 giorni
- Idoneità per principianti
- 42/100
- Tipo di issue
- Funzionalità
- Chiarezza
- Specificata chiaramente
- Stato di attività
- Attiva
- Stack tecnologico
- typescript
- Ambito
- backend, distributed-systems
Direzione di ricerca
Start with src/contract/operations.ts around relay.request and src/daemon/relay.ts to see where today's one-way relay cuts off the worker's read side. Trace src/gateway/relay.ts, src/modules/host.ts and src/daemon/main.ts for the gateway relay and module target wiring. Run the existing src/daemon/relay-gateway.e2e.test.ts and src/gateway/relay.test.ts; done when the new two-way, refusal, silence and uplink-drop tests pass and the protocol bump is in src/contract/protocol.ts.
Scritto dal modello di indicizzazione a partire dal testo della issue.
Descrizione
Part of #1013.
Scope
A gateway module can hold a byte channel to a worker module open in both directions for as long as it needs, instead of one request then one response. Nothing user-facing uses it yet; #1017's input socket is its first caller. Through the relay's own test target, a caller sees bytes flow both ways, a silent worker cut off after 15 s, and an uplink drop end every such relay on both sides.
In short
Today a relay carries one HTTP exchange: once the worker's module answers, the rest of what the gateway sends is dropped (F3). A two-way relay keeps both directions open. Ending one direction ends only that direction: a worker that has finished sending can still read what the gateway writes. The relay ends when both directions have ended, or when either side aborts. Bytes are carried as they are; the relay never parses them. While the worker's direction is open, a two-way relay ends with WORKER_UNREACHABLE when 15 s pass with no byte from the worker, counted from the head or from the last byte the relay read off the channel from the worker. The relay reads the worker's direction itself into the module's readable, so a module that reads slowly does not make a live worker look silent. It reads ahead at most 64 KiB; past that it stops reading until the module catches up, and the link holds the rest.
sequenceDiagram
participant M as Gateway module
participant R as Gateway relay
participant WR as Worker relay
participant T as Worker module two-way target (NEW)
M->>R: relayTwoWay(worker, request, tenant, leaseId) NEW
R->>WR: relay.request {twoWay: true} on a channel CHANGED
WR->>T: request
T-->>WR: head 200, then pipe
WR-->>R: relay-head 200
R-->>M: {readable, writable}
M->>T: bytes (gateway → worker)
T-->>M: bytes (worker → gateway), at least every 15 s
Note over R: worker direction open and 15 s with no byte from it → WORKER_UNREACHABLE
T-->>R: END (worker direction done; the gateway may still write)
M->>R: END → both ended, relay done
If this goes wrong, a user of #1017's input socket would notice it cut while they only watch, or a socket that never ends after the worker went away.
Technical spec
Facts
Commit e1f2f43e6.
| # | Fact | Source |
|---|---|---|
| F1 | relay.request takes relayId, target, method, url, headers, tenant, an optional leaseId, hasBody and channel; hasBody is refused with GET or HEAD; the answer comes once the response body has ended |
src/contract/operations.ts:1067-1096 |
| F2 | The head travels as a relay-head push with a status from 200 to 599 |
src/contract/pushes.ts:99-105 |
| F3 | The worker stops reading the request body once its target answers | src/daemon/relay.ts:240-243, src/daemon/relay.ts:44-45 |
| F4 | With no request body, the worker cancels its read side of the channel at once | src/daemon/relay.ts:205-207 |
| F5 | A worker relay target is (request, tenant, leaseId) → Promise<Response>; targets come from each module's relayTarget, keyed by module name |
src/daemon/relay.ts:18-24, src/modules/host.ts:307-310, src/daemon/main.ts:678-681 |
| F6 | An unknown target answers RELAY_TARGET_MISSING |
src/daemon/relay.ts:100-111 |
| F7 | The gateway relay takes {target, request, tenant, leaseId?}; a gateway module reaches it through GatewayContext.relay |
src/gateway/relay.ts:24-29, src/modules/types.ts:202, src/modules/host.ts:223-229 |
| F8 | The gateway bounds the channel and head by 330 s, then the response body by 330 s of silence, re-armed by every chunk from the worker | src/gateway/relay.ts:31-36, src/gateway/relay.ts:100-108, src/gateway/relay.ts:190-195, src/gateway/relay.ts:222-225 |
| F9 | A channel closed with its transport fails the gateway's relay with WORKER_UNREACHABLE; a stalled channel with INTERNAL |
src/gateway/relay.ts:317-345 |
| F10 | A closed uplink connection aborts every relay on the worker | src/daemon/server.ts:1634, src/daemon/relay.ts:143-146 |
| F11 | Closing a channel's writable ends that direction only; cancelling readable stops the peer's writer; a closed transport errors every channel with closed |
src/link/channel.ts:308, src/link/channel.ts:385, src/link/index.ts:220-225 |
| F12 | A channel stalls only while a write waits and the peer holds everything sent, after 60 s | src/link/channel.ts:333-346 |
| F13 | A link holds at most 64 open channels per side | src/link/index.ts:32 |
| F14 | The protocol range is {min: 61, max: 61}; a change to a contract shape file needs a change to the range |
src/contract/protocol.ts:12-13, .agents/scripts/protocol-bump-check.mjs:1-9 |
| F15 | Only the gateway's own uplink session may call relay.request on a worker |
src/daemon/dispatcher.ts:666-674 |
| F16 | relay.request's input is strict and refuses hasBody with GET or HEAD through a refine |
src/contract/operations.ts:1091-1094 |
| F17 | The gateway arms its idle bound when its own read-ahead of the worker's direction returns a chunk | src/gateway/relay.ts:190-202 |
| F18 | GatewayRelayOptions takes a clock; FakeClock exists in ports; src/gateway/relay.test.ts uses real timers (sleep, a setTimeout spy) today |
src/gateway/relay.ts:17-22, src/ports/clock.ts:31, src/gateway/relay.test.ts:15-16, src/gateway/relay.test.ts:272-276 |
Modules touched
Searches: relay.request, relayRequest, RelayTarget, ModuleRelayTarget, relayTargets, GatewayRelay, GatewayRelayCall, DEFAULT_IDLE_MS, hasBody across src/.
src/contract/operations.ts:relay.requestgainstwoWay: true(optional);src/contract/protocol.ts: the bump.src/daemon/relay.ts: a two-way run: no body cut-off, the target gets both directions.src/daemon/main.ts,src/modules/types.ts,src/modules/host.ts:WorkerModule.relayTwoWaytargets collected by module name besiderelayTarget;GatewayContext.relayTwoWay.src/gateway/relay.ts:GatewayRelay.relayTwoWay, with the 15 s silence bound.src/gateway/AGENTS.md,src/daemon/AGENTS.md,docs/internal/flows/if a flow names the relay.
flowchart LR
gm[gateway module] --> host[modules host CHANGED]
host --> grelay[gateway relay CHANGED]
grelay --> link[link channels]
grelay -->|relay.request CHANGED| contract[contract CHANGED]
wrelay[worker relay CHANGED] --> contract
wrelay --> wt[worker module two-way target NEW]
link --> wrelay
Surface and extension points
GatewayContext.relayTwoWay(workerId, request, tenant, leaseId?)→Promise<Response | TwoWayRelay>. A refusal (non-2xx head) is aResponsewith its body, as today. A 2xx head givesTwoWayRelay:{ status, headers, readable, writable }, wherereadablecarries bytes from the worker andwritablebytes to it; cancellingreadableor abortingwritableaborts the relay.WorkerModule.relayTwoWay?: (request, tenant, leaseId, pipe) → Promise<Response>. The target answers a refusalResponsebefore touchingpipe, or a 2xxResponsewith no body and then usespipe = { readable, writable, signal }until it ends. One per module, keyed by the module's name, likerelayTarget(F5).
Contract and event changes
relay.request gains twoWay: z.literal(true).optional(). With twoWay, method must be GET and hasBody false, else BAD_REQUEST, by a second refine beside F16's; the head is still relay-head (F2); the answer comes once both directions have ended, or { cancelled: true }. A worker with no two-way target of that name answers RELAY_TARGET_MISSING (F6). This PR bumps the protocol by one. No event changes.
Other code on the same state
Searches: openChannel( (2 hits: gateway/relay.ts, simlock-client), relays.abortAll (1 hit: daemon/server.ts), relay.cancel (3 hits: contract, gateway relay, worker dispatcher).
- Today's one-way relays (agent-device, stream) share the 64 channels per link (F13) with two-way relays. A two-way relay holds its channel for its whole life; the 65th open waits on its caller's signal, as today.
relay.cancelandabortAll(F10) end a two-way relay as they end a one-way one.- #1017 is the only caller; it writes and reads the bytes (Shared values: Channel framing on #1013).
Failure modes
| Call | Outcome | What happens | Limit, counted from |
|---|---|---|---|
gateway → relay.request {twoWay} |
head 2xx | TwoWayRelay handed to the module |
330 s, from the call (F8) |
gateway → relay.request {twoWay} |
head non-2xx | the refusal Response, relay ends |
same |
gateway → relay.request {twoWay} |
RELAY_TARGET_MISSING |
thrown with its code, as today | same |
| two-way relay | no byte from the worker while its direction is open | WORKER_UNREACHABLE, relay.cancel, channel aborted |
15 s, from the head or the last byte the relay read from the worker |
| two-way relay | the worker ends its direction | the module's readable ends; the gateway may still write; no silence bound from then on |
— |
| two-way relay | uplink closes | gateway side: WORKER_UNREACHABLE on readable and writable; worker side: pipe.signal aborts |
at once (F9, F10) |
| two-way relay | module on the gateway aborts | relay.cancel, channel aborted, the worker target's pipe.signal aborts |
at once |
| two-way relay | the worker target fails after the head | the target aborts pipe.writable (its Response has already settled, so a throw cannot reach the relay); the channel is aborted; the gateway's readable errors |
at once |
| two-way relay | worker does not read for 60 s while the gateway writes | INTERNAL stalled (F12) |
60 s, from the waiting write |
State written: none persisted. A restart of either daemon closes the uplink: the uplink row above.
Rules in play
- Architecture rule 11, "Every wait that crosses a process boundary is bounded": the head keeps its 330 s deadline (#1017 bounds its own open tighter with a signal); after it, 15 s of silence from the worker ends the relay while the worker's direction is open, never reset by bytes from the gateway.
- Architecture rule 10, "A rule is enforced in exactly one place": only
src/gateway/relay.tsbounds silence; modules do not time the channel. src/contract/AGENTS.md, "Every change to a contract shape bumps it by one in the same PR": the bump above.- ADR 0028 (byte channels) is extended by ADR 0049 §5: the link is unchanged; only the relay keeps both directions open.
Tests
Seam: src/daemon/relay-gateway.e2e.test.ts (a real gateway and worker, a fake relay target) for the carrying, refusal and uplink rows; the silence row on src/gateway/relay.test.ts, with a FakeClock passed through GatewayRelayOptions.clock (F18), since that file uses real timers today; the BAD_REQUEST case on src/contract/operations.test.ts.
- A gateway module's two-way relay carries bytes both ways at once until either side ends: both directions together; worker ends its direction and the gateway still writes; gateway ends; both ended, relay done (the worker's answer comes only once both directions have ended, and a caller abort after that sends no
relay.cancel); gateway module aborts, the worker target's signal aborts; the worker target aborts itswritable, the gateway'sreadableerrors (decided in delivery, pending the maintainer: the worker-side rows may run onsrc/daemon/relay.test.ts, the cancel row onsrc/gateway/relay.test.ts). - A gateway module opening a two-way relay gets the worker's refusal or an error: non-2xx head as a
Response; no two-way targetRELAY_TARGET_MISSING;twoWaywith POST or a bodyBAD_REQUEST. - A two-way relay with no bytes from the worker for 15 s ends with
WORKER_UNREACHABLE; bytes every 5 s keep it open: silent 15 s while the gateway writes; a byte every 5 s for 60 s; worker's direction ended, no limit. - An uplink that drops ends every two-way relay on both sides: gateway side gets
WORKER_UNREACHABLE; worker target's signal aborts.
The existing one-way tests in both files stay unchanged and prove one-way relays keep their 330 s bounds and body cut-off.
Done when
pnpm testpasses with the four tests above and the existing relay tests unchanged.PROTOCOL_VERSION_RANGEmoves by one, andnode .agents/scripts/protocol-bump-check.mjspasses.src/gateway/AGENTS.md(its timer table) andsrc/daemon/AGENTS.md(section "Uplink and relays (worker side)") describe the two-way relay and its 15 s bound.
Out of scope
- What travels on the channel: framing, heartbeat, close reasons (#1017).
- Any HTTP route or WebSocket (#1017).
Depends on
Written by an agent.
- Lingua principale
- TypeScript
- Stelle
- 22
- Fork
- 2
- Merge medio
- 9h 19m
- PR unite (30g)
- 430
Preparare l'ambiente
Questo progetto non fornisce container di sviluppo, Dockerfile né guida per i contributori, quindi l'ambiente è a tuo carico: parti dal suo README e consulta la nostra guida al primo contributo per i passaggi generali.
Come iniziare
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Altre issue di callstackincubator/simlock
-
bug:new
Difficoltà 2/5 1-3 ore Idoneità per principianti 82/100
callstackincubator/simlock#1035 ·
I maintainer di solito rispondono entro 1 giorno
-
bug:ready
Difficoltà 2/5 1-3 ore Idoneità per principianti 70/100
callstackincubator/simlock#639 · 4 commenti ·
I maintainer di solito rispondono entro 1 giorno
-
feature:spec
Difficoltà 5/5 Più di una settimana Idoneità per principianti 12/100
callstackincubator/simlock#1024 ·
I maintainer di solito rispondono entro 1 giorno
-
A lease renew right after a gateway restart answers UNKNOWN_LEASE and the client drops the leaseAperta
Difficoltà 4/5 3-5 giorni Idoneità per principianti 45/100
callstackincubator/simlock#983 · 2 commenti ·
I maintainer di solito rispondono entro 1 giorno
-
request:new
Difficoltà 5/5 Più di una settimana Idoneità per principianti 35/100
callstackincubator/simlock#948 · 1 commento ·
I maintainer di solito rispondono entro 1 giorno
Tutte le issue di callstackincubator/simlock
Issue simili
-
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 91/100
caelestia-dots/caelestia#534 · 1 commento ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 78/100
bobadilla-tech/biasmarket.com#402 ·
I maintainer di solito rispondono entro 1 giorno
-
bug good first issue
Difficoltà 2/5 1-3 ore Idoneità per principianti 88/100
javvadivijayprasad/sel2pw#28 ·
-
Area: Infrastructure Chore Difficulty: Low Good First Issue Priority: Low
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 88/100
DouglasNeuroInformatics/OpenDataCapture#1836 ·
I maintainer di solito rispondono entro 1 giorno
-
Two wording fixes: the init PROVIDER comment and the confirm page's ledeForse già presa @kurtbruns l’ha presa oggi. Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 83/100
I maintainer di solito rispondono entro 1 giorno