docs: remove em dashes from tty-local spec and ADRs 004/006/007

- Rewrite dash-joined clauses with colons, semicolons, commas, and
  parentheses, varied per instance
- No decision, status, ADR reference, heading structure, or code-fence
  content changed
Verification: grep -c '—' returns 0 for all four files; structure,
links, tables, and fences unchanged
This commit is contained in:
glm-5.3-flash committed 2026-11-01 13:03:57 +00:00
1 parent db0b1fce5c
commit e9e01c04e1
4 files changed
+130 -130

No files matched your search

@@ -3,10 +3,10 @@
## Status
Accepted (ported from alknet ADR-055 2026-08-17; cross-references
renumbered to alktty's ADR range — ADR-052→001, ADR-053→002,
renumbered to alktty's ADR range: ADR-052→001, ADR-053→002,
ADR-054→003, ADR-055→004, ADR-056→005, ADR-057→006, ADR-077→007,
ADR-093→008. The alknet ADR referenced by alknet number (052, 053) is
not ported into alktty's ADR range as a separate ADR — it is ADR-001
not ported into alktty's ADR range as a separate ADR; it is ADR-001
and ADR-002 here. The alknet originals at
`/workspace/@alkdev/alknet/docs/architecture/decisions/` remain
authoritative for any ADR not yet ported.)
@@ -16,11 +16,11 @@ authoritative for any ADR not yet ported.)
The alknet-docker POC validated exit-code propagation for the JSON
carriage path: exec with an exit code rides on a final `call.responded`
frame `{ "exitCode": N }` before `call.completed`. That works because
the JSON carriage path is the call protocol — `call.responded` and
the JSON carriage path is the call protocol: `call.responded` and
`call.completed` exist and carry the result.
The raw-carriage path (ADR-001) has no `call.responded` and no
`call.completed` — after the negotiation frame, the stream is raw
`call.completed`; after the negotiation frame, the stream is raw
chunks. The exit code must ride on the chunk format itself. Two options:
- **(a) Control chunk**: `{"type":"exit","code":N}` as the last control
@@ -28,7 +28,7 @@ chunks. The exit code must ride on the chunk format itself. Two options:
explicit, carries the code as structured data on the channel that
already exists for control metadata.
- **(b) Final data chunk with exit code**: a special stdout chunk with
an exit-code payload. Overloads the data channel for metadata — a
an exit-code payload. Overloads the data channel for metadata: a
client parsing stdout chunks would have to special-case "this stdout
chunk is actually an exit code," conflating data and control.
@@ -45,7 +45,7 @@ the drainer writes it to the client before the writer closes.
Clients will depend on the **"exit chunk is last"** invariant: after
the exit control chunk, no more data chunks follow, and the stream
closes. This is the deterministic completion notification the docker
POC identified as the stopgap coordination property — a coordinator
POC identified as the stopgap coordination property: a coordinator
spawns a process, streams its output, and gets a reliable "it exited
with code N" signal without polling or plugin state. Changing the
ordering after clients exist would break every consumer that reads
@@ -65,13 +65,13 @@ not data (process output). It rides on the control channel
The `code` is an `i32` in this crate's exit-status convention:
`ExitStatus::code()` on normal exit; on Unix, the negative signal
number on signal-terminated exit (e.g., `-9` for SIGKILL) — the same
number on signal-terminated exit (e.g., `-9` for SIGKILL), which is the same
convention as Python's `subprocess.Popen.returncode`. It is **not**
`ExitStatus::code()`'s behavior, which returns `None` for signal
termination. Both local backends encode it: the pipe path via
`ExitStatusExt::signal()` (`src/local/pipe.rs::exit_code_from`), the
PTY path via a direct `libc::waitpid` reap decoded to `WEXITSTATUS` /
`-WTERMSIG` (`src/local/pty.rs::wait_exit_code`) — portable-pty 0.9.0's
`-WTERMSIG` (`src/local/pty.rs::wait_exit_code`); portable-pty 0.9.0's
`ExitStatus` would flatten a signal death to `code = 1` with the signal
only as a strsignal name string, so the waiter reaps by pid and skips
the conversion (review #004). The chunk is the last control chunk
@@ -94,7 +94,7 @@ session end. The exit chunk is the deterministic completion signal.
Per ADR-002 assumption 3, the backend resolves `exit_code` (a
`BoxFuture<'static, Result<i32, TtyError>>`); the adapter awaits it,
sends the exit control chunk, and closes the stream. The backend does
not write to the wire — it produces handles; the adapter pumps. This
not write to the wire: it produces handles; the adapter pumps. This
keeps the wire-format logic (including the "exit is last" invariant) in
one place (the adapter's session driver) and the backend focused on its
allocation target.
@@ -116,14 +116,14 @@ resolve, and the exit chunk is enqueued only after the exit resolves.
The drainer writes chunks in arrival order; the exit chunk is last
because the exit is the last thing to resolve (the child must exit
before its stdout drains, but the exit chunk is sent only after
`exit_code` resolves, which is after `Child::wait()` returns — i.e.,
`exit_code` resolves, which is after `Child::wait()` returns, i.e.,
after the child is reaped).
### 4. Error exit codes
A backend `TtyError` during `allocate()` (the PTY couldn't be
allocated, the docker exec failed to start, the SSH channel request was
rejected) is handled before the raw-carriage phase begins — the adapter
rejected) is handled before the raw-carriage phase begins; the adapter
sends a JSON error response to the negotiation frame and closes the
stream without entering raw mode. See `tty-adapter.md` §"Negotiation
errors".
@@ -144,30 +144,30 @@ the completion notification.
overload of the data channel. Clients parse it as a
`ControlMessage::Exit`, not as a special-cased stdout chunk.
- The "exit chunk is last" invariant gives coordinators deterministic
completion notification — the same stopgap property the docker POC
completion notification: the same stopgap property the docker POC
validated for logs subscriptions. No polling, no plugin state; the
process exiting is the signal.
- The adapter owns the ordering, so the invariant is enforced in one
place; backends don't have to know the wire format's completion
semantics.
- The error-exit `-1` fallback keeps the completion notification
reliable even when the backend can't determine the real code — the
reliable even when the backend can't determine the real code, so the
client still knows the session ended.
**Negative:**
- The "exit chunk is last" invariant is a one-way door — clients depend
- The "exit chunk is last" invariant is a one-way door: clients depend
on it. Reversing it (allowing data chunks after the exit chunk, or
moving the exit code to a data chunk) would break every consumer.
This is the intended commitment: the invariant is the value.
- A client that doesn't read until the exit chunk (e.g., a runner that
cancels mid-stream by closing the write half) won't see the exit
code. That's correct — a cancelled stream doesn't have a
code. That's correct: a cancelled stream doesn't have a
deterministic exit; the client that cancels already knows it
cancelled. The exit chunk is for the client that reads to completion.
- The `-1` sentinel collides with a real SIGHUP termination on the
Unix pipe path: signal-terminated children are encoded `-N`, so
SIGHUP (signal 1) encodes as exactly `-1` — the same value the
SIGHUP (signal 1) encodes as exactly `-1`, the same value the
adapter sends for a backend wait failure. A client can't
distinguish a real SIGHUP termination from a backend wait failure
from the code alone. The collision is narrow in practice (the usual
@@ -175,7 +175,7 @@ the completion notification.
`Signal { name: "HUP" }` control chunk, which the client knows it
sent), but it is real for any other SIGHUP source. Note
`std::process::ExitStatus::code()` cannot itself cause the
collision — it returns `None` for signal termination, so this
collision (it returns `None` for signal termination), so this
conflation exists only through this crate's `-N` encoding. If
distinguishing becomes load-bearing, a future control message type
(`{"type":"exit_error","message":"..."}`) can carry the distinction
@@ -214,16 +214,16 @@ additive.
## References
- [ADR-001](001-wire-format-and-two-carriage.md) — the wire format
- [ADR-001](001-wire-format-and-two-carriage.md): the wire format
(control channel, `STREAM_CTRL_OUT` stream_type 4) this ADR's exit
chunk rides on
- [ADR-002](002-ttybackend-trait-and-ttyhandle.md) — the
- [ADR-002](002-ttybackend-trait-and-ttyhandle.md): the
`TtyHandle.exit_code` field (the `Future` the adapter awaits) this
ADR's ordering consumes
- [ADR-005](005-backend-cleanup-on-session-cancel.md) — the cancel path
- [ADR-005](005-backend-cleanup-on-session-cancel.md): the cancel path
that bypasses this ADR's happy-path ordering (no exit chunk is sent
on cancel — the stream is gone)
- [ADR-008](008-channels-pure-channel-multiplexing.md) — the
on cancel, since the stream is gone)
- [ADR-008](008-channels-pure-channel-multiplexing.md): the
exit-chunk-is-last invariant generalizes to channels mode (the exit
chunk is the last `STREAM_CTRL_OUT` chunk before the channel closes)
- Spec: [tty-adapter.md](../tty-adapter.md) (the session driver that
@@ -3,14 +3,14 @@
## Status
Accepted (ported from alknet ADR-057 2026-08-17; the dependency-edge
decision carries over unchanged — alktty depends on alkcall (for the
decision carries over unchanged: alktty depends on alkcall (for the
`ProtocolHandler` trait, auth, ownership types) but not on alkcall's
internal wire types. The framing is self-contained in
`src/negotiation.rs`. Cross-references renumbered to alktty's ADR
range — ADR-052→001, ADR-053→002, ADR-054→003, ADR-055→004,
range: ADR-052→001, ADR-053→002, ADR-054→003, ADR-055→004,
ADR-056→005, ADR-057→006, ADR-077→007, ADR-093→008. The alknet ADR
referenced by alknet number (003) is not ported into alktty's ADR
range — it is an alknet-core ADR. The alknet originals at
range; it is an alknet-core ADR. The alknet originals at
`/workspace/@alkdev/alknet/docs/architecture/decisions/` remain
authoritative.)
@@ -18,7 +18,7 @@ authoritative.)
The alknet-tty specs (alknet ADR-052, 053, 054) and alknet ADR-003
Amendment 1 previously stated that alknet-tty depends on alknet-call for
the `FrameFramedReader`/`FrameFramedWriter` "framing utility" — the
the `FrameFramedReader`/`FrameFramedWriter` "framing utility": the
4-byte big-endian length prefix + UTF-8 JSON body framing the
negotiation frame uses. The claim was "reuse the framing utility, not
the `EventEnvelope` type" (alknet ADR-052 §6).
@@ -39,7 +39,7 @@ pub async fn read_frame(&mut self) -> Result<EventEnvelope, FrameError> {
The framing logic (read 4 bytes → length, read N bytes) and the
`EventEnvelope` deserialization are entangled in the same method. The
"framing utility" the spec claimed to reuse does not exist as a
separable thing — the length-prefix read and the type-specific
separable thing: the length-prefix read and the type-specific
deserialize are one call. alktty's negotiation frame is a
`NegotiateRequest`, not an `EventEnvelope`, so `read_frame()` cannot
return what alktty needs.
@@ -53,13 +53,13 @@ comparison):
2. **Promote a generic length-prefixed framing utility to alkcall::core.**
Makes the spec's claim true (a reusable utility exists), but
accretes a framing module to the foundation crate for the sake of
two consumers — a shared utility pays for itself at ≥2 consumers,
two consumers. A shared utility pays for itself at ≥2 consumers,
but the framing is trivial enough that the cost of the shared
abstraction (a new module, a new type, a refactor of alkcall)
exceeds the cost of the duplication.
3. **Actually use alkcall** (e.g., model the tty control channel as
call-protocol operations). A different architecture, not a
dependency-edge fix — the current `ControlMessage` tagged enum
dependency-edge fix: the current `ControlMessage` tagged enum
(ADR-001) is the two-way-door seam; replacing it with the call
protocol is a v2 ALPN decision, not a v1 dependency choice.
@@ -74,7 +74,7 @@ dependency edge is:
```
alktty
└── alkcall (ProtocolHandler, Connection, AuthContext, Identity,
AccessControl, OwnershipProvider — alknet ADR-050)
AccessControl, OwnershipProvider; alknet ADR-050)
```
No alkcall-internal-wire-types dependency. The crate depends on alkcall
@@ -99,10 +99,10 @@ alktty implements the negotiation framing as a small module
(`src/negotiation.rs`, ~30 lines for the framing reader/writer: read
4-byte BE length, bounds-check, read N bytes; write the inverse). The
module's types (`NegotiationReader`/`NegotiationWriter`) are private to
the crate — they are not a reusable utility for other crates. If a
the crate; they are not a reusable utility for other crates. If a
future crate wants length-prefixed JSON framing, it implements its own
(the idiom is trivial) or a future ADR promotes a generic utility to
alkcall::core at that point (deferred — not needed for the current
alkcall::core at that point (deferred: not needed for the current
scope; two consumers is the threshold but the second consumer does not
yet exist).
@@ -126,7 +126,7 @@ yet exist).
internal wire types" rule is preserved without the Amendment 1
exception for alktty. (alknet ADR-003 Amendment 1's exception
remains for alknet-http/agent/napi, which use alkcall's
`OperationSpec`/`Handler`/`OperationAdapter` types — actual type
`OperationSpec`/`Handler`/`OperationAdapter` types: actual type
reuse, not framing glue. alktty does not need that exception.)
**Negative:**
@@ -164,7 +164,7 @@ wire-format change), but the dependency edge is one-way.
2. **The format coincidence (both use 4-byte BE length + JSON body) is
stable.** Both crates use the same length-prefix convention. If
alkcall's framing changes (e.g., a different max-frame-size, a
different prefix width), alktty's is unaffected — they are
different prefix width), alktty's is unaffected; they are
independent implementations that happen to share a format today.
## References
@@ -175,12 +175,12 @@ wire-format change), but the dependency edge is one-way.
- [ADR-001](001-wire-format-and-two-carriage.md) §2 (negotiation frame
format), §6 (revised: format coincides by convention, not by code
reuse)
- [ADR-002](002-ttybackend-trait-and-ttyhandle.md) — the crate
- [ADR-002](002-ttybackend-trait-and-ttyhandle.md): the crate
decomposition this ADR's dependency edge affects
- `src/negotiation.rs` — the self-contained framing reader/writer this
- `src/negotiation.rs`: the self-contained framing reader/writer this
ADR commits (`NegotiationReader`, `NegotiationWriter`,
`error_response_bytes`)
- alkcall's `FrameFramedReader`/`FrameFramedWriter` — the
- alkcall's `FrameFramedReader`/`FrameFramedWriter`: the
`EventEnvelope`-bound methods that are NOT reused (the entangled
length-prefix-read + `EventEnvelope`-deserialize that motivated this
ADR)
@@ -1,16 +1,16 @@
# ADR-007: TTY Inside Channels — Sub-Streams, Not Wire Format
# ADR-007: TTY Inside Channels (Sub-Streams, Not Wire Format)
## Status
Accepted (**reversed 2026-07-18 by alknet ADR-093, ported here as
[ADR-008](008-channels-pure-channel-multiplexing.md): TTY always uses
its 5-byte format; the channels layer carries it transparently in the
payload — see "Reversal (ADR-008, 2026-07-18)" below.** Ported from
payload (see "Reversal (ADR-008, 2026-07-18)" below).** Ported from
alknet ADR-077 2026-08-17; cross-references renumbered to alktty's
ADR range — ADR-052→001, ADR-053→002, ADR-054→003, ADR-055→004,
ADR range: ADR-052→001, ADR-053→002, ADR-054→003, ADR-055→004,
ADR-056→005, ADR-057→006, ADR-077→007, ADR-093→008. The alknet ADRs
referenced by alknet number (052, 053, 055, 057, 061, 071, 073, 074,
092) are not ported into alktty's ADR range — they are alknet-core /
092) are not ported into alktty's ADR range; they are alknet-core /
alknet-channels ADRs. The alknet originals at
`/workspace/@alkdev/alknet/docs/architecture/decisions/` remain
authoritative.)
@@ -28,14 +28,14 @@ modes differ only in *where the `BiStream` comes from* (a top-level
When TTY is inside channels, the channels layer strips its 8-byte header
(alknet ADR-093 / ADR-008) and hands TTY the payload bytes. TTY parses
its 5-byte header from the payload. The channels layer carries TTY's
5-byte chunks transparently in its payload — no shared fields, no
5-byte chunks transparently in its payload: no shared fields, no
leaked abstraction, no double-chunking concern (the 13-byte total
header is 8 channels + 5 TTY, not 8 + 9; the channels `length` is
always `tty_len + 5`).
The `channels` feature on alktty (if such a feature were added) becomes
"run TTY's sub-demux on a channels-backed `BiStream`" — the same code
as direct mode, different `BiStream` source. The control channel split
"run TTY's sub-demux on a channels-backed `BiStream`" (the same code
as direct mode, a different `BiStream` source). The control channel split
(`STREAM_CTRL_IN` / `STREAM_CTRL_OUT`, Phase 7) is TTY-internal; the
channels layer doesn't know about it. alknet ADR-074's
`into_sub_streams()` (the accessor this ADR's two-mode design relied
@@ -66,7 +66,7 @@ integration question the research hand-waved.
The problem: the `TtyAdapter`'s current `handle()` loops `accept_bi()`,
spawning a `drive_session` task per bidi stream that parses 5-byte TTY
chunks off the stream. Inside a channels connection, the stream is
*already de-chunked* by the channels layer's 9-byte format — the
*already de-chunked* by the channels layer's 9-byte format: the
handler sees an `AsyncRead + AsyncWrite` pair, not a chunk-encoded
stream. If the TTY adapter tries to parse 5-byte chunks off an
already-de-chunked stream, it breaks.
@@ -80,21 +80,21 @@ its `Connection`:
| Mode | When | Wire format | How the adapter gets sub-streams |
|------|------|-------------|---------------------------------|
| **Direct (`alk/tty` ALPN)** | Top-level QUIC/TCP connection with ALPN `alk/tty` | TTY's 5-byte format (ADR-001, amended — see below) | `accept_bi()` → parse 5-byte chunks → split into stream_types 0-4 |
| **Inside channels** | `channel/open` with ALPN `alk/tty` on a channels connection | Channels' 9-byte format (alknet ADR-071) — the channels layer de-chunks | `into_sub_streams()` (alknet ADR-074) → five named handles for stream_types 0-4 |
| **Direct (`alk/tty` ALPN)** | Top-level QUIC/TCP connection with ALPN `alk/tty` | TTY's 5-byte format (ADR-001, amended; see below) | `accept_bi()` → parse 5-byte chunks → split into stream_types 0-4 |
| **Inside channels** | `channel/open` with ALPN `alk/tty` on a channels connection | Channels' 9-byte format (alknet ADR-071); the channels layer de-chunks | `into_sub_streams()` (alknet ADR-074) → five named handles for stream_types 0-4 |
In both modes, the `TtyBackend` trait and `TtyHandle` are unchanged
(ADR-002). The backend allocates a PTY and returns a `TtyHandle`; the
adapter pumps data between the handle and the sub-streams. The
difference is only in how the adapter gets the sub-streams — 5-byte
difference is only in how the adapter gets the sub-streams: 5-byte
chunk parsing (direct) vs. `into_sub_streams()` (channels).
### The control channel is now properly bidirectional
The phase-0 findings flagged that the TTY control channel "isn't
actually bidirectional… the adapter ignores Exit from the client." The
root cause: `stream_type 3` was "bidirectional" — one stream both sides
wrote to, which is not properly multiplexed.
root cause: `stream_type 3` was "bidirectional" (one stream both sides
wrote to, which is not properly multiplexed).
alknet ADR-071's stream_type decomposition fixes this: **every
stream_type is unidirectional.** Control is now two halves:
@@ -107,8 +107,8 @@ stream_type is unidirectional.** Control is now two halves:
The TTY adapter writes resize/signal/eof to `ctrl_in` (stream_type 3)
and reads exit/keepalive from `ctrl_out` (stream_type 4). Each has its
own reassembly buffer, its own flow control, its own EOF. The control
channel is *actually* bidirectional — two unidirectional streams, not
one shared stream both sides write to.
channel is *actually* bidirectional (two unidirectional streams, not
one shared stream both sides write to).
This amends ADR-001's stream_type assignments for direct mode too:
direct `alk/tty` connections now use stream_types [0, 1, 2, 3, 4] (data
@@ -121,11 +121,11 @@ splits from 3 into 3+4).
1. **The adapter's session-driving code splits into two entry
points:**
- `drive_session_direct(send, recv, backends, ...)` — the existing
path: parse 5-byte chunks, split into stream_types 0-4, pump. Used
- `drive_session_direct(send, recv, backends, ...)` (the existing
path): parse 5-byte chunks, split into stream_types 0-4, pump. Used
for direct `alk/tty` connections.
- `drive_session_channels(sub_streams, backends, ...)` — the new
path: receive `ChannelSubStreams` (five named handles: stdin=
- `drive_session_channels(sub_streams, backends, ...)` (the new
path): receive `ChannelSubStreams` (five named handles: stdin=
SendStream, stdout=RecvStream, stderr=Option<RecvStream>,
ctrl_in=SendStream, ctrl_out=RecvStream), pump directly without
chunk parsing. Used when the channel's `Connection` is backed by
@@ -135,7 +135,7 @@ splits from 3 into 3+4).
type.** The channels crate's `ChannelBidiStreamSource` is a
`BidiStreamSource` (alknet ADR-070); the `Connection` wraps it. The
adapter detects whether the `Connection` is channels-backed (via a
downcast or a channels-crate extension trait — exact ergonomics per
downcast or a channels-crate extension trait; exact ergonomics per
alknet ADR-074's implementation detail) and calls
`drive_session_channels` instead of `drive_session_direct`.
@@ -147,16 +147,16 @@ splits from 3 into 3+4).
3. **The 5-byte wire format (ADR-001) is unchanged for direct
connections.** ADR-001's scope is now "the wire format for direct
`alk/tty` connections." The channels path does not use it. This
amends ADR-001's scope — the format is not replaced, it's scoped.
amends ADR-001's scope: the format is not replaced, it's scoped.
4. **The control channel works the same in both modes, now properly
bidirectional.** In direct mode, control-in JSON rides in 5-byte
chunks with `stream_type=3` and control-out rides with
`stream_type=4`. In channels mode, control-in rides in 9-byte chunks
with `stream_type=3` (write) and control-out with `stream_type=4`
(read) — but the channels layer de-chunks them, so the adapter
(read), but the channels layer de-chunks them, so the adapter
reads raw JSON bytes from `ctrl_in`/`ctrl_out` in both cases. The
`ControlMessage` enum (resize, signal, eof, exit) is unchanged —
`ControlMessage` enum (resize, signal, eof, exit) is unchanged:
the JSON shape is the same; only the stream_type assignments change
(3 splits into 3+4).
@@ -166,19 +166,19 @@ splits from 3 into 3+4).
amended). In channels mode, the exit control message is the last
data on `stream_type=4` before `channel/close` is sent on channel 0
(alknet ADR-073 §channel/close). The ordering invariant is the same
— exit before close — but the mechanism differs: 5-byte chunk
(exit before close), but the mechanism differs: 5-byte chunk
ordering on stream_type 4 (direct) vs. `stream_type 4` ordering +
`channel/close` after pump completion (channels).
### What does NOT change
- **`TtyBackend` trait, `TtyHandle`, `TtyControl`** (ADR-002) —
- **`TtyBackend` trait, `TtyHandle`, `TtyControl`** (ADR-002):
unchanged. Backends don't know about channels or direct mode.
- **`DockerTtyBackend`, `LocalTtyBackend`** — unchanged. They implement
- **`DockerTtyBackend`, `LocalTtyBackend`**: unchanged. They implement
`TtyBackend::allocate()` and return a `TtyHandle`.
- **`ControlMessage` enum** — unchanged. The JSON shape is the same in
- **`ControlMessage` enum**: unchanged. The JSON shape is the same in
both modes.
- **The `alk/tty` ALPN string** — unchanged. Direct connections use it;
- **The `alk/tty` ALPN string**: unchanged. Direct connections use it;
channels `channel/open` requests it.
### Crate dependency
@@ -195,16 +195,16 @@ build; the feature-gated dependency is opt-in, same as `alknet-docker`'s
## Consequences
**Positive:**
- The TTY crate's direct mode is unchanged — existing `alk/tty`
- The TTY crate's direct mode is unchanged: existing `alk/tty`
deployments (browser terminals over WebSocket, direct QUIC TTY) keep
working with the 5-byte format.
- The channels path uses the channels layer's de-chunking — no
double-chunking (5-byte inside 9-byte). The TTY adapter sees clean
- The channels path uses the channels layer's de-chunking, so there is
no double-chunking (5-byte inside 9-byte). The TTY adapter sees clean
sub-streams.
- The `TtyBackend` trait is insulated — backends don't know which mode
- The `TtyBackend` trait is insulated: backends don't know which mode
the adapter is in. Docker, SSH, and local backends work in both
modes without changes.
- The control channel and exit-chunk invariant carry forward cleanly —
- The control channel and exit-chunk invariant carry forward cleanly:
the `ControlMessage` enum and ordering semantics are
mode-independent.
@@ -220,17 +220,17 @@ build; the feature-gated dependency is opt-in, same as `alknet-docker`'s
`alknet-docker`'s `tty` feature (alknet ADR-061) and is opt-in.
- ADR-001's scope is amended (from "the TTY wire format" to "the TTY
wire format for direct connections"). This is a scope clarification,
not a format change — the 5-byte format itself is unchanged.
not a format change; the 5-byte format itself is unchanged.
## Door type
**One-way (scope amendment) + two-way (feature gate).** ADR-001's scope
amendment (direct-only) is one-way — once the channels path exists,
amendment (direct-only) is one-way: once the channels path exists,
re-merging the formats would require unifying 5-byte and 9-byte chunk
handling, which is a rewrite. The `channels` feature gate is two-way —
it can be removed if channels integration is no longer needed.
handling, which is a rewrite. The `channels` feature gate is two-way
(it can be removed if channels integration is no longer needed).
**Reversed by ADR-008 (2026-07-18):** the two-mode design is reversed —
**Reversed by ADR-008 (2026-07-18):** the two-mode design is reversed:
TTY always uses its 5-byte format, carried transparently in the
channels payload. The one-way door is re-cast (the channels crate is
not yet implemented, so this is the right time). See ADR-008 for the
@@ -239,29 +239,29 @@ amended door-type discussion.
## References
- **[ADR-008](008-channels-pure-channel-multiplexing.md)**: channels
pure channel multiplexing (reverses this ADR — TTY always uses its
pure channel multiplexing (reverses this ADR: TTY always uses its
5-byte format; the channels layer carries it transparently; the
two-mode design is preserved but differs only in `BiStream` source,
not in parsing)
- [ADR-001](001-wire-format-and-two-carriage.md): alktty wire format
(amended — scoped to direct connections by this ADR; **re-amended by
ADR-008 — TTY always uses its 5-byte format, in both direct and
(amended; scoped to direct connections by this ADR; **re-amended by
ADR-008: TTY always uses its 5-byte format, in both direct and
inside-channels modes**)
- [ADR-002](002-ttybackend-trait-and-ttyhandle.md): TtyBackend trait
and TtyHandle (unchanged by this ADR)
- [ADR-004](004-exit-code-on-control-chunk.md): exit-chunk-is-last
(generalized by this ADR + alknet ADR-073)
- [ADR-006](006-negotiation-framing-self-contained.md): alktty does not
depend on alkcall's internal wire types (preserved — the channels
depend on alkcall's internal wire types (preserved: the channels
feature is on alknet-channels, not alkcall's internal wire types)
- alknet ADR-071: channels wire format (the 9-byte format the channels
path uses; **amended by alknet ADR-093 / ADR-008 — 8-byte format, no
path uses; **amended by alknet ADR-093 / ADR-008: 8-byte format, no
`stream_type`**)
- alknet ADR-074: ChannelBidiStreamSource / `into_sub_streams` (the
accessor the channels path uses; **amended by alknet ADR-093 /
ADR-008 — `into_sub_streams()` removed**)
ADR-008: `into_sub_streams()` removed**)
- alknet ADR-092: `BiStream` as the handler leaf (the transport-leaf
decision that enables the reversal — `accept_bi` returns `BiStream`)
decision that enables the reversal: `accept_bi` returns `BiStream`)
- alknet ADR-061: DockerTtyBackend in alknet-docker (the feature-gated
dependency pattern this ADR mirrors)
- alknet channels phase-0 findings §DP-3, §OQ-CH-02, §Relationship to
+48 -48
View File
@@ -6,10 +6,10 @@ status: draft (ported from alknet 2026-08-17; alknet-tty → alktty,
last_updated: 2026-08-17
---
# alktty — Local TTY Backend (`local` feature module)
# alktty: Local TTY Backend (`local` feature module)
The local backend: a `TtyBackend` implementation that wraps
`portable_pty` for the PTY case (terminal semantics — resize, signal
`portable_pty` for the PTY case (terminal semantics: resize, signal
delivery, escape-sequence handling) and `tokio::process::Command` with
`Stdio::piped()` for the pipe/runner case (process-streaming without
terminal semantics). This document specifies the `LocalTtyBackend`, the
@@ -24,12 +24,12 @@ it implements is in [tty-backend.md](tty-backend.md).
(`src/local/`, gated by the `local` cargo feature) and implements
`TtyBackend`. The backend's `allocate()` branches on `TtyParams.terminal`:
- **`terminal: Some(TerminalParams { ... })`** — allocate a real PTY via
- **`terminal: Some(TerminalParams { ... })`**: allocate a real PTY via
`portable_pty::native_pty_system().openpty()`, spawn the command into
the slave side, return a `TtyHandle` with merged stdout (stderr is
`None` — kernel PTY property) and a real `TtyControl` (resize via
`None`, a kernel PTY property) and a real `TtyControl` (resize via
`MasterPty::resize`, signal via `libc::kill(-pgid, sig)`).
- **`terminal: None`** — pipe mode, the runner case. Spawn the command
- **`terminal: None`**: pipe mode, the runner case. Spawn the command
with `Stdio::piped()` for stdin/stdout/stderr, return a `TtyHandle`
with separate stdout and stderr (stderr is `Some`) and a `TtyControl`
whose `resize` is a no-op (no PTY) and `signal` calls
@@ -44,10 +44,10 @@ process group).
The local backend is the simplest backend and the one that enables the
runner pattern: a process whose stdin/stdout/stderr/exit-code stream over
a framed bidi connection — the same shape as GitHub/Gitea Actions runners,
a framed bidi connection: the same shape as GitHub/Gitea Actions runners,
just over alk's transport instead of HTTP polling. With
`LocalTtyBackend`, the dispatch project (a reverse runner that currently
requires SSH on the remote end) works without SSH — the endpoint runs
requires SSH on the remote end) works without SSH: the endpoint runs
the process directly and streams its I/O back. SSH becomes one transport
option (for reaching hosts that don't run alk), not a requirement.
@@ -56,8 +56,8 @@ The PTY case is what makes a terminal a terminal: real resize (via
(via `libc::kill(-pgid, sig)`, REQ-TTY-02), and escape-sequence handling
(the kernel PTY's line discipline). Without a PTY, it's a runner (piped
process); with a PTY, it's a terminal. The per-session choice
(`TtyParams.terminal`) lets one `LocalTtyBackend` serve both — see
ADR-003.
(`TtyParams.terminal`) lets one `LocalTtyBackend` serve both (see
ADR-003).
The wrinkle that drove the Phase 0 POC: `portable_pty` is a **blocking
`std::io` API**, not async. `MasterPty::try_clone_reader()` returns
@@ -65,7 +65,7 @@ The wrinkle that drove the Phase 0 POC: `portable_pty` is a **blocking
`Box<dyn std::io::Write + Send>`; `Child::wait()` blocks. The POC was
built to discover how that constraint shapes the `TtyBackend` trait
(REQ-TTY-01) and the signal-delivery contract (REQ-TTY-02). This spec
records both as requirements, not open questions — the POC turned them
records both as requirements, not open questions; the POC turned them
into grounded requirements.
## Architecture
@@ -89,15 +89,15 @@ pub enum StdinCmd {
}
```
1. **Reader thread** — blocking reads from `MasterPty::try_clone_reader()`
1. **Reader thread**: blocking reads from `MasterPty::try_clone_reader()`
→ `mpsc::Sender<Bytes>`. The reader loop reads into an 8 KiB buffer,
copies each chunk to `Bytes`, and `blocking_send`s to the mpsc. On EOF
(the master reader returns EOF when the slave closes — the child has
(the master reader returns EOF when the slave closes; the child has
exited and the OS has drained the PTY buffer), the thread sends a
zero-length `Bytes` sentinel (the "drained" signal) and exits. The
async-facing `TtyHandle.stdout` is the `mpsc::Receiver<Bytes>`,
wrapped as `Pin<Box<dyn Stream<Item = Bytes> + Send>>`.
2. **Writer thread** — drains an `mpsc::Receiver<StdinCmd>` → blocking
2. **Writer thread**: drains an `mpsc::Receiver<StdinCmd>` → blocking
writes to `MasterPty::take_writer()`. `StdinCmd::Bytes(bytes)` writes
and flushes; `StdinCmd::Eof` drops the writer (sends EOF to the
slave's stdin) and exits. The async-facing `TtyHandle.stdin` is the
@@ -105,10 +105,10 @@ pub enum StdinCmd {
Unpin>` (an `AsyncWrite` impl that wraps each `write` as a
`StdinCmd::Bytes` and `flush` as a no-op; the `mpsc::Sender` is the
sink).
3. **Waiter thread** — blocking wait → `oneshot::Sender<i32>` with the
3. **Waiter thread**: blocking wait → `oneshot::Sender<i32>` with the
exit code in ADR-004's encoding. On Unix the reap is
`libc::waitpid(pid, ..)` on the direct child, decoded to
`WEXITSTATUS` (normal exit) or `-WTERMSIG` (signal-terminated exit —
`WEXITSTATUS` (normal exit) or `-WTERMSIG` (signal-terminated exit,
the ADR-004 negative encoding): portable-pty 0.9.0's `ExitStatus`
would flatten a signal-terminated child to `code = 1` with the signal
only as a strsignal name string (review #004); reaping by pid avoids
@@ -122,7 +122,7 @@ pub enum StdinCmd {
REQ-TTY-01; ADR-004); its `Drop`-on-cancel kills the child
(ADR-005).
`TtyHandle.stderr` is `None` (PTY backends merge stdout/stderr — kernel
`TtyHandle.stderr` is `None` (PTY backends merge stdout/stderr: a kernel
PTY property, one output stream from the slave).
`TtyHandle.control` is a `PtyControl` struct (the POC's concrete type;
@@ -138,8 +138,8 @@ pub struct PtyControl {
}
```
`resize()` locks the master and calls `MasterPty::resize(PtySize)` —
non-blocking (it issues an `ioctl`). `signal()` — see REQ-TTY-02 below.
`resize()` locks the master and calls `MasterPty::resize(PtySize)`,
non-blocking (it issues an `ioctl`). `signal()` (see REQ-TTY-02 below).
### REQ-TTY-02: Signal Forwarding Must Target the Process Group
@@ -151,10 +151,10 @@ the **foreground process group**, which (under job-control shells) is the
process group the shell most recently spawned for the foreground job.
`portable_pty` makes the child a session leader (when
`controlling_tty = true`, the default — `CommandBuilder::set_controlling_tty(true)`),
`controlling_tty = true`, the default, `CommandBuilder::set_controlling_tty(true)`),
so the child's pid *is* its process-group id, and `libc::kill(-pid, sig)`
(the negative pid) reaches the whole group. The POC's `PtyControl::signal`
uses exactly this — `kill(-pgid, sig)` with a fallback to `kill(pid, sig)`
uses exactly this: `kill(-pgid, sig)` with a fallback to `kill(pid, sig)`
if the group signal fails (e.g., the child already exited).
The spec records:
@@ -184,13 +184,13 @@ future kills the session target.** The local backend implements this for
both PTY and pipe modes.
**PTY mode.** `allocate()` obtains a `portable_pty::Child` (with
`wait()`) and a `portable_pty::ChildKiller` (with `kill()`) — the two
`wait()`) and a `portable_pty::ChildKiller` (with `kill()`); the two
handles `portable_pty` exposes alongside each other. The `Child` moves
into the waiter thread (which blocks on `wait()`). The `ChildKiller`
moves into the `exit_code` future's `Drop` guard, alongside the
`oneshot::Receiver<i32>` from the waiter thread. The future's `poll`
delegates to the oneshot receiver (resolves on natural exit); the
future's `Drop` (runs on cancel only — on resolve, the guard is
future's `Drop` (runs on cancel only; on resolve, the guard is
disarmed) calls `ChildKiller::kill(SIGHUP)`:
```rust
@@ -210,8 +210,8 @@ impl Drop for LocalExitFuture {
```
On cancel: the `Drop` kills the child (SIGHUP); the child exits; the
waiter thread reaps it (Unix: `libc::waitpid` — see "Waiter thread"
above) and exits (its `oneshot::send` fails silently — the receiver was
waiter thread reaps it (Unix: `libc::waitpid`; see "Waiter thread"
above) and exits (its `oneshot::send` fails silently: the receiver was
dropped with the future, which is expected); the reader/writer threads
exit on channel close. The child is reaped (no zombie) after the kill;
its SIGHUP death resolves to `-1` (the ADR-004 sentinel collision) on
@@ -233,7 +233,7 @@ This closes the orphaned-process gap the local-PTY POC surfaced: a
child that ignores stdin EOF (a daemon, a long-lived process with no
stdin reader) is killed when the session is cancelled, not left
running. The POC's `LocalPty::exit_code` was a bare
`oneshot::Receiver<i32>` with no kill guard — an implementer who
`oneshot::Receiver<i32>` with no kill guard; an implementer who
copies the POC's shape without the guard violates the contract. See
ADR-005 for the contract and the trait-level rationale.
@@ -241,24 +241,24 @@ ADR-005 for the contract and the trait-level rationale.
`allocate()` spawns the command with `tokio::process::Command` and
`Stdio::piped()` for stdin, stdout, and stderr. The async bridge is
simpler than the PTY case — tokio's `Child` provides `AsyncRead` for
simpler than the PTY case: tokio's `Child` provides `AsyncRead` for
stdout/stderr and `AsyncWrite` for stdin directly (no std-thread
bridge needed). `TtyHandle.stderr` is `Some` (separate streams). The
`exit_code` future is `Child::wait()` (async on tokio's `Child`).
`TtyHandle.control` is a `PipeControl` whose `resize()` is a no-op
(no PTY — resize doesn't apply) and `signal()` calls `libc::kill(pid, sig)`
(no PTY, so resize doesn't apply) and `signal()` calls `libc::kill(pid, sig)`
on the child's pid. Signal forwarding to the process group is not
applicable in pipe mode (there's no session leader / controlling tty);
`kill(pid, sig)` reaches the direct child only. If the child has
spawned its own children, they won't receive the signal — this is a
spawned its own children, they won't receive the signal; this is a
known limitation of the runner case (a runner that needs
process-group signal delivery uses the PTY case, not the pipe case).
### The Threading/Deadlock Caveat (DP-4, Acknowledged Constraint)
`std::process::Command` with piped stdio can deadlock if stdin writes
block while stdout/stderr buffers fill — the classic pipe-buffer deadlock.
block while stdout/stderr buffers fill (the classic pipe-buffer deadlock).
The fix is concurrent reads on stdout/stderr alongside stdin writes,
which is exactly what the bidirectional pump does (the POC's
`drive_attach_raw` runs the two directions as concurrent
@@ -282,13 +282,13 @@ local = ["dep:portable-pty", "dep:tokio-util", "tokio/process", "tokio/rt-multi-
A consumer that wants the local backend enables `features = ["local"]`
and gets `alktty::local::LocalTtyBackend`. A consumer that only wants
docker/ssh uses the default features and depends on the backend crate
directly — no `portable_pty` in the dependency tree. See ADR-003.
directly, with no `portable_pty` in the dependency tree. See ADR-003.
The single-crate consolidation resolves the alknet cyclic-dep
workaround that motivated the original sibling-crate decision
(alknet ADR-054): `alknet-tty-local` depended on `alknet-tty` for the
trait, and ADR-054 wanted `alknet-tty` to re-export `LocalTtyBackend`
behind a `local` feature — which cargo rejects (a crate cannot
behind a `local` feature, which cargo rejects (a crate cannot
re-export from a sibling crate it depends on via an optional dep AND
have that sibling depend back on it). The workaround in the alknet
mono-repo was the assembly-layer pattern (consumer depends on both
@@ -302,16 +302,16 @@ both the original alknet decision and the alktty consolidation.
alktty (local feature)
├── alktty (default) (TtyBackend trait, TtyHandle, TtyControl, wire types)
├── alkcall::core (via alktty's re-export; not direct)
├── portable_pty (PTY allocation — the heavy dep, Unix openpty + Windows ConPTY)
├── libc (signal forwarding — REQ-TTY-02, Unix only)
├── portable_pty (PTY allocation; the heavy dep, Unix openpty + Windows ConPTY)
├── libc (signal forwarding: REQ-TTY-02, Unix only)
└── tokio (process, rt-multi-thread, mpsc, oneshot, AsyncRead/AsyncWrite)
```
The `local` feature is inherently non-wasm (`portable-pty` +
`tokio::process` need a real OS); enabling `local` on
`wasm32-unknown-unknown` is a build error by design. The default crate
(no features) stays wasm-clean — see the crate root's `# WASM target`
doc comment.
(no features) stays wasm-clean (see the crate root's `# WASM target`
doc comment).
## The Runner Pattern
@@ -321,12 +321,12 @@ research identified. A coordinator sends a negotiation frame with
endpoint runs `cargo test` with piped stdio, streams stdout/stderr chunks
back, sends `{"type":"exit","code":N}` when it finishes (ADR-004). The
coordinator gets reliable completion notification (the exit control
chunk + stream close) — no polling, no plugin state.
chunk + stream close): no polling, no plugin state.
This is functionally identical to GitHub/Gitea Actions runners, just over
alk's transport instead of HTTP polling. The dispatch project is a
reverse runner that currently requires SSH on the remote end; with
`LocalTtyBackend`, the same pattern works without SSH — the endpoint
`LocalTtyBackend`, the same pattern works without SSH: the endpoint
runs the process directly. SSH becomes one transport option (for
reaching hosts that don't run alk), not a requirement.
@@ -354,18 +354,18 @@ building runner policy into alktty.
A runner that needs process-group signal delivery uses the PTY case.
- **The pipe-buffer deadlock is handled by the concurrent pump.** The
adapter's three-pump driver (`tty-adapter.md`) reads stdout/stderr
concurrently with writing stdin — the POC-validated pattern. No design
concurrently with writing stdin (the POC-validated pattern). No design
decision needed; the spec notes it as a known constraint with a known
solution.
- **`LocalTtyBackend` takes no constructor dependencies.** Unlike
`DockerTtyBackend` (wraps a `bollard::Docker` client) or
`SshTtyBackend` (wraps an SSH session), the local backend is
dependency-free at construction — the `portable_pty` system is
process-global. The assembly layer constructs one `LocalTtyBackend`
dependency-free at construction (the `portable_pty` system is
process-global). The assembly layer constructs one `LocalTtyBackend`
and registers it as `"local"`.
- **The `exit_code` future's `Drop`-on-cancel kills the child
(ADR-005).** The local backend MUST NOT return a bare
`oneshot::Receiver<i32>` as `TtyHandle.exit_code` — it must wrap it
`oneshot::Receiver<i32>` as `TtyHandle.exit_code`; it must wrap it
in a `Future` whose `Drop` calls `ChildKiller::kill(SIGHUP)` (PTY) or
`Child::start_kill()` (pipe) when dropped without resolving. An
implementer who copies the POC's bare `oneshot::Receiver<i32>` shape
@@ -388,19 +388,19 @@ building runner policy into alktty.
## References
- [ADR-003](decisions/003-local-backend-placement.md) — the module
- [ADR-003](decisions/003-local-backend-placement.md): the module
placement decision (single-crate consolidation)
- [ADR-002](decisions/002-ttybackend-trait-and-ttyhandle.md) — the
- [ADR-002](decisions/002-ttybackend-trait-and-ttyhandle.md): the
trait this backend implements; REQ-TTY-01 (the blocking-backend
accommodation)
- [ADR-004](decisions/004-exit-code-on-control-chunk.md) — the
- [ADR-004](decisions/004-exit-code-on-control-chunk.md): the
waiter thread's `oneshot::Receiver<i32>` feeds the exit chunk
- [ADR-005](decisions/005-backend-cleanup-on-session-cancel.md) —
- [ADR-005](decisions/005-backend-cleanup-on-session-cancel.md):
the cancel-cleanup contract this backend implements (the `exit_code`
future's `Drop`-on-cancel kills the child via `ChildKiller` /
`start_kill`)
- `src/local/` — the Rust source this spec documents (`backend.rs`,
- `src/local/`: the Rust source this spec documents (`backend.rs`,
`pty.rs`, `pipe.rs`)
- [tty-backend.md](tty-backend.md) — the trait this backend implements
- [tty-adapter.md](tty-adapter.md) — the session driver that consumes
- [tty-backend.md](tty-backend.md): the trait this backend implements
- [tty-adapter.md](tty-adapter.md): the session driver that consumes
this backend's handles