Skip to content

Two-way relay over the uplink #1016

Closed

Description

@V3RON

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
Loading

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 e1f2f43.

# 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.request gains twoWay: 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.relayTwoWay targets collected by module name beside relayTarget; 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
Loading

Surface and extension points

  • GatewayContext.relayTwoWay(workerId, request, tenant, leaseId?) → Promise<Response | TwoWayRelay>. A refusal (non-2xx head) is a Response with its body, as today. A 2xx head gives TwoWayRelay: { status, headers, readable, writable }, where readable carries bytes from the worker and writable bytes to it; cancelling readable or aborting writable aborts the relay.
  • WorkerModule.relayTwoWay?: (request, tenant, leaseId, pipe) → Promise<Response>. The target answers a refusal Response before touching pipe, or a 2xx Response with no body and then uses pipe = { readable, writable, signal } until it ends. One per module, keyed by the module's name, like relayTarget (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).

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 (Input socket for a lease #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.ts bounds 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 its writable, the gateway's readable errors (decided in delivery, pending the maintainer: the worker-side rows may run on src/daemon/relay.test.ts, the cancel row on src/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 target RELAY_TARGET_MISSING; twoWay with POST or a body BAD_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 test passes with the four tests above and the existing relay tests unchanged.
  • PROTOCOL_VERSION_RANGE moves by one, and node .agents/scripts/protocol-bump-check.mjs passes.
  • src/gateway/AGENTS.md (its timer table) and src/daemon/AGENTS.md (section "Uplink and relays (worker side)") describe the two-way relay and its 15 s bound.

Out of scope

Depends on

Written by an agent.

Activity

  1. added
    task:draftScope written; technical spec or its spec check missing.
    on Oct 26, 2026
  2. added
    task:readySpec checked. An agent may implement it once its Depends on allow.
    and removed
    task:draftScope written; technical spec or its spec check missing.
    on Oct 26, 2026
  3. self-assigned this
    on Oct 26, 2026
  4. V3RON commented on Oct 26, 2026

    @V3RON
    ContributorAuthor

    Handoff

    Done: The two-way relay is built on task/1016 (PR #1026, draft, head e1622ec). Review round 1 found R1–R4, round 2 found R5–R6, and all six are fixed and verified. Decisions on three audit test rows are folded into the Tests section, pending the maintainer.

    Not done: R7 is still open. The cap allows no further review after the fix-check.

    Findings: R7, in src/gateway/relay.ts:325-330. After a peer reset (channel.fail("aborted")), closing the writable rejects with a TypeError. The close handler only handles the STOP case, so this TypeError counts as a channel failure. The caller gets the TypeError and not the stored reason, relay.cancel is sent, and the worker's answer is lost. A similar pattern exists in #upload at relay.ts:226 on main, outside this PR.

    Blocked on: a maintainer decision: allow one more fix and fix-check for R7, or take another path. #1017–#1019 wait on this task.

    Written by an agent.

  5. added
    task:blockedAgent stopped on a blocker in its handoff. Maintainer re-adds the label it came from.
    and removed
    task:readySpec checked. An agent may implement it once its Depends on allow.
    on Oct 26, 2026
  6. removed their assignment
    on Oct 26, 2026
  7. added
    task:readySpec checked. An agent may implement it once its Depends on allow.
    and removed
    task:blockedAgent stopped on a blocker in its handoff. Maintainer re-adds the label it came from.
    on Oct 26, 2026
  8. self-assigned this
    on Oct 26, 2026
  9. added a commit that references this issue on Oct 27, 2026
    c1dea1d
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

task:readySpec checked. An agent may implement it once its Depends on allow.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions