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:
1 parent
df5d04af1d
commit
db4da1e6f0
7 files changed
+131
-59
No files matched your search
@@ -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 |
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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(¶ms)` (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
|
||||
|
||||
|
||||
@@ -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 +
|
||||
|
||||
@@ -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).
|
||||
Reference in new issue
Block a user