Hacktoberfest 2026: le issue che i maintainer hanno segnato per ottobre, aperte e adatte ai principianti. Sfoglia le issue Hacktoberfest

Two-way relay over the uplink

Chiusa
#1,016 1 commento 0 reazioni 1 assegnatario Vedi su GitHub

I maintainer di solito rispondono entro 1 giorno

@V3RON ci sta già lavorando.

Dal 26/10/2026.

  • #1026 di @V3RON — integrata

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

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

task:ready

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

  • 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.cancel and abortAll (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.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

  • 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

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Altre issue di callstackincubator/simlock

Tutte le issue di callstackincubator/simlock

Issue simili

Altre issue su TypeScript

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.