docs(arch): resolve OQ-45 — no application-level windowing; QUIC per-stream flow control suffices

The backpressure chain is complete by construction: QUIC flow control
→ bounded drainer channel → bounded stdout channel → OS pipe/PTY buffer
→ process write() blocks. Every link awaits its producer; no unbounded
buffer breaks the chain. The reversal path (an additive ControlMessage
variant, not a wire-format header change) is noted in ADR-052's
consequences as a two-way door, not the expected path.

Tightened ADR-052 assumption 1 from a hedge ('expected to work; a POC
would confirm') to a decided constraint. Updated all OQ-45 references
across the tty spec docs from 'open (low risk)' to 'resolved'.
This commit is contained in:
glm-5.2 committed 2026-07-07 09:36:18 +00:00
1 parent df5d04af1d
commit db4da1e6f0
7 files changed
+131 -59

No files matched your search

+2 -2
View File
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-07-06
last_updated: 2026-07-07
---
# alknet-tty
@@ -48,7 +48,7 @@ the PTY (docker, SSH, local process) via a `TtyBackend` trait.
|----|-------|--------|-----------|
| OQ-43 | `TtyControl` trait object `Clone` constraint | resolved | `control: Option<Box<dyn TtyControl + Send + Unpin + Clone>>` via an `Arc`-backed `Clone` newtype; confirmed by the POC's concrete `PtyControl` |
| OQ-44 | Terminal modes (TTY modes) | deferred(scope) | `TerminalParams.modes` reserved; default terminal modes suffice for current scope; blocked on a concrete mode-control use case |
| OQ-45 | Flow control for high-throughput stdout | open (low risk) | QUIC per-stream flow control is expected to suffice; a high-volume POC would confirm |
| OQ-45 | Flow control for high-throughput stdout | resolved | QUIC per-stream flow control is the backpressure mechanism (chain complete by construction); no application-level windowing. Reversal is an additive `ControlMessage` variant, not a wire-format change |
| OQ-46 | Runner API surface | deferred(scope) | The runner mechanism (pipe mode) is in alknet-tty; runner policy (job management, log persistence, task graph) is a downstream crate, not in scope here |
| OQ-47 | Stdin closure canonical signal | resolved | Either a zero-length stdin chunk or a `{"type":"eof"}` control chunk; both are accepted; the spec recommends `eof` for explicitness |
+2 -2
View File
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-07-06
last_updated: 2026-07-07
---
# alknet-tty — Overview
@@ -263,7 +263,7 @@ See [open-questions.md](../../open-questions.md) for full details.
- **OQ-43** (resolved): `TtyControl` as a `Clone` trait object.
- **OQ-44** (deferred(scope)): Terminal modes (TTY modes).
- **OQ-45** (open, low risk): Flow control for high-throughput stdout.
- **OQ-45** (resolved): Flow control for high-throughput stdout — no application-level windowing; QUIC per-stream flow control is the backpressure mechanism.
- **OQ-46** (deferred(scope)): Runner API surface.
- **OQ-47** (resolved): Stdin closure canonical signal.
+31 -27
View File
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-07-06
last_updated: 2026-07-07
---
# alknet-tty — TtyAdapter and Session Lifecycle
@@ -204,16 +204,19 @@ revokes. The adapter's access control declares against the ADR-050 model:
`identity.scopes` for the `tty:open` scope (or a deployment-configured
scope) before allocating the session. A caller without the scope gets
a negotiation error (`{"error":"forbidden"}`) and the stream closes.
- **Resource ownership for backend-specific resources.** A docker
backend's session targets a specific container (`BackendParams::Docker
{ container }`); the adapter extracts the container ID via the
`OperationSpec.resource_id_path` analog (here, a hardcoded field — the
tty adapter is not an `OperationSpec`, so the extraction is direct
from the negotiation frame's `container` field) and checks
`OwnershipProvider::owns(identity, "container", id, "tty")` if an
ownership provider is wired. A local backend's session has no
pre-existing resource — the process is the resource, and the caller
that opened the session owns it by construction.
- **Resource ownership for backend-specific resources.** Some
backends target a pre-existing resource (a docker backend targets a
specific container); others create their own (a local backend's
process, an SSH backend's channel). The adapter delegates the
resource-id extraction to the backend via
`TtyBackend::resource_id(&params)` (ADR-053), which returns
`None` (no pre-existing resource — the session creates its own) or
`Some((kind, id))` (the caller must own this resource). The adapter
checks `OwnershipProvider::owns(identity, kind, id, "tty")` if an
ownership provider is wired and the backend returns `Some`. The
adapter does not parse backend-specific JSON itself — the extraction
is backend-driven, so adding a backend with a new resource shape (e.g.,
a Kubernetes backend targeting a pod) requires no adapter change.
- **`forwarded_for` for proxied sessions.** A hub that proxies a
terminal session to a worker carries the end user's identity as
`forwarded_for` (ADR-032); the worker authorizes the hub (its direct
@@ -222,22 +225,23 @@ revokes. The adapter's access control declares against the ADR-050 model:
The tty adapter is a `ProtocolHandler`, not an `OperationSpec`-registered
operation — it doesn't go through the call protocol's
`OperationRegistry::invoke()`. The access-control shape is the adapter's
own (scope-gate + ownership check at negotiation), declaring against the
ADR-050 model but not consuming `OperationSpec.resource_id_path` (that
field is for call-protocol operations; the tty adapter is its own
ALPN). See ADR-050 §"Specifics" for the model this declares against.
own (scope-gate + backend-driven ownership check at negotiation),
declaring against the ADR-050 model but not consuming
`OperationSpec.resource_id_path` (that field is for call-protocol
operations; the tty adapter is its own ALPN, and the resource-id
extraction is delegated to the backend via `resource_id()` rather than
a path expression). See ADR-050 §"Specifics" for the model this declares
against.
The concrete choices — the scope name (`tty:open`), the check-at-
negotiation timing, and the hardcoded `container` field extraction for
docker backend-specific resources (rather than a generic path like
`OperationSpec.resource_id_path`) — are **two-way-door** choices within
the one-way `TtyAdapter` shape. The scope name can be renamed (a
deployment-configured scope, not a wire-format constant); the hardcoded
field extraction can be generalized if a second backend with a different
resource-id location appears (the adapter would branch by backend). No
ADR is warranted for these until a second backend forces the
generalization — they are reversible implementation choices, not
architectural commitments.
The concrete choice — the scope name (`tty:open`) and the
check-at-negotiation timing — is a **two-way-door** choice within the
one-way `TtyAdapter` shape. The scope name can be renamed (a
deployment-configured scope, not a wire-format constant). The
resource-id extraction is backend-driven via `resource_id()`, so new
backends with new resource shapes require no adapter change — the
generalization is already in place (ADR-053). No ADR is warranted for
the scope name; it is a reversible implementation choice, not an
architectural commitment.
### Connection and Stream Lifecycle
@@ -291,7 +295,7 @@ architectural commitments.
See [open-questions.md](../../open-questions.md) for full details.
- **OQ-45** (open, low risk): Flow control for high-throughput stdout.
- **OQ-45** (resolved): Flow control for high-throughput stdout — no application-level windowing; QUIC per-stream flow control is the backpressure mechanism.
## References
+16 -7
View File
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-07-06
last_updated: 2026-07-07
---
# alknet-tty — Wire Format
@@ -96,9 +96,11 @@ Fields:
- `env` — environment variables (empty = inherit).
Backend-specific selector fields ride alongside (e.g., `"container":
"abc123"` for docker). The adapter parses the negotiation frame, extracts
the `backend` string, and passes the backend-specific fields to the
selected backend's `allocate()` as `BackendParams` (ADR-053).
"abc123"` for docker). The adapter parses the negotiation frame,
extracts the `backend` string, and passes the remaining backend-specific
fields to the selected backend's `allocate()` as an opaque
`serde_json::Map` (ADR-053) — the adapter does not interpret them; the
backend deserializes its own strongly-typed params struct.
After the negotiation frame, the stream switches to raw chunks. There is
no `call.responded`/`call.completed` — this is not the call protocol.
@@ -239,8 +241,13 @@ a session — see [tty-adapter.md](tty-adapter.md).
and servers parse. A 5th channel type requires a new ALPN
(`alknet/tty/v2` per ADR-006), not a negotiated addition.
- **No windowing.** The chunk format has no flow-control window; QUIC's
per-stream flow control handles backpressure. See OQ-45 (high-throughput
stdout; expected to suffice; a POC would confirm).
per-stream flow control is the backpressure mechanism (OQ-45 resolved:
the backpressure chain is complete by construction — QUIC flow control
→ bounded drainer channel → bounded stdout channel → OS pipe/PTY
buffer → process `write()` blocks; no unbounded buffer breaks the
chain). The reversal path, if ever needed, is an additive
`ControlMessage` variant on stream_type 3, not a wire-format header
change.
- **No negotiation round-trip.** The client writes the negotiation frame
and starts sending chunks; the server reads the frame and starts
pumping. There is no "the server acknowledges the negotiation before
@@ -268,7 +275,9 @@ a session — see [tty-adapter.md](tty-adapter.md).
See [open-questions.md](../../open-questions.md) for full details.
- **OQ-44** (deferred(scope)): Terminal modes.
- **OQ-45** (open, low risk): Flow control for high-throughput stdout.
- **OQ-45** (resolved): Flow control for high-throughput stdout — no
application-level windowing; QUIC per-stream flow control is the
backpressure mechanism.
- **OQ-47** (resolved): Stdin closure canonical signal.
## References
@@ -260,6 +260,16 @@ exception.)
intentional (the tty session is not a call-protocol operation) but
means the tty client is a separate small client, not a `CallClient`
method.
- **No application-level flow-control window (OQ-45 resolved).** The
chunk format carries no window; backpressure relies on QUIC's
per-stream flow control composing with the bounded channels in the
adapter's pump and the OS pipe/PTY buffer. This is sufficient by
construction (see §Assumptions 1). If a workload ever requires
sub-QUIC-window backpressure signaling, the reversal is an additive
`ControlMessage::WindowUpdate` (or similar) variant on stream_type 3
— a two-way-door extension to the control channel, not a wire-format
header change. The `type`-tagged enum's "unknown types ignored" rule
means older peers degrade gracefully if such a variant is introduced.
## Door type
@@ -277,11 +287,18 @@ not.
## Assumptions
1. **QUIC per-stream flow control is sufficient for terminal output.**
The chunk format has no windowing — QUIC's bidi-stream flow control
handles backpressure. High-throughput stdout (e.g., `cargo build`
output) is expected to work; a concrete high-volume use case that
surfaces a flow-control problem would require revisiting. See OQ-45.
1. **No application-level windowing; QUIC per-stream flow control is the
backpressure mechanism.** The chunk format carries no window field.
This is decided (OQ-45 resolved): the backpressure chain from a slow
client read all the way back to the child process's stdout write is
complete by construction — QUIC flow control → bounded drainer
channel → bounded stdout channel → OS pipe/PTY buffer → process
`write()` blocks. Every link awaits its producer; no unbounded buffer
breaks the chain. The reversal path, if a workload ever surfaces a
problem QUIC's defaults cannot handle, is an additive window-update
`ControlMessage` variant (a two-way-door extension to the control
channel, not a wire-format header change) — noted in §Consequences.
See OQ-45.
2. **The negotiation frame fits in one chunk of the underlying stream's
initial flow-control window.** The frame is small (terminal params +
+2 -2
View File
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-07-06
last_updated: 2026-07-07
---
# Open Questions
@@ -145,7 +145,7 @@ Door type is separate from whether a decision is made. A two-way door is a decis
|----|-------|--------|------|-----|
| [OQ-43](questions/043-ttycontrol-as-a-clone-trait-object.md) | `TtyControl` as a `Clone` trait object | resolved | one | med |
| [OQ-44](questions/044-terminal-modes-tty-modes.md) | Terminal Modes (TTY modes) | deferred(scope) | two | low |
| [OQ-45](questions/045-flow-control-for-high-throughput-stdout.md) | Flow Control for High-Throughput stdout | open | two | low |
| [OQ-45](questions/045-flow-control-for-high-throughput-stdout.md) | Flow Control for High-Throughput stdout | resolved | two | low |
| [OQ-46](questions/046-runner-api-surface.md) | Runner API Surface | deferred(scope) | two | low |
| [OQ-47](questions/047-stdin-closure-canonical-signal.md) | Stdin Closure Canonical Signal | resolved | two | low |
@@ -3,20 +3,62 @@
- **Origin**: `docs/research/alknet-tty/phase-0-findings.md` OQ-TTY-03;
[crates/tty/tty-wire.md](crates/tty/tty-wire.md) (no windowing in the
chunk format).
- **Status**: open (low risk)
- **Status**: resolved
- **Door type**: Two-way
- **Priority**: low
- **Resolution**: The chunk format has no windowing — QUIC's per-stream
flow control handles backpressure. This is expected to suffice for
high-throughput stdout (e.g., `cargo build` output): the docker POC's
logs subscription handled multi-line output without issue, and QUIC's
bidi-stream flow control is the established backpressure mechanism. A
POC with real high-volume output (a deliberately large `cargo build`
or `find /` over a high-bandwidth connection) would confirm. If a
flow-control problem surfaces, the reversal is a per-stream
flow-control window in the chunk format (a two-way-door extension to
the wire format, additive — a new control message type for window
updates, not a header change). Not blocking; the default assumption is
QUIC flow control suffices.
- **Resolution**: No application-level windowing. QUIC's per-stream flow
control is the backpressure mechanism — the chunk format carries no
window. This is decided, not a default assumption.
The backpressure chain is complete by construction — every link awaits
its producer, so slowness at the client's read end propagates all the
way back to the child process's stdout write without any unbounded
buffering link:
1. **Client reads slowly → QUIC flow control.** The server's
`write_chunk` writes into a quinn `SendStream`, which respects
per-stream flow control. When the client's receive window fills,
the send awaits. (Standard, mature quinn behavior — not something
alknet-tty inventes.)
2. **Send awaits → drainer channel backpressures.** The drainer task
writes chunks to the client one at a time. When `write_chunk`
awaits, the drainer stops pulling from the bounded `writer_rx`
channel (depth 64 in the POC's `session.rs`).
3. **Drainer channel fills → stdout pump backpressures.** The
stdout→client pump does `writer_tx.send(chunk).await`; when the
drainer isn't pulling, the send awaits.
4. **Stdout pump awaits → backend stdout channel backpressures.** The
pump stops receiving from the backend's stdout channel (the local
backend's `mpsc::Receiver<Bytes>`, also bounded at 64).
5. **Stdout channel fills → reader thread backpressures.** The
backend's reader thread (`portable_pty` master reader, or a piped
`Child::stdout`) does `blocking_send`; when the channel is full,
the std thread blocks and stops reading from the kernel.
6. **Reader thread stops → OS pipe/PTY buffer fills → process blocks.**
The kernel PTY buffer (or `Stdio::piped()` buffer, typically 64 KiB)
fills, and the child process's `write()` to stdout blocks. The
process is throttled.
Every link in the chain awaits its producer. The chain is the standard
composition of QUIC flow control + bounded tokio channels + OS pipe
buffers — the same pattern the docker POC's logs subscription and the
tty POC's PTY pump already use. A high-throughput stdout workload
(e.g., `cargo build` output) throttles at the process when the client
can't keep up; no unbounded buffer breaks the chain.
The two-way-door reversal — a per-stream window-update control message
on stream_type 3 — is an additive extension to the control channel
(a new `ControlMessage` variant), not a wire-format header change. It
is not the expected path; it is noted in ADR-052's consequences as the
cheap reversal if a flow-control problem ever surfaces that QUIC's
defaults cannot handle (e.g., a pathological stream that needs
sub-QUIC-window backpressure signaling). Tuning concerns (read buffer
size, channel depth) are implementation-level, not architectural, and
don't warrant an ADR.
- **Cross-references**: ADR-052, [tty-wire.md](crates/tty/tty-wire.md),
[tty-adapter.md](crates/tty/tty-adapter.md)
[tty-adapter.md](crates/tty/tty-adapter.md),
`/workspace/alknet-tty-poc/src/session.rs` (the three-pump driver —
the bounded channels at every link),
`/workspace/alknet-tty-poc/src/local_pty.rs` (the blocking→async
bridge — the reader thread that backpressures into the kernel).