Hacktoberfest 2026:メンテナが10月に向けて印を付けた、オープンで初心者向けの issue。 Hacktoberfest の issue を見る

Two-way relay over the uplink

オープン
#1,016 コメント 1 件 リアクション 0 件 担当者 1 名 GitHub で見る

メンテナーはふだん 1 日以内に返信

@V3RON がすでに取り組んでいます。

2026年10月26日 から。

  • #1026 @V3RON による — オープン

評価

難易度
4/5
見積もり時間
3〜5日
初心者へのやさしさ
42/100
issue の種類
機能追加
明瞭さ
明確に書かれている
活発さ
活発
技術スタック
typescript

調査の方向性

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.

索引モデルが issue の本文から書いたものです。

説明

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.

主要言語
TypeScript
スター
22
フォーク
2
平均マージ
9時間 14分
マージ済み PR(30日)
425

環境構築

このプロジェクトには開発コンテナ、Dockerfile、コントリビューションガイドがありません。まず README を読み、一般的な手順ははじめてのコントリビューションガイドを参照してください。

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

callstackincubator/simlock のほかの issue

callstackincubator/simlock の issue をすべて見る

似ている issue

TypeScript の issue をもっと見る

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。