docs: correct WS framing claims from spike; add implementation plan
Spike against alkcall source resolved ADR-067 assumptions: - write_chunk issues header+payload as separate write_alls; channel 0's write_frame issues prefix+body separately — a logical write can surface as multiple chunks, so the WS adapter must parse outgoing chunk boundaries (byte-stream treatment both directions), not assume write-per-chunk or message-per-chunk - MAX_CHUNK_LEN is 16 MiB; the WS path needs a practical message cap with oversized chunks split across messages - install_channel_zero + run_loop_single_stream confirmed as the exact server-side seam; EOF/teardown invariants already specified by alkcall (REQ-CH-01/02) Corrections applied to websocket.md, ADR-067, OQ-01. docs/plans/implementation.md: scoped plan guiding task decomposition — spike findings, 4-phase build order, OQ dispositions, task conventions.
This commit is contained in:
@@ -46,18 +46,23 @@ in the alknet design). The path is an axum route on the `HttpAdapter`
|
||||
router, subject to the same reserved-path collision rule as any
|
||||
default-surface route ([ADR-046](046-assembly-layer-custom-http-routes.md)).
|
||||
|
||||
### Framing: the WS message boundary carries chunks, not envelopes
|
||||
### Framing: the chunk header is the boundary, not the WS message
|
||||
|
||||
The alknet design's "one `EventEnvelope` = one binary WS message, no
|
||||
length prefix" framing is **superseded**. The WS message boundary now
|
||||
carries channels chunks; the call protocol's envelopes ride inside
|
||||
channel 0 as length-prefixed JSON (alkcall ADR-014 frame format), the
|
||||
same as any other in-line channels transport.
|
||||
length prefix" framing is **superseded**. The WS binary message stream
|
||||
is treated as a byte stream; the 8-byte chunk header is the only
|
||||
framing. Call-protocol envelopes ride inside channel 0 as
|
||||
length-prefixed JSON (alkcall ADR-014 frame format), the same as any
|
||||
other in-line channels transport. A chunk may span WS messages and a
|
||||
WS message may carry chunk fragments (in practice the write path
|
||||
usually produces one chunk per message, but nothing may depend on
|
||||
it — channel 0's frame writer issues two writes, prefix then body,
|
||||
which can surface as two chunks).
|
||||
|
||||
Layering on the wire, for a call frame over WS:
|
||||
|
||||
```
|
||||
WS binary message
|
||||
WS binary message(s) — byte stream
|
||||
└── chunk header [channel_id: u32 BE][length: u32 BE] (8 bytes)
|
||||
└── payload = frame [len: u32 BE][EventEnvelope JSON] (channel 0)
|
||||
```
|
||||
|
||||
@@ -22,15 +22,22 @@ with their resolutions; new alkhttp OQs start at OQ-01.
|
||||
needs nailing down before implementation:
|
||||
(a) inbound buffer bound (bounded channel between the WS read task
|
||||
and the `AsyncRead` half — what bound, what policy on overflow);
|
||||
(b) write-side chunk completeness (the adapter assumes the mux emits
|
||||
each chunk as one contiguous `write_all` — verify against alkcall's
|
||||
`MuxRunner` and codify, or add an internal chunking layer);
|
||||
(b) write-side chunk boundary parsing (verified against alkcall
|
||||
source: the mux emits one mpsc payload per chunk, but a logical
|
||||
write above the mux — e.g. channel 0's `write_frame`, which issues
|
||||
prefix and body as separate `write_all`s — can surface as multiple
|
||||
chunks; the adapter must parse outgoing chunk headers rather than
|
||||
assume write-per-chunk; also confirm the WS-message cap policy for
|
||||
chunks up to `MAX_CHUNK_LEN` = 16 MiB — split across messages, and
|
||||
what the practical cap is for browser stacks);
|
||||
(c) flush mapping (`AsyncWrite::flush` → WS message emission point);
|
||||
(d) close mapping (WS close code → transport EOF → REQ-CH-02
|
||||
teardown; and does `AsyncWrite::shutdown` map to a WS Close frame or
|
||||
to a zero-length chunk sentinel?).
|
||||
- **Blocked on**: nothing (implementation-blocking, not
|
||||
decision-blocking — resolve during implementation of the WS adapter)
|
||||
teardown; `AsyncWrite::shutdown` maps to the zero-length EOF
|
||||
sentinel (REQ-CH-01) then a WS Close frame — confirm this ordering
|
||||
against the mux's pump-exit behavior).
|
||||
- **Blocked on**: nothing (the spike resolved the factual
|
||||
sub-questions; the remaining items are implementation decisions to
|
||||
lock during the WS adapter task)
|
||||
|
||||
### OQ-02: `/publish` body framing details
|
||||
|
||||
|
||||
@@ -112,9 +112,14 @@ WS binary message (message boundary = transport frame)
|
||||
channel N: handler-owned framing (opaque to channels)
|
||||
```
|
||||
|
||||
- **One WS binary message = one chunk** (header + payload). The WS
|
||||
message boundary is the chunk boundary — no re-splitting, no
|
||||
coalescing across messages required.
|
||||
- **WS messages are transport frames, not protocol boundaries.** The
|
||||
WS binary message stream is treated as a byte stream; the 8-byte
|
||||
chunk header is the only framing. A chunk may span WS messages and a
|
||||
WS message may carry chunk fragments — the adapter (below) is the
|
||||
seam. (In practice the write path usually emits one chunk per
|
||||
message, but nothing may depend on it: channel 0's frame writer
|
||||
issues two writes — length prefix, then body — which the mux can
|
||||
deliver as two mpsc payloads.)
|
||||
- **Channel 0's payload is the call protocol's frame format**
|
||||
(alkcall ADR-014): a 4-byte big-endian length prefix + UTF-8 JSON
|
||||
`EventEnvelope`. This is exactly the framing channel 0 uses over
|
||||
@@ -141,17 +146,23 @@ whole `Message`s. The adapter bridges the two, in both directions:
|
||||
drains the buffer. Backpressure: the reader task awaits a bounded
|
||||
buffer slot before admitting the next message (bound: OQ-01).
|
||||
- **Outbound (bytes → WS):** the `AsyncWrite` half accumulates bytes
|
||||
into a pending buffer and emits exactly one WS binary message per
|
||||
chunk — the mux writes header+payload as one contiguous
|
||||
`write_all` (the channels mux's `MuxRunner` composes chunks
|
||||
atomically), so the adapter's write-side job is buffer-until-chunk-
|
||||
complete, then flush as one message. Flush semantics and the
|
||||
chunk-completeness assumption are OQ-01 items to verify against
|
||||
alkcall's `MuxRunner` behavior.
|
||||
into a pending buffer; a background task scans the pending bytes for
|
||||
complete chunks (8-byte header → payload length) and emits each
|
||||
complete chunk as one WS binary message, carrying any partial tail
|
||||
until its chunk completes. The adapter **parses the outgoing byte
|
||||
stream** to find chunk boundaries — it does not assume one write
|
||||
equals one chunk (verified against alkcall: `write_chunk` issues
|
||||
header+payload as separate writes, and channel 0's `write_frame`
|
||||
issues prefix+body as separate writes; each can surface as separate
|
||||
mux payloads). Oversized chunks (a single chunk exceeding the
|
||||
WS-message cap, up to `MAX_CHUNK_LEN` = 16 MiB) are split across
|
||||
multiple WS messages — legal, since the receiver's boundary is the
|
||||
chunk header, not the message. Flush semantics are an OQ-01 item.
|
||||
- **Close mapping:** WS close (either side) → transport EOF → the
|
||||
demux clears all channels (REQ-CH-02: every handler sees EOF) and
|
||||
channel 0's dispatch loop fails outstanding pendings with
|
||||
`connection closed`.
|
||||
`connection closed`. `AsyncWrite::shutdown` maps to the zero-length
|
||||
EOF sentinel (REQ-CH-01) followed by a WS Close frame.
|
||||
|
||||
The adapter is shared with the `from_wss` consumer path
|
||||
([ADR-070](decisions/070-from-wss-consumer-adapter.md)) — one
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
# Plan: alkhttp Implementation
|
||||
|
||||
Working plan guiding task decomposition and implementation. Less
|
||||
specific than the tasks it produces, more specific than the
|
||||
architecture docs. Status fields in task frontmatter are the source of
|
||||
truth for progress; this document explains the *why* of the ordering.
|
||||
|
||||
Source crate: `/workspace/@alkdev/alknet/crates/alknet-http` (~11k LOC).
|
||||
Target: this crate, on alkcall 0.1.1 (crates.io).
|
||||
|
||||
## What the spike established
|
||||
|
||||
A validation pass against alkcall's actual source resolved the factual
|
||||
unknowns that would have shaped tasks incorrectly:
|
||||
|
||||
1. **The WS adapter must parse outgoing chunk boundaries.** The mux
|
||||
emits one mpsc payload per chunk, but a logical write above the mux
|
||||
(channel 0's `write_frame` issues length-prefix and body as two
|
||||
`write_all`s) can surface as multiple chunks. "One write = one
|
||||
chunk" and "one chunk = one WS message" are both unusable as
|
||||
invariants. The adapter treats the WS message stream as a byte
|
||||
stream in both directions and parses the 8-byte header on both
|
||||
read and write sides.
|
||||
2. **Chunk size cap.** `MAX_CHUNK_LEN` is 16 MiB; browser WS stacks
|
||||
and intermediaries commonly cap messages far lower. The WS path
|
||||
needs its own practical cap (default ~1 MiB) with oversized chunks
|
||||
split across messages — legal, since the receiver's boundary is the
|
||||
chunk header.
|
||||
3. **The `install_channel_zero` hook is the exact seam** for the
|
||||
server WS path: alkcall's `ChannelsAdapter` runs the in-line demux
|
||||
loop, and the hook receives channel 0's `Connection` + `AuthContext`
|
||||
— alkhttp's job is to construct the `CallConnection`, attach the
|
||||
bearer-resolved identity, and run
|
||||
`Dispatcher::run_loop_single_stream`. `alkcall`'s own tests
|
||||
(`channels/client.rs`) demonstrate this wiring end-to-end over
|
||||
duplex pairs.
|
||||
4. **EOF/teardown semantics are already specified** by alkcall
|
||||
(REQ-CH-01/02): `AsyncWrite::shutdown` → zero-length sentinel; demux
|
||||
EOF → all channels cleared. The adapter maps WS close to transport
|
||||
EOF and lets alkcall's invariants do the rest.
|
||||
5. **`Dispatcher::run_loop_single_stream` exists** and is the channel-0
|
||||
dispatch loop — no new dispatch code is needed anywhere in alkhttp.
|
||||
|
||||
This de-risks the two "high" tasks (WS adapter, WS session) from
|
||||
"unknown design" to "known shape, careful implementation."
|
||||
|
||||
## Build order (dependency spine)
|
||||
|
||||
```
|
||||
Phase 1 — server foundation (no WS, no adapters)
|
||||
core types → server adapter + auth → gateway dispatch + routes
|
||||
→ verified over tokio DuplexStream end-to-end
|
||||
|
||||
Phase 2 — WS + channels (the novel part)
|
||||
ws byte-stream adapter → ws upgrade handler + channels session
|
||||
→ verified browser-session-style over duplex WS
|
||||
|
||||
Phase 3 — adapters (mostly ports)
|
||||
http client host → from_openapi/from_jsonschema → to_openapi
|
||||
→ from_wss (depends on ws adapter) → mcp feature (from_mcp/to_mcp)
|
||||
→ /publish endpoint + to_openapi v2 of the gateway doc
|
||||
|
||||
Phase 4 — hardening
|
||||
integration test suite (full surface over duplex) → docs sync
|
||||
→ publish prep (dry-run, semver check)
|
||||
```
|
||||
|
||||
Rationale for key orderings:
|
||||
|
||||
- **Server core before WS** because the WS upgrade is a route on the
|
||||
`HttpAdapter` — the router, auth middleware, and decoy must exist
|
||||
first, and they're verifiable without WS (gateway over duplex).
|
||||
- **WS adapter before `from_wss`** — same adapter, both directions;
|
||||
building the consumer first would mean validating the adapter
|
||||
without its hardest user (the axum WS type).
|
||||
- **`/publish` in Phase 3** — the gateway spine (`/call`/`/subscribe`)
|
||||
lands in Phase 1; `/publish` adds a dispatch mode to a working
|
||||
gateway and gates OQ-02's version bump. Building it early would
|
||||
couple an unresolved OQ to the critical path.
|
||||
- **MCP last** — feature-gated, rmcp-heavy, and the gateway dispatch
|
||||
spine it consumes is stable by then.
|
||||
|
||||
## OQ dispositions
|
||||
|
||||
| OQ | Disposition | Where it resolves |
|
||||
|----|-------------|-------------------|
|
||||
| OQ-01 (WS adapter semantics) | Partially resolved by the spike: byte-stream treatment both directions, boundary = chunk header, split oversized chunks, shutdown → EOF sentinel + Close frame. Remaining: exact buffer bounds, flush semantics — locked during the WS adapter task. | `tasks/websocket/` |
|
||||
| OQ-02 (/publish framing) | Resolve in the `/publish` task: first line carries `{operation, chunk}`; terminal error = plain HTTP status + JSON body (not an NDJSON line). Then bump the gateway doc version. | `tasks/gateway/` |
|
||||
| OQ-03 (from_wss reconnect) | v1: connection drop → retryable failures; policy deferred. Documented in ADR-070; no task. | — |
|
||||
| OQ-04 (browser client) | Out of scope for this crate. | — |
|
||||
|
||||
## Conventions for the tasks
|
||||
|
||||
- Topic subdirectories: `tasks/server/`, `tasks/websocket/`,
|
||||
`tasks/gateway/`, `tasks/adapters/`, `tasks/client/`, plus
|
||||
`tasks/infra/` for repo-level concerns (CI, publish prep).
|
||||
- Every task carries the full frontmatter set (`scope`, `risk`,
|
||||
`impact`, `level`) — taskgraph's analysis commands rely on them.
|
||||
- Ported-source references: each task cites the alknet-http source
|
||||
file(s) it ports from, so the implementing agent can diff against
|
||||
the original rather than re-derive.
|
||||
- Verification per task: `cargo test -p <affected>` at minimum; the
|
||||
Phase 4 integration task runs the full `cargo test --all-features`.
|
||||
- No task crosses a subsystem boundary except through its declared
|
||||
`depends_on`.
|
||||
|
||||
## What the tasks will NOT cover
|
||||
|
||||
- The alknet-side wiring (endpoint, TLS, ALPN router registration) —
|
||||
alkhttp exposes `HttpAdapter` as a `ProtocolHandler`; the dial/accept
|
||||
composition is the consumer's job (AGENTS.md convention 9).
|
||||
- A browser/JS client for channels-over-WS (OQ-04, deferred).
|
||||
- WebTransport (ADR-069: out of scope entirely).
|
||||
Reference in New Issue
Block a user