Two-way relay over the uplink
メンテナーはふだん 1 日以内に返信
評価
- 難易度
- 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 の本文から書いたものです。
説明
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.
- 主要言語
- TypeScript
- スター
- 22
- フォーク
- 2
- 平均マージ
- 9時間 14分
- マージ済み PR(30日)
- 425
環境構築
このプロジェクトには開発コンテナ、Dockerfile、コントリビューションガイドがありません。まず README を読み、一般的な手順ははじめてのコントリビューションガイドを参照してください。
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
callstackincubator/simlock のほかの issue
-
bug:new
難易度 2/5 1〜3時間 初心者へのやさしさ 82/100
callstackincubator/simlock#1035 ·
メンテナーはふだん 1 日以内に返信
-
bug:ready
難易度 2/5 1〜3時間 初心者へのやさしさ 70/100
callstackincubator/simlock#639 · コメント 3 件 ·
メンテナーはふだん 1 日以内に返信
-
feature:spec
難易度 5/5 1週間以上 初心者へのやさしさ 12/100
callstackincubator/simlock#1024 ·
メンテナーはふだん 1 日以内に返信
-
iOS simulator input through the HID injector対応中かも @V3RON が 1 日前に担当しました。 オープンtask:blocked
難易度 5/5 1週間以上 初心者へのやさしさ 20/100
callstackincubator/simlock#1019 · コメント 1 件 ·
メンテナーはふだん 1 日以内に返信
-
Android input through scrcpy's control channel対応中かも @V3RON が 1 日前に担当しました。 オープンtask:blocked
難易度 4/5 3〜5日 初心者へのやさしさ 42/100
callstackincubator/simlock#1018 · コメント 1 件 ·
メンテナーはふだん 1 日以内に返信
callstackincubator/simlock の issue をすべて見る
似ている issue
-
難易度 2/5 1〜3時間 初心者へのやさしさ 75/100
メンテナーはふだん 1 日以内に返信
-
難易度 1/5 1〜3時間 初心者へのやさしさ 84/100
-
難易度 2/5 1〜3時間 初心者へのやさしさ 83/100
keptinventory/kept#336 ·
メンテナーはふだん 1 日以内に返信
-
good first issue
難易度 2/5 1〜3時間 初心者へのやさしさ 84/100
-
hemi-earn
難易度 2/5 1〜3時間 初心者へのやさしさ 72/100
vetro-protocol/vetro-monorepo#1013 ·
メンテナーはふだん 1 日以内に返信