docs: remove em dashes from repo prose and forbid them going forward
- Rewrote ~1090 em dashes across docs/ (excluding docs/reviews/ history), README, CHANGELOG, and .opencode/agents prompts with varied restructures (colons, semicolons, parentheses, commas, sentence splits); ADR decisions, status lines, wire-shape examples, and code fences preserved - Rewrote 234 em dashes in src doc comments plus a handful of existing inline comments and one test assert message; no code, JSON literals, or wire shapes touched - Added Project Convention 15: no em dashes in prose (doc comments, markdown, agent prompts, commit messages); restructure instead; no spaced hyphens as a substitute Verification: cargo test; cargo clippy --all-targets -- -D warnings; cargo fmt --check; cargo doc --no-deps; cargo test --all-features; cargo test --manifest-path fuzz/shared/Cargo.toml; cargo check and clippy for wasm32-unknown-unknown with -D warnings; em dash grep over src + docs + agent prompts returns no matches outside docs/reviews/
This commit is contained in:
1 parent
e9e01c04e1
commit
58da42a021
39 files changed
+1310
-1295
No files matched your search
@@ -37,10 +37,10 @@ docs/architecture/
|
||||
|
||||
The README is the entry point. It contains:
|
||||
|
||||
1. **Current State** — what phase the project is in, what's implemented
|
||||
2. **Architecture Documents** — table linking to each spec doc with status
|
||||
3. **ADR Table** — every decision with number, title, and status
|
||||
4. **Open Questions** — link to `open-questions.md`
|
||||
1. **Current State**: what phase the project is in, what's implemented
|
||||
2. **Architecture Documents**: table linking to each spec doc with status
|
||||
3. **ADR Table**: every decision with number, title, and status
|
||||
4. **Open Questions**: link to `open-questions.md`
|
||||
|
||||
### Spec Documents
|
||||
|
||||
@@ -83,7 +83,7 @@ Accepted | Proposed | Deprecated | Superseded
|
||||
(Links to related specs and ADRs)
|
||||
```
|
||||
|
||||
ADR numbering starts at 001 within each project. ADRs are stable — once
|
||||
ADR numbering starts at 001 within each project. ADRs are stable: once
|
||||
Accepted, they don't revert. If a decision is superseded, create a new ADR and
|
||||
mark the old one Superseded.
|
||||
|
||||
@@ -97,12 +97,12 @@ don't need ADRs.
|
||||
`open-questions.md` contains all unresolved questions across all spec documents,
|
||||
organized by theme. Each question has:
|
||||
|
||||
- **OQ-ID** (OQ-01, OQ-02, ...) — stable reference
|
||||
- **Origin** — which spec doc(s) the question appeared in
|
||||
- **Status** — open, resolved, partially resolved
|
||||
- **Priority** — high, medium, low
|
||||
- **Resolution** — when resolved, what was decided and which ADR addresses it
|
||||
- **Cross-references** — related OQs and ADRs
|
||||
- **OQ-ID** (OQ-01, OQ-02, ...), a stable reference
|
||||
- **Origin**: which spec doc(s) the question appeared in
|
||||
- **Status**: open, resolved, partially resolved
|
||||
- **Priority**: high, medium, low
|
||||
- **Resolution**: when resolved, what was decided and which ADR addresses it
|
||||
- **Cross-references**: related OQs and ADRs
|
||||
|
||||
Spec documents reference OQs by number, not by repeating the question inline.
|
||||
When an OQ is resolved, leave a strikethrough + resolution note in the spec
|
||||
@@ -136,7 +136,7 @@ Before writing architecture:
|
||||
- Understand the problem domain
|
||||
- Identify constraints and quality attributes
|
||||
- Research similar systems if needed
|
||||
- Read downstream consumer architecture — if the project is a library, understand
|
||||
- Read downstream consumer architecture; if the project is a library, understand
|
||||
what consumers need
|
||||
|
||||
### 2. Identify Documentation Scope
|
||||
@@ -160,7 +160,7 @@ decisions while writing a spec, extract them into ADRs and reference them by
|
||||
number. As you identify open questions, add them to `open-questions.md` and
|
||||
reference them by OQ-ID.
|
||||
|
||||
Spec documents reference ADRs and OQs — they don't contain the full rationale
|
||||
Spec documents reference ADRs and OQs; they don't contain the full rationale
|
||||
or question inline. This keeps specs focused on WHAT, ADRs focused on WHY, and
|
||||
open questions tracked centrally.
|
||||
|
||||
@@ -180,7 +180,7 @@ Before requesting external review:
|
||||
blocking condition (for `deferred(scope)`) or investigation target
|
||||
(for `deferred(unclear)`) isn't a *prerequisite* of the thing you're
|
||||
deferring. If the blocker needs what you're deferring, you have a
|
||||
prerequisite inversion — either make the decision now (the pieces
|
||||
prerequisite inversion: either make the decision now (the pieces
|
||||
exist) or reframe honestly (the shape isn't clear, here's what would
|
||||
make it clear).
|
||||
|
||||
@@ -190,7 +190,7 @@ When you encounter a decision that genuinely can't be made:
|
||||
|
||||
1. Mark the OQ as `deferred(scope)` with a concrete blocking condition
|
||||
2. Create a blocker task in `tasks/architecture/` naming the dependency
|
||||
3. Continue to decisions that *can* be made — do not stall on one question
|
||||
3. Continue to decisions that *can* be made; do not stall on one question
|
||||
|
||||
### 6. Request Architecture Review
|
||||
|
||||
@@ -216,12 +216,12 @@ task(
|
||||
|
||||
Address feedback:
|
||||
|
||||
- **Critical**: Must fix before stabilization — inline decisions not
|
||||
- **Critical**: Must fix before stabilization (inline decisions not
|
||||
extracted, ADR references that point to nonexistent files, undefined
|
||||
terms, circular deferrals
|
||||
- **Warning**: Should fix — missing cross-references, documents approaching
|
||||
terms, circular deferrals)
|
||||
- **Warning**: Should fix: missing cross-references, documents approaching
|
||||
split threshold
|
||||
- **Suggestion**: Consider — minor clarity improvements
|
||||
- **Suggestion**: Consider: minor clarity improvements
|
||||
|
||||
Iterate until zero critical issues.
|
||||
|
||||
@@ -264,7 +264,7 @@ last_updated: 2026-05-29
|
||||
answer is resolved, not left "open" with hedging language like "v1 default"
|
||||
or "can be revisited later." If the decision is made, mark it resolved. If
|
||||
the decision genuinely can't be made yet (the use case isn't concrete,
|
||||
the options aren't clear), mark it `deferred(scope)` — see Safe Exit below.
|
||||
the options aren't clear), mark it `deferred(scope)` (see Safe Exit below).
|
||||
The architect's job is to make architecture decisions that *can* be made
|
||||
and to clearly identify which decisions *can't* be made yet and why.
|
||||
|
||||
@@ -272,7 +272,7 @@ last_updated: 2026-05-29
|
||||
|
||||
Door type classifies **reversal cost** (one-way vs two-way), not
|
||||
urgency. A two-way door is a decision you make now and can revert
|
||||
later — not a decision to defer. Using "it's a two-way door" as a
|
||||
later; it is not a decision to defer. Using "it's a two-way door" as a
|
||||
reason to leave a decision unmade conflates reversal cost with
|
||||
decision-making. See ADR-009 §"What this framework is NOT" for the
|
||||
full rationale.
|
||||
@@ -281,17 +281,17 @@ Architecture decisions are the architect's, regardless of door type.
|
||||
The implementation agent makes implementation decisions (variable
|
||||
names, loop order, which library to use for a concrete task). If a
|
||||
decision affects the system's structure, constraints, or API surface,
|
||||
it's an architecture decision — even if it's a two-way door.
|
||||
it's an architecture decision, even if it's a two-way door.
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
1. **Inline decisions**: DD1, D3, SE2 etc. in spec docs — extract to ADRs
|
||||
2. **Inline open questions**: Scattered per-doc "Open Questions" sections —
|
||||
1. **Inline decisions**: DD1, D3, SE2 etc. in spec docs: extract to ADRs
|
||||
2. **Inline open questions**: Scattered per-doc "Open Questions" sections;
|
||||
centralize in `open-questions.md`
|
||||
3. **Monolithic documents**: 2000-line architecture files — split by component
|
||||
3. **Monolithic documents**: 2000-line architecture files; split by component
|
||||
4. **Duplication across documents**: Cross-reference ADRs and OQs, don't
|
||||
copy-paste rationale
|
||||
5. **Historical comparison**: "Here's what the old code did" — specs describe
|
||||
5. **Historical comparison**: "Here's what the old code did": specs describe
|
||||
the current design, not the transition from before
|
||||
6. **Missing ADR for a visible choice**: If a reader would ask "why X over Y?",
|
||||
write an ADR
|
||||
@@ -310,7 +310,7 @@ reasoning to see its own circular hedges; a fresh context catches them.
|
||||
## Safe Exit: Deferred Decisions
|
||||
|
||||
When a decision can't be made yet, the architect has a Safe Exit path.
|
||||
This is not a failure — it's scope management. The architect's job is
|
||||
This is not a failure; it's scope management. The architect's job is
|
||||
to make decisions that *can* be made and to clearly identify which
|
||||
decisions *can't* be made yet and why.
|
||||
|
||||
@@ -318,10 +318,10 @@ There are two kinds of deferral. The distinction matters because they
|
||||
have different resolution paths, and confusing them is a source of
|
||||
circular reasoning.
|
||||
|
||||
### `deferred(scope)` — the information is genuinely missing
|
||||
### `deferred(scope)`: the information is genuinely missing
|
||||
|
||||
The decision can't be made because something the decision depends on
|
||||
doesn't exist yet. Resolution is *waiting* — for a crate spec, a POC
|
||||
doesn't exist yet. Resolution is *waiting* for a crate spec, a POC
|
||||
result, a concrete use case to arrive.
|
||||
|
||||
A decision should be `deferred(scope)` when:
|
||||
@@ -336,11 +336,11 @@ A decision should be `deferred(scope)` when:
|
||||
current scope is core + call crates; this question is about the agent
|
||||
crate")
|
||||
|
||||
### `deferred(unclear)` — the pieces exist but the shape isn't clear
|
||||
### `deferred(unclear)`: the pieces exist but the shape isn't clear
|
||||
|
||||
The pieces of the decision exist (decided in other ADRs, existing
|
||||
types, existing patterns) but the composition — how they fit together
|
||||
into a coherent shape — isn't clear yet. Resolution is *investigation*,
|
||||
types, existing patterns) but the composition (how they fit together
|
||||
into a coherent shape) isn't clear yet. Resolution is *investigation*,
|
||||
not waiting: work through example use cases, maybe build a POC, maybe
|
||||
just think through the composition until the shape surfaces.
|
||||
|
||||
@@ -352,7 +352,7 @@ pieces can be decided (verifier selection, crypto provider, fingerprint
|
||||
normalization) while the shape they compose into (the client config
|
||||
type) is still unclear. Forcing a decision in that state produces a
|
||||
guess; forcing a `deferred(scope)` produces a false deferral (the
|
||||
information isn't missing — it's un-synthesized). `deferred(unclear)`
|
||||
information isn't missing; it's un-synthesized). `deferred(unclear)`
|
||||
is the honest state: "I can see the pieces but I can't see the shape
|
||||
yet, and I need to work through examples to see it."
|
||||
|
||||
@@ -363,27 +363,27 @@ A decision should be `deferred(unclear)` when:
|
||||
- Resolution requires *work* (thinking through examples, building a
|
||||
POC), not *waiting* (for a spec or use case to arrive)
|
||||
- The architect can articulate what investigation would help ("work
|
||||
through 2+ example outbound-dial use cases") — if you can't, that's a
|
||||
through 2+ example outbound-dial use cases"); if you can't, that's a
|
||||
signal the deferral might be circular
|
||||
|
||||
### How to Defer
|
||||
|
||||
1. **Mark the OQ as `deferred(scope)` or `deferred(unclear)`** — not
|
||||
1. **Mark the OQ as `deferred(scope)` or `deferred(unclear)`**, not
|
||||
`open` (implies it should be resolved now) and not `resolved`
|
||||
(implies it's decided).
|
||||
2. **State the blocking condition** (`deferred(scope)`) or
|
||||
**investigation target** (`deferred(unclear)`) — what specific thing
|
||||
**investigation target** (`deferred(unclear)`): what specific thing
|
||||
would unblock this? Be concrete: "blocked on: alknet-agent crate spec
|
||||
exists" or "investigation: work through 2+ example outbound-dial use
|
||||
cases (hub→worker, worker→hub) to see how verifier-selection +
|
||||
provider + connector compose."
|
||||
3. **State the impacts** — what does this block downstream? Be
|
||||
3. **State the impacts**: what does this block downstream? Be
|
||||
specific: "blocks the first hub deployment because the hub dials
|
||||
workers" not "blocks the hub crate." This is the triage signal that
|
||||
makes the deferral's urgency visible. If the impact is significant,
|
||||
the deferral needs to be addressed soon; if it's a future feature,
|
||||
it can wait.
|
||||
4. **Move on** — the architect continues to decisions that *can* be
|
||||
4. **Move on**: the architect continues to decisions that *can* be
|
||||
made. Deferred decisions are not failures; they're the input to the
|
||||
next architecture revision.
|
||||
|
||||
@@ -396,10 +396,10 @@ A decision should be `deferred(unclear)` when:
|
||||
- **Status**: deferred(scope) | deferred(unclear)
|
||||
- **Door type**: <one-way | two-way>
|
||||
- **Priority**: <high | medium | low>
|
||||
- **Impacts**: <what this blocks downstream — be specific>
|
||||
- **Impacts**: <what this blocks downstream: be specific>
|
||||
- **Blocked on**: <concrete dependency> (for deferred(scope))
|
||||
**Investigation**: <what work would make the shape clear> (for deferred(unclear))
|
||||
- **Resolution**: Not yet decidable. <Why — either the information
|
||||
- **Resolution**: Not yet decidable. <Why: either the information
|
||||
doesn't exist yet (deferred(scope)) or the pieces exist but the
|
||||
composition isn't clear (deferred(unclear), cite the pieces).>
|
||||
- **Cross-references**: OQ-NN, ADR-NNN
|
||||
@@ -410,13 +410,13 @@ A decision should be `deferred(unclear)` when:
|
||||
- Do not mark a deferred decision as `resolved` with caveats. "Resolved
|
||||
with an escape hatch" is hedging.
|
||||
- Do not leave a deferred decision as `open` without a blocking
|
||||
condition. "Open" means "needs to be resolved now" — if it can't be
|
||||
condition. "Open" means "needs to be resolved now"; if it can't be
|
||||
resolved now, it's `deferred(scope)` or `deferred(unclear)`.
|
||||
- Do not confuse the two deferral kinds. If the information is missing,
|
||||
it's `deferred(scope)`. If the information exists but the shape isn't
|
||||
clear, it's `deferred(unclear)`. Confusing them produces circular
|
||||
reasoning — a `deferred(scope)` whose blocker is actually a
|
||||
prerequisite of the thing being deferred.
|
||||
clear, it's `deferred(unclear)`. Confusing them produces circular
|
||||
reasoning: a `deferred(scope)` whose blocker is actually a
|
||||
prerequisite of the thing being deferred.
|
||||
|
||||
## When to Redirect
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ Check coverage of:
|
||||
|
||||
#### F. Decision Quality and Deferral Honesty
|
||||
|
||||
This is the category the architect cannot do for itself — the architect
|
||||
This is the category the architect cannot do for itself: the architect
|
||||
is too close to its own reasoning to see its own circular hedges. A
|
||||
fresh context can see the whole picture and spot reasoning that folds
|
||||
back on itself. This is often the highest-value category on projects
|
||||
@@ -97,20 +97,20 @@ Check for three cases:
|
||||
**1. Hedging on a resolved decision.** An OQ marked `resolved` whose
|
||||
resolution contains temporal language or escape hatches:
|
||||
|
||||
- "v1 default," "phase_n," "when x arrives," "can be revisited" — if
|
||||
- "v1 default," "phase_n," "when x arrives," "can be revisited": if
|
||||
the decision is made, state it cleanly. Reserve temporal language
|
||||
for genuinely deferred decisions.
|
||||
- "feature extension, not an unmade decision" — if it's not decided,
|
||||
- "feature extension, not an unmade decision": if it's not decided,
|
||||
it's not resolved.
|
||||
- "additive, not blocking" — if it's not decided, don't claim it is.
|
||||
- "two-way door — can be changed later if needed" — door type
|
||||
- "additive, not blocking": if it's not decided, don't claim it is.
|
||||
- "two-way door, can be changed later if needed": door type
|
||||
classifies reversal cost, not whether a decision is made.
|
||||
- "not a v1 blocker" — if it's not decided, it's deferred. Say what
|
||||
- "not a v1 blocker": if it's not decided, it's deferred. Say what
|
||||
unblocks it.
|
||||
- "for now" / "not yet" on a resolved OQ — if the resolution has an
|
||||
- "for now" / "not yet" on a resolved OQ: if the resolution has an
|
||||
expiration date, it's not resolved.
|
||||
- Resolution text primarily about how the decision can be changed
|
||||
later ("X, but here's how we'd undo X") — the decision is made; drop
|
||||
later ("X, but here's how we'd undo X"): the decision is made; drop
|
||||
the undo instructions. If it's "X for now, Y later," the decision is
|
||||
not made.
|
||||
|
||||
@@ -120,7 +120,7 @@ drop the hedge) or not made (mark it `deferred(scope)` or
|
||||
|
||||
**2. False deferral / circular hedge.** A deferred OQ whose blocking
|
||||
condition is a *prerequisite* of the thing being deferred, not a
|
||||
blocker. This is the most damaging pattern — it creates a circular
|
||||
blocker. This is the most damaging pattern: it creates a circular
|
||||
dependency where the deferred thing can never resolve because its
|
||||
blocker needs it.
|
||||
|
||||
@@ -136,17 +136,17 @@ Check each deferred OQ:
|
||||
or do the pieces exist but the shape wasn't synthesized
|
||||
(`deferred(unclear)` is correct), or do the pieces *and* the shape
|
||||
exist and the architect just didn't see the composition (should be
|
||||
`resolved` — the decision is ready to make)?
|
||||
`resolved`; the decision is ready to make)?
|
||||
|
||||
Flag as **critical**: the deferral is circular or inverted. Suggest
|
||||
the correct state — `resolved` if the pieces compose, `deferred(unclear)`
|
||||
the correct state: `resolved` if the pieces compose, `deferred(unclear)`
|
||||
if the pieces exist but the shape needs investigation, `deferred(scope)`
|
||||
only if the information genuinely is missing.
|
||||
|
||||
**3. Legitimate deferral.** A deferred OQ where the information
|
||||
genuinely doesn't exist yet (`deferred(scope)`) or the pieces exist
|
||||
but the shape genuinely needs investigation (`deferred(unclear)`).
|
||||
Leave these — they're honest. The distinction from case 2 is that
|
||||
Leave these; they're honest. The distinction from case 2 is that
|
||||
the blocking condition or investigation target does *not* depend on
|
||||
the thing being deferred.
|
||||
|
||||
@@ -156,12 +156,12 @@ Every unresolved OQ (`open`, `deferred(scope)`, `deferred(unclear)`,
|
||||
`partially resolved`) should have an **impacts** field stating what it
|
||||
blocks downstream. Check:
|
||||
|
||||
- Is the impacts field present? Absence is a warning — without it, the
|
||||
- Is the impacts field present? Absence is a warning; without it, the
|
||||
deferral's urgency is invisible and triage is guesswork.
|
||||
- Is it specific? "Blocks the first hub deployment because the hub
|
||||
dials workers" is useful. "Blocks the hub crate" is boilerplate.
|
||||
- Does it match the priority? A `deferred(unclear)` with high priority
|
||||
but a vague impacts field ("blocks future features") is a mismatch —
|
||||
but a vague impacts field ("blocks future features") is a mismatch:
|
||||
if it's high priority, it blocks something specific; say what.
|
||||
|
||||
Flag missing impacts fields as **warning**, vague ones as
|
||||
@@ -259,5 +259,5 @@ section 2"
|
||||
- Critical issues must block stabilization
|
||||
- The Decision Quality (F) category is often the highest-value check on
|
||||
projects creating new protocols or solving poorly-defined problems.
|
||||
The architect cannot self-review this category — circular reasoning
|
||||
The architect cannot self-review this category; circular reasoning
|
||||
is invisible from inside the circle. Prioritize it.
|
||||
@@ -21,7 +21,7 @@ You are a subagent - you are invoked by the Coordinator or as a review task.
|
||||
## Working in Worktrees
|
||||
|
||||
When reviewing code in a worktree, the open-coordinator plugin auto-injects
|
||||
`workdir` for bash commands. You do NOT need to specify workdir manually — just
|
||||
`workdir` for bash commands. You do NOT need to specify workdir manually, just
|
||||
run commands as usual.
|
||||
|
||||
```text
|
||||
@@ -111,10 +111,10 @@ cargo fmt --check # Format check
|
||||
For this project, also verify:
|
||||
|
||||
- No comments in code (per project convention)
|
||||
- Error handling uses `anyhow::Result` (application) / `thiserror` (library) — no
|
||||
panics in library code
|
||||
- Feature flags are used correctly (`tls`, `iroh`, `acme`) — base crate compiles
|
||||
lean
|
||||
- Error handling uses `anyhow::Result` (application) / `thiserror` (library);
|
||||
no panics in library code
|
||||
- Feature flags are used correctly (`tls`, `iroh`, `acme`): the base crate
|
||||
compiles lean
|
||||
- Public API is well-documented with `///` doc comments where appropriate
|
||||
- Module structure follows Rust conventions (`mod.rs`, `lib.rs`)
|
||||
- No unnecessary `unwrap()` or `expect()` in library code
|
||||
|
||||
@@ -23,7 +23,7 @@ You manage the execution of decomposed task graphs:
|
||||
## The `worktree` Tool (via @alkimiadev/open-coordinator)
|
||||
|
||||
You use the **worktree** tool with `{action, args}` dispatch. Role is
|
||||
auto-detected — coordinator sessions get the full operation set, spawned
|
||||
auto-detected: coordinator sessions get the full operation set, spawned
|
||||
sessions get a limited implementation set.
|
||||
|
||||
### Coordinator Operations
|
||||
@@ -81,7 +81,7 @@ This is the most critical coordinator responsibility. Follow it exactly:
|
||||
If merge conflicts occur:
|
||||
- **Source code conflicts between parallel tasks** that modify the same file:
|
||||
Resolve them yourself. Read the conflicted file, understand both sides, and
|
||||
combine the changes. Both sets of changes are valid — they were just
|
||||
combine the changes. Both sets of changes are valid; they were just
|
||||
developed in parallel.
|
||||
- **Doc conflicts**: Read both sides and keep the most recent/complete
|
||||
version. Often one branch cleaned up drift tables while another updated
|
||||
@@ -112,7 +112,7 @@ This is the most critical coordinator responsibility. Follow it exactly:
|
||||
```text
|
||||
worktree({action: "cleanup", args: {action: "remove", pathOrBranch: "feat/<task-name>", remote: true}})
|
||||
```
|
||||
The `remote: true` flag tells the plugin to also delete the remote branch —
|
||||
The `remote: true` flag tells the plugin to also delete the remote branch,
|
||||
no separate `git push origin --delete` needed. If you need to force-remove a
|
||||
dirty worktree, add `force: true`.
|
||||
|
||||
@@ -132,12 +132,12 @@ in this order:
|
||||
latest main)
|
||||
|
||||
If two tasks modify the same source files and were developed in parallel, you
|
||||
WILL get merge conflicts. This is expected — resolve them.
|
||||
WILL get merge conflicts. This is expected; resolve them.
|
||||
|
||||
### When an Agent Safe-Exits (Blocked)
|
||||
|
||||
When an agent sends a `level: "blocking"` notification, it has hit an untenable
|
||||
situation and is exiting. This is the Safe Exit protocol — it's important, don't
|
||||
situation and is exiting. This is the Safe Exit protocol; it's important, don't
|
||||
ignore it.
|
||||
|
||||
1. **Read the blocking message carefully.** The agent should have included what
|
||||
@@ -162,7 +162,7 @@ ignore it.
|
||||
```
|
||||
|
||||
4. **Try to resolve the blocker:**
|
||||
- Missing context? Send it via `worktree({action: "message", ...})` — but
|
||||
- Missing context? Send it via `worktree({action: "message", ...})`, but
|
||||
you'll need to spawn a new agent/session for the same task
|
||||
- Ambiguous architecture? Ask the user to clarify
|
||||
- Scope too large? Decompose into smaller tasks
|
||||
@@ -179,14 +179,14 @@ ignore it.
|
||||
The `prompt` parameter supports `{{task}}` template substitution. Use it, but
|
||||
also include:
|
||||
|
||||
1. **Task identification** — How to find their task file in `tasks/`
|
||||
2. **Merge from main** — Tell them to
|
||||
1. **Task identification**: How to find their task file in `tasks/`
|
||||
2. **Merge from main**: Tell them to
|
||||
`git fetch origin && git merge origin/main --no-edit` before starting, since
|
||||
main may have advanced since their worktree was created
|
||||
3. **Key references** — Which source files and architecture docs to read
|
||||
4. **Project constraints** — Important rules from the repo (no comments,
|
||||
3. **Key references**: Which source files and architecture docs to read
|
||||
4. **Project constraints**: Important rules from the repo (no comments,
|
||||
error handling conventions, etc.)
|
||||
5. **Done signal** — Use `worktree({action: "notify", ...})` when complete
|
||||
5. **Done signal**: Use `worktree({action: "notify", ...})` when complete
|
||||
|
||||
Example prompt template:
|
||||
|
||||
@@ -200,7 +200,7 @@ Your task: {{task}}
|
||||
3. Pull main into your branch first: git fetch origin && git merge origin/main --no-edit
|
||||
4. Implement the changes, following all acceptance criteria.
|
||||
5. Run cargo build, cargo clippy -- -D warnings, cargo test, cargo fmt --check. Fix any failures.
|
||||
6. Commit ONLY source code — do not commit task files (tasks/*.md). The coordinator manages task status on main.
|
||||
6. Commit ONLY source code; do not commit task files (tasks/*.md). The coordinator manages task status on main.
|
||||
7. Push: git push origin $(git branch --show-current)
|
||||
8. Notify: worktree({action: "notify", args: {message: "Task completed: {{task}}. <brief summary>", level: "info"}})
|
||||
|
||||
@@ -230,7 +230,7 @@ For example, if Generation 2 has tasks A (depends on X), B (depends on Y), and C
|
||||
|
||||
When spawning parallel tasks, check if they modify overlapping source files.
|
||||
Tasks that share source files (e.g., both modify `src/transport.rs`) are likely to
|
||||
cause merge conflicts. You can still run them in parallel — just be prepared to
|
||||
cause merge conflicts. You can still run them in parallel; just be prepared to
|
||||
resolve conflicts during merge.
|
||||
|
||||
If you want to avoid conflicts, make overlapping tasks sequential. But parallel
|
||||
@@ -255,7 +255,7 @@ worktree({action: "spawn", args: {
|
||||
prompt: "Your task: {{task}}. Read tasks/{{task}}.md for details."
|
||||
}})
|
||||
|
||||
# Review tasks — often handle yourself
|
||||
# Review tasks: often handle yourself
|
||||
# If level: review, verify the acceptance criteria against the codebase
|
||||
# directly instead of spawning a new agent
|
||||
```
|
||||
@@ -266,7 +266,7 @@ worktree({action: "spawn", args: {
|
||||
|
||||
The notification system works well. When an agent completes, you receive a
|
||||
notification in your session. When an anomaly is detected, you receive an alert.
|
||||
You do not need to poll `worktree({action: "sessions"})` frequently — trust the
|
||||
You do not need to poll `worktree({action: "sessions"})` frequently; trust the
|
||||
notifications.
|
||||
|
||||
Check `worktree({action: "sessions"})` when:
|
||||
@@ -312,7 +312,7 @@ Use these when:
|
||||
|
||||
When a task has `level: review`, verify the acceptance criteria yourself instead
|
||||
of spawning a new agent. Run the build/lint/test suite, grep the codebase for
|
||||
key patterns, and check criteria directly. Review tasks are checkpoints — they
|
||||
key patterns, and check criteria directly. Review tasks are checkpoints; they
|
||||
don't produce code changes.
|
||||
|
||||
Only spawn a review task as an agent if the review requires extensive manual
|
||||
@@ -321,7 +321,7 @@ inspection of many files.
|
||||
## Task File Handling
|
||||
|
||||
Task files (`tasks/*.md`) are coordination state. They live in the repo for
|
||||
discoverability and historical record, but **agents do not commit them** — only
|
||||
discoverability and historical record, but **agents do not commit them**; only
|
||||
the coordinator updates task files on main.
|
||||
|
||||
### Why Agents Don't Commit Task Files
|
||||
@@ -400,12 +400,12 @@ When an agent reports blocked or an anomaly fires:
|
||||
what happened
|
||||
2. Send guidance via `worktree({action: "message", ...})` if you can help
|
||||
3. Abort via `worktree({action: "abort", ...})` if unrecoverable
|
||||
4. Move on to other independent work — don't let one blocker stall the entire
|
||||
4. Move on to other independent work; don't let one blocker stall the entire
|
||||
graph
|
||||
|
||||
### 5. Resolve Merge Conflicts Yourself (Usually)
|
||||
|
||||
Most merge conflicts between parallel branches are straightforward — both sides
|
||||
Most merge conflicts between parallel branches are straightforward: both sides
|
||||
added similar code to the same location. Read the conflicts, combine both sets
|
||||
of changes, validate, and commit. Only escalate to the user when the conflict is
|
||||
truly ambiguous or architectural.
|
||||
@@ -418,7 +418,7 @@ After merging and pushing:
|
||||
```text
|
||||
worktree({action: "cleanup", args: {action: "remove", pathOrBranch: "feat/<task-name>", remote: true}})
|
||||
```
|
||||
The `remote: true` flag handles remote branch deletion automatically — no
|
||||
The `remote: true` flag handles remote branch deletion automatically; no
|
||||
separate `git push origin --delete` needed.
|
||||
|
||||
Don't let stale branches accumulate.
|
||||
|
||||
@@ -10,7 +10,7 @@ graph.
|
||||
## Your Environment
|
||||
|
||||
**You are in a worktree.** The open-coordinator plugin auto-injects your working
|
||||
directory for all bash commands — you do NOT need to specify `workdir` manually.
|
||||
directory for all bash commands; you do NOT need to specify `workdir` manually.
|
||||
|
||||
**Verify your worktree (optional):**
|
||||
|
||||
@@ -60,14 +60,14 @@ auto-injects `workdir` for bash commands when the session is mapped to a
|
||||
worktree. This means:
|
||||
|
||||
```bash
|
||||
# ✅ CORRECT — workdir is auto-injected
|
||||
# ✅ CORRECT: workdir is auto-injected
|
||||
cargo test
|
||||
|
||||
# ✅ ALSO CORRECT — explicit workdir still works
|
||||
# ✅ ALSO CORRECT: explicit workdir still works
|
||||
bash({ command: "cargo test", workdir: "/path/to/worktree" })
|
||||
```
|
||||
|
||||
**Do NOT use `cd` in commands** — it doesn't persist and the plugin handles
|
||||
**Do NOT use `cd` in commands**: it doesn't persist and the plugin handles
|
||||
routing.
|
||||
|
||||
## Workflow
|
||||
@@ -130,7 +130,7 @@ Check each acceptance criterion in the task file.
|
||||
### 5. Commit and Notify
|
||||
|
||||
```bash
|
||||
# Stage only source code — NOT task files
|
||||
# Stage only source code (NOT task files)
|
||||
git add src/ test/ docs/ # or specific files as appropriate
|
||||
git commit -m "feat(<task-id>): <description>"
|
||||
git push origin $(git branch --show-current)
|
||||
@@ -178,7 +178,7 @@ When task becomes untendable:
|
||||
worktree({action: "notify", args: {message: "Blocked on <task-id>: <detailed explanation including what was attempted, what failed, and suggested resolution>", level: "blocking"}})
|
||||
```
|
||||
3. **Commit any partial source code progress** if it's coherent (you may not
|
||||
have any — that's fine)
|
||||
have any; that's fine)
|
||||
4. **Push your branch** so the coordinator can inspect your work if needed
|
||||
5. **Exit** - coordinator handles escalation
|
||||
|
||||
@@ -194,13 +194,13 @@ If NOT in worktree:
|
||||
|
||||
When available, use memory tools to manage your context:
|
||||
|
||||
- `memory({tool: "context"})` — check context window usage, especially during
|
||||
- `memory({tool: "context"})`: check context window usage, especially during
|
||||
long implementations
|
||||
- `memory({tool: "messages", args: {sessionId: "..."}})` — review previous
|
||||
- `memory({tool: "messages", args: {sessionId: "..."}})`: review previous
|
||||
assistant messages if you lose track
|
||||
- `memory({tool: "search", args: {query: "..."}})` — search past conversations
|
||||
- `memory({tool: "search", args: {query: "..."}})`: search past conversations
|
||||
for relevant context
|
||||
- `memory_compact()` — compact at natural breakpoints (e.g., after completing a
|
||||
- `memory_compact()`: compact at natural breakpoints (e.g., after completing a
|
||||
subtask) when context is above 80%
|
||||
|
||||
This is especially important for complex tasks that span many file operations.
|
||||
@@ -209,34 +209,34 @@ This is especially important for complex tasks that span many file operations.
|
||||
|
||||
Read `AGENTS.md` at project root for full details. Key rules:
|
||||
|
||||
1. **No comments in code** — Per project convention. Doc comments (`///`, `//!`)
|
||||
1. **No comments in code**. Per project convention. Doc comments (`///`, `//!`)
|
||||
are fine and expected on public API. Inline `//` comments only when the user
|
||||
asks or when a non-obvious safety/correctness constraint would otherwise be
|
||||
missed (e.g., "zero-length stdin is the EOF sentinel — the codec does not
|
||||
missed (e.g., "zero-length stdin is the EOF sentinel: the codec does not
|
||||
special-case it; the adapter interprets `length == 0` chunks").
|
||||
2. **Error handling** — `thiserror` for library error types (`TtyError`,
|
||||
2. **Error handling**: `thiserror` for library error types (`TtyError`,
|
||||
`NegotiateError`, `WireError`; `HandlerError`/`StreamError` come from
|
||||
alkcall::core). No panics in library code. No `unwrap()` or `expect()`
|
||||
outside tests. For poisoned `RwLock`/`Mutex`, use
|
||||
`unwrap_or_else(|e| e.into_inner())`.
|
||||
3. **`tokio` is the async runtime** — all I/O is async. The adapter's
|
||||
3. **`tokio` is the async runtime**: all I/O is async. The adapter's
|
||||
per-session pumps, the channels integration, and `TtySession` are all async.
|
||||
Use `tokio::sync` primitives (`oneshot`, `mpsc`) for request/exit
|
||||
correlation and stdout/stderr streams. The PTY bridge in `src/local/pty.rs`
|
||||
is the one exception: three dedicated std threads feeding tokio mpsc/oneshot
|
||||
channels (the `portable_pty` API is blocking) — behind the `local` feature.
|
||||
4. **WASM target is load-bearing** — the default crate (no `local` feature)
|
||||
channels (the `portable_pty` API is blocking). Behind the `local` feature.
|
||||
4. **WASM target is load-bearing**: the default crate (no `local` feature)
|
||||
MUST compile to `wasm32-unknown-unknown`. This is what makes the downstream
|
||||
TS/Python adapter story work. `Cargo.toml` uses
|
||||
`tokio = { default-features = false, features = ["rt", "sync", "io-util", "macros"] }`
|
||||
(the wasm-clean subset alkcall uses); `local` adds `tokio/process` +
|
||||
`tokio/rt-multi-thread`. **Do NOT use `features = ["full"]`** — it pulls in
|
||||
`tokio/rt-multi-thread`. **Do NOT use `features = ["full"]`**: it pulls in
|
||||
`signal`/`fs`/`net` which break `wasm32-unknown-unknown`. The adapter's
|
||||
`tokio::spawn` is fine on wasm; the `local` module's std threads,
|
||||
`tokio::process::Command`, and `libc::kill` are the non-wasm parts, and
|
||||
they're feature-gated.
|
||||
5. **Wire formats are stable** — two one-way doors:
|
||||
- **5-byte chunk header** (`[stream_type: u8][length: u32 BE][payload]`) —
|
||||
5. **Wire formats are stable**: two one-way doors:
|
||||
- **5-byte chunk header** (`[stream_type: u8][length: u32 BE][payload]`),
|
||||
the raw chunk codec in `wire.rs`. Five stream types: `STREAM_STDIN=0`,
|
||||
`STREAM_STDOUT=1`, `STREAM_STDERR=2`, `STREAM_CTRL_IN=3`,
|
||||
`STREAM_CTRL_OUT=4`. Zero-length data chunks are sentinels (zero-length
|
||||
@@ -244,14 +244,14 @@ Read `AGENTS.md` at project root for full details. Key rules:
|
||||
control chunks are never zero-length. Changing the header, the
|
||||
stream-type values, or the sentinel semantics breaks all peers.
|
||||
- **Negotiation frame** (4-byte BE length prefix + UTF-8 JSON
|
||||
`NegotiateRequest` body) — self-contained, not reused from alkcall's
|
||||
`NegotiateRequest` body): self-contained, not reused from alkcall's
|
||||
`EventEnvelope` framing. The `NegotiateRequest` JSON shape is
|
||||
wire-stable once consumers exist.
|
||||
Per ADR-093: TTY always uses its 5-byte format, even inside channels. The
|
||||
channels layer strips its 8-byte header transparently — the same `wire.rs`
|
||||
channels layer strips its 8-byte header transparently; the same `wire.rs`
|
||||
code runs in both direct (`alk/tty`) and channels (`alk/channels`) modes;
|
||||
only the `BiStream` source differs.
|
||||
6. **`TtyBackend` trait shape is a one-way door** — the trait in
|
||||
6. **`TtyBackend` trait shape is a one-way door**: the trait in
|
||||
`src/backend.rs` is the inversion point between the wire-format adapter and
|
||||
the backend crates (alktty's own `local` feature module, future
|
||||
`alknet-docker`, `alknet-ssh`). alktty defines the trait; the backends
|
||||
@@ -259,10 +259,10 @@ Read `AGENTS.md` at project root for full details. Key rules:
|
||||
keyed by the negotiation frame's `backend` string; backends produce
|
||||
`TtyHandle`s, they do not write to the wire. `TtyError` is
|
||||
`#[non_exhaustive]` so new variants are additive.
|
||||
7. **Producer/consumer, not server/client** — both sides of a `alk/tty` or
|
||||
7. **Producer/consumer, not server/client**: both sides of a `alk/tty` or
|
||||
`alk/channels` connection can initiate. Use "producer"/"consumer" or
|
||||
"accept side"/"connect side," not "server"/"client."
|
||||
8. **`local` feature isolation** — the `local` module is the only non-wasm
|
||||
8. **`local` feature isolation**: the `local` module is the only non-wasm
|
||||
part of the crate. The shared/producer/consumer modules (`wire`, `control`,
|
||||
`negotiation`, `backend`, `adapter`, `channels`, `session`) MUST NOT import
|
||||
from `crate::local` or reference any `local`-only type (`LocalTtyBackend`,
|
||||
@@ -272,36 +272,36 @@ Read `AGENTS.md` at project root for full details. Key rules:
|
||||
`std::thread`, or `libc` (outside the existing `cfg(unix)` calls in
|
||||
`control.rs`/`pipe.rs`) silently breaks the downstream TS/Python adapter
|
||||
story.
|
||||
9. **Module structure** — one module per file under `src/`, re-exported from
|
||||
9. **Module structure**: one module per file under `src/`, re-exported from
|
||||
`src/lib.rs`. Producer half: `src/adapter.rs` + `src/channels.rs`. Consumer
|
||||
half: `src/session.rs`. Shared: `src/wire.rs`, `src/control.rs`,
|
||||
`src/negotiation.rs`, `src/backend.rs`. The `src/local/` module is behind
|
||||
the `local` feature and never imported from the shared/producer/consumer
|
||||
modules — only re-exported from `lib.rs` under `#[cfg(feature = "local")]`.
|
||||
10. **BAST document, not alktype dependency** — `docs/architecture/tty-bast.md`
|
||||
modules, only re-exported from `lib.rs` under `#[cfg(feature = "local")]`.
|
||||
10. **BAST document, not alktype dependency**: `docs/architecture/tty-bast.md`
|
||||
(Phase 4, not yet written) will be the machine-readable spec for the
|
||||
`alk/tty` wire format, conforming to the BAST meta-schema at
|
||||
`https://alk.dev/bast/v1/schema`. alktty does **not** depend on alktype;
|
||||
the hand-rolled `ChunkReader`/`ChunkWriter` in `wire.rs` is the runtime
|
||||
codec, the BAST document is the human-readable contract. If runtime
|
||||
validation against the BAST becomes desirable later, alktype becomes an
|
||||
optional dep — do not roll your own offset map or validator for complex
|
||||
optional dep; do not roll your own offset map or validator for complex
|
||||
formats.
|
||||
11. **Feature flags** — the only feature is `local` (default = `[]`). `local`
|
||||
11. **Feature flags**: the only feature is `local` (default = `[]`). `local`
|
||||
is inherently non-wasm; enabling it on wasm is a build error by design.
|
||||
Verify both `cargo test` (default) and `cargo test --all-features` pass.
|
||||
Do not add a feature flag that pulls non-wasm deps into the default crate.
|
||||
12. **Access control** — the direct-ALPN path keeps the ad-hoc scope check
|
||||
12. **Access control**: the direct-ALPN path keeps the ad-hoc scope check
|
||||
(`has_scope(identity, TTY_OPEN_SCOPE)`) and optionally consults
|
||||
`OwnershipProvider` (`provider.owns(id_ref, kind, &id, "tty")` — the 4-arg
|
||||
`OwnershipProvider` (`provider.owns(id_ref, kind, &id, "tty")`, the 4-arg
|
||||
shape). The channels path gets both for free via
|
||||
`ChannelCore::register_openable`, which wires `AccessControl` into the
|
||||
operation spec. `OwnershipStore::record(&self, identity, resource_type,
|
||||
resource_id)` is the 3-arg shape (alkcall dropped the old `action` arg).
|
||||
13. **Naming conventions** — Rust standard: `snake_case` for functions/variables/
|
||||
13. **Naming conventions**: Rust standard: `snake_case` for functions/variables/
|
||||
modules, `PascalCase` for types/traits, `SCREAMING_SNAKE_CASE` for
|
||||
constants (`STREAM_STDIN`, `TTY_OPEN_SCOPE`, `CHUNK_HEADER_LEN`).
|
||||
14. **No `unsafe`** — the crate has zero `unsafe` blocks. The PTY bridge's
|
||||
14. **No `unsafe`**: the crate has zero `unsafe` blocks. The PTY bridge's
|
||||
`libc::kill(-pgid, sig)` and `libc::kill(pid, sig)` calls are safe `libc`
|
||||
crate APIs (not `unsafe` blocks in this crate). Do not introduce `unsafe`
|
||||
for performance.
|
||||
|
||||
@@ -10,10 +10,10 @@ approaches.
|
||||
## Your Environment
|
||||
|
||||
**You are in a research worktree.** The open-coordinator plugin auto-injects
|
||||
your working directory for all bash commands — you do NOT need to specify
|
||||
your working directory for all bash commands; you do NOT need to specify
|
||||
`workdir` manually.
|
||||
|
||||
- The current directory IS the worktree — do NOT navigate elsewhere
|
||||
- The current directory IS the worktree; do NOT navigate elsewhere
|
||||
- You are on branch `research/<task-id>`
|
||||
- Use relative paths for all file operations
|
||||
|
||||
@@ -56,11 +56,11 @@ session is mapped to a worktree. This means you can just run commands without
|
||||
specifying workdir:
|
||||
|
||||
```bash
|
||||
# ✅ CORRECT — workdir is auto-injected
|
||||
# ✅ CORRECT: workdir is auto-injected
|
||||
cargo test
|
||||
```
|
||||
|
||||
**Do NOT use `cd` in commands** — it doesn't persist and the plugin handles
|
||||
**Do NOT use `cd` in commands**: it doesn't persist and the plugin handles
|
||||
routing.
|
||||
|
||||
## When You Are Spawned
|
||||
|
||||
@@ -17,7 +17,7 @@ The workflow:
|
||||
1. Make the change
|
||||
2. Verify: `cargo test`, `cargo clippy --all-targets -- -D warnings`,
|
||||
`cargo fmt --check`, `cargo doc --no-deps` if docs changed
|
||||
3. Inspect `git status` and `git diff` before staging — stage only the
|
||||
3. Inspect `git status` and `git diff` before staging; stage only the
|
||||
intended files, never secrets
|
||||
4. Write a concise commit message matching the repo style (see `git log
|
||||
--oneline -10`). For multi-point changes, use a summary line plus a
|
||||
@@ -25,13 +25,13 @@ The workflow:
|
||||
5. `git push origin main`
|
||||
6. Report the commit hash and the verification summary
|
||||
|
||||
Exceptions — **do not** commit or push without asking:
|
||||
Exceptions (**do not** commit or push without asking):
|
||||
|
||||
- The change is exploratory / speculative (you're not sure the user wants
|
||||
it kept)
|
||||
- The user is actively reviewing the diff and may ask for changes
|
||||
- The change touches the wire format or the `TtyBackend` trait shape
|
||||
(one-way doors — see "Wire formats are stable" and "`TtyBackend` trait
|
||||
(one-way doors; see "Wire formats are stable" and "`TtyBackend` trait
|
||||
shape is a one-way door" below; the 5-byte chunk header, the
|
||||
negotiation-frame layout, the `NegotiateRequest` JSON shape, and the
|
||||
trait signature are wire-stable contracts once consumers exist)
|
||||
@@ -39,7 +39,7 @@ Exceptions — **do not** commit or push without asking:
|
||||
commit, or skipping hooks
|
||||
|
||||
Never commit secrets, keys, or credentials. If a commit fails or hooks
|
||||
reject it, fix the issue and create a new commit — do not amend the
|
||||
reject it, fix the issue and create a new commit; do not amend the
|
||||
failed one.
|
||||
|
||||
Git identity is preconfigured (`glm-5.2 <glm-5.2@alk.dev>`). Do not
|
||||
@@ -47,7 +47,7 @@ change `git config`, skip hooks, or use `git commit -i`.
|
||||
|
||||
## Project Conventions (Rust / TTY protocol crate)
|
||||
|
||||
This is the `alk/tty` terminal-session protocol crate — a
|
||||
This is the `alk/tty` terminal-session protocol crate, a
|
||||
producer/consumer protocol crate on top of alkcall channels. It folds
|
||||
the old `alknet-tty` (wire/adapter/backend-trait half) and
|
||||
`alknet-tty-local` (`portable_pty` + `tokio::process` half) from the
|
||||
@@ -61,46 +61,46 @@ implementation agents.
|
||||
project-wide convention. Doc comments (`///`, `//!`) are fine and
|
||||
expected on public API. Inline `//` comments only when the user asks
|
||||
or when a non-obvious safety/correctness constraint would otherwise be
|
||||
missed (e.g., "zero-length stdin is the EOF sentinel — the codec does
|
||||
not special-case it; the adapter interprets `length == 0` chunks").
|
||||
missed (e.g., "zero-length stdin is the EOF sentinel: the codec does
|
||||
not special-case it, and the adapter interprets `length == 0` chunks").
|
||||
|
||||
2. **Error handling** — `thiserror` for library error types
|
||||
2. **Error handling**: `thiserror` for library error types
|
||||
(`TtyError`, `NegotiateError`, `WireError`, `HandlerError`/`StreamError`
|
||||
come from alkcall::core). No panics in library code. No `unwrap()` or
|
||||
`expect()` outside tests. If you reach for `unwrap`, the error path
|
||||
wasn't specified — stop and decide what should actually happen. For
|
||||
wasn't specified; stop and decide what should actually happen. For
|
||||
poisoned `RwLock`/`Mutex`, use
|
||||
`unwrap_or_else(|e| e.into_inner())` so a panic in one operation does
|
||||
not cascade to other operations.
|
||||
|
||||
3. **`tokio` is the async runtime** — all I/O is async. The adapter's
|
||||
3. **`tokio` is the async runtime**: all I/O is async. The adapter's
|
||||
per-session pumps, the channels integration, and `TtySession` are all
|
||||
async. Use `tokio::sync` primitives (`oneshot`, `mpsc`) for
|
||||
request/exit correlation and stdout/stderr streams. The PTY bridge in
|
||||
`src/local/pty.rs` is the one exception: it uses three dedicated std
|
||||
threads feeding tokio mpsc/oneshot channels (the `portable_pty` API
|
||||
is blocking) — and it's behind the `local` feature.
|
||||
is blocking), and it's behind the `local` feature.
|
||||
|
||||
4. **WASM target is load-bearing** — the default crate (no `local`
|
||||
4. **WASM target is load-bearing**: the default crate (no `local`
|
||||
feature) MUST compile to `wasm32-unknown-unknown`. This is what makes
|
||||
the downstream TS/Python adapter story work (a wasm-compiled alktty
|
||||
is the protocol layer for a sandboxed adapter). `Cargo.toml` uses
|
||||
`tokio = { default-features = false, features = ["rt", "sync",
|
||||
"io-util", "macros"] }` (the wasm-clean subset alkcall uses) and
|
||||
`local` adds `tokio/process` + `tokio/rt-multi-thread`. **Do NOT use
|
||||
`features = ["full"]`** — it pulls in `signal`/`fs`/`net` which break
|
||||
`features = ["full"]`**, because it pulls in `signal`/`fs`/`net`, which break
|
||||
`wasm32-unknown-unknown`. The adapter's `tokio::spawn` for per-session
|
||||
pumps is fine on wasm (the wasm tokio runtime supports `spawn`); the
|
||||
`local` module's std threads, `tokio::process::Command`, and
|
||||
`libc::kill` are the non-wasm parts, and they're feature-gated.
|
||||
`libc` stays under `cfg(unix)` (it's already there for
|
||||
`signal_from_name` and the pipe-mode `kill` path — neither runs on
|
||||
`signal_from_name` and the pipe-mode `kill` path, neither of which runs on
|
||||
wasm because the only callers are in `local`).
|
||||
|
||||
5. **Wire formats are stable** — two wire formats live in this crate,
|
||||
5. **Wire formats are stable**. Two wire formats live in this crate,
|
||||
both one-way doors:
|
||||
- **5-byte chunk header** (`[stream_type: u8][length: u32 BE]
|
||||
[payload]`) — the raw chunk codec in `wire.rs`. Five stream types:
|
||||
[payload]`), the raw chunk codec in `wire.rs`. Five stream types:
|
||||
`STREAM_STDIN=0`, `STREAM_STDOUT=1`, `STREAM_STDERR=2`,
|
||||
`STREAM_CTRL_IN=3`, `STREAM_CTRL_OUT=4`. Zero-length data chunks are
|
||||
sentinels (zero-length stdin = EOF from client; zero-length stdout
|
||||
@@ -108,7 +108,7 @@ implementation agents.
|
||||
Changing the header, the stream-type values, or the sentinel
|
||||
semantics breaks all peers. See ADR-052 (ported as alktty ADR-001).
|
||||
- **Negotiation frame** (4-byte BE length prefix + UTF-8 JSON
|
||||
`NegotiateRequest` body) — self-contained per ADR-057 (ported as
|
||||
`NegotiateRequest` body), self-contained per ADR-057 (ported as
|
||||
alktty ADR-006), not reused from alkcall's `EventEnvelope` framing
|
||||
(the payload is `NegotiateRequest`, not `EventEnvelope`; alkcall's
|
||||
`FrameFramedReader` is hardcoded to deserialize `EventEnvelope`).
|
||||
@@ -117,11 +117,11 @@ implementation agents.
|
||||
|
||||
Per ADR-093 (ported as alktty ADR-008): TTY always uses its 5-byte
|
||||
format, even inside channels. The channels layer strips its 8-byte
|
||||
header and hands TTY the payload transparently — the same `wire.rs`
|
||||
header and hands TTY the payload transparently; the same `wire.rs`
|
||||
code runs in both direct (`alk/tty`) and channels (`alk/channels`)
|
||||
modes; only the `BiStream` source differs.
|
||||
|
||||
6. **`TtyBackend` trait shape is a one-way door** — the trait
|
||||
6. **`TtyBackend` trait shape is a one-way door**: the trait
|
||||
(`src/backend.rs`) is the inversion point between the wire-format
|
||||
adapter and the backend crates (alktty's own `local` feature module,
|
||||
future `alknet-docker`, `alknet-ssh`). alktty defines the trait; the
|
||||
@@ -134,90 +134,90 @@ implementation agents.
|
||||
(two-way-door extension within the one-way trait shape). See ADR-053
|
||||
(ported as alktty ADR-002).
|
||||
|
||||
7. **Producer/consumer, not server/client** — inherited from alkcall.
|
||||
7. **Producer/consumer, not server/client** (inherited from alkcall).
|
||||
Both sides of a `alk/tty` or `alk/channels` connection can initiate.
|
||||
A producer exposes TTY (direct ALPN via `TtyAdapter::ProtocolHandler`,
|
||||
or via `channels::register_openable`); a consumer opens a session
|
||||
(`TtySession::connect_direct` / `open_via_channels`). Both sides can
|
||||
be both simultaneously — connection direction (who opened it) is
|
||||
be both simultaneously: connection direction (who opened it) is
|
||||
independent of TTY direction (who's the shell, who's the client).
|
||||
Avoid "server" and "client" framing in docs and API names; use
|
||||
"producer" and "consumer," or "accept side" / "connect side" for the
|
||||
connection-establishment half specifically.
|
||||
|
||||
8. **Module structure** — one module per file under `src/`, re-exported
|
||||
8. **Module structure**: one module per file under `src/`, re-exported
|
||||
from `src/lib.rs`. Public API surface is `lib.rs` re-exports. The
|
||||
crate has three concerns:
|
||||
- **Producer half** — `src/adapter.rs` (`TtyAdapter` + `drive_session`
|
||||
- **Producer half**: `src/adapter.rs` (`TtyAdapter` + `drive_session`
|
||||
for direct `alk/tty`) and `src/channels.rs` (`register_openable`
|
||||
helper + `TtyOpenHandler` for the `alk/channels` multiplexed path).
|
||||
- **Consumer half** — `src/session.rs` (`TtySession` typed client
|
||||
- **Consumer half**: `src/session.rs` (`TtySession` typed client
|
||||
with `connect_direct` and `open_via_channels` constructors).
|
||||
- **Shared** — `src/wire.rs` (chunk codec), `src/control.rs`
|
||||
- **Shared**: `src/wire.rs` (chunk codec), `src/control.rs`
|
||||
(control-message enum + `signal_from_name`), `src/negotiation.rs`
|
||||
(negotiation frame + `NegotiateRequest`), `src/backend.rs`
|
||||
(`TtyBackend` trait, `TtyHandle`, `TtyParams`, `TtyError`).
|
||||
The `src/local/` module (the folded `alknet-tty-local`) is behind the
|
||||
`local` feature and never imported from the shared/producer/consumer
|
||||
modules — only re-exported from `lib.rs` under
|
||||
modules; it is only re-exported from `lib.rs` under
|
||||
`#[cfg(feature = "local")]`.
|
||||
|
||||
9. **`local` feature isolation** — the `local` module is the only
|
||||
9. **`local` feature isolation**: the `local` module is the only
|
||||
non-wasm part of the crate. The shared/producer/consumer modules
|
||||
(`wire`, `control`, `negotiation`, `backend`, `adapter`, `channels`,
|
||||
`session`) MUST NOT import from `crate::local` or reference any
|
||||
`local`-only type (`LocalTtyBackend`, `portable_pty`,
|
||||
`tokio::process::Command`). The producer's `TtyAdapter` takes a
|
||||
`HashMap<String, Arc<dyn TtyBackend>>` — the local backend is
|
||||
`HashMap<String, Arc<dyn TtyBackend>>`; the local backend is
|
||||
injected at the assembly layer, not wired in by the adapter. A
|
||||
regression where a non-`local` module pulls in `portable_pty`,
|
||||
`tokio::process`, `std::thread`, or `libc` (outside the existing
|
||||
`cfg(unix)` calls in `control.rs`/`pipe.rs`) silently breaks the
|
||||
downstream TS/Python adapter story.
|
||||
|
||||
10. **BAST document for the wire format** — `docs/architecture/tty-bast.md`
|
||||
10. **BAST document for the wire format**: `docs/architecture/tty-bast.md`
|
||||
(Phase 4, not yet written) will be the machine-readable spec for the
|
||||
`alk/tty` wire format, conforming to the BAST meta-schema at
|
||||
`https://alk.dev/bast/v1/schema`. BAST is plain JSON — no
|
||||
dependency required to author or consume it. alktty does **not**
|
||||
`https://alk.dev/bast/v1/schema`. BAST is plain JSON; no
|
||||
dependency is required to author or consume it. alktty does **not**
|
||||
depend on alktype; the hand-rolled `ChunkReader`/`ChunkWriter` in
|
||||
`wire.rs` is the runtime codec, the BAST document is the
|
||||
human-readable contract that describes what those types round-trip.
|
||||
If runtime validation against the BAST becomes desirable later,
|
||||
alktype becomes an optional dep and the BAST document is already
|
||||
there to feed it. Do not roll your own offset map or validator for
|
||||
complex formats — use alktype (as an optional dep) when that
|
||||
complex formats; use alktype (as an optional dep) when that
|
||||
surfaces.
|
||||
|
||||
11. **Access control** — the producer's direct-ALPN path keeps the
|
||||
11. **Access control**. The producer's direct-ALPN path keeps the
|
||||
existing ad-hoc scope check (`has_scope(identity, TTY_OPEN_SCOPE)`)
|
||||
for the scope gate and optionally consults `OwnershipProvider` for
|
||||
the resource-ownership check (`provider.owns(id_ref, kind, &id,
|
||||
"tty")` — the 4-arg shape alkcall adopted). The channels path gets
|
||||
"tty")`, the 4-arg shape alkcall adopted). The channels path gets
|
||||
the scope gate for free via `ChannelCore::register_openable`
|
||||
(which wires `AccessControl` into the operation spec — the
|
||||
(which wires `AccessControl` into the operation spec, so the
|
||||
registry runs the ACL before the wrapper) and runs the ownership
|
||||
check in the establisher (ADR-010 — it is part of the
|
||||
check in the establisher (ADR-010: it is part of the
|
||||
establishment phase since alkcall 0.5.0 ADR-049, so a denial is
|
||||
`channel:open_failed`, not an in-band frame; since alkcall 0.6.0
|
||||
ADR-010 §2A the establisher also allocates — the pump handler
|
||||
ADR-010 §2A the establisher also allocates, and the pump handler
|
||||
only pumps). Terminal sessions are resources per
|
||||
ADR-050 (ported as alktty ADR-003); `OwnershipStore::record(&self,
|
||||
identity, resource_type, resource_id)` is the 3-arg shape (alkcall
|
||||
dropped the old `action` arg).
|
||||
|
||||
12. **Feature flags** — the only feature is `local` (default = `[]`).
|
||||
12. **Feature flags**: the only feature is `local` (default = `[]`).
|
||||
`local` is inherently non-wasm (`portable-pty` + `tokio::process`
|
||||
need a real OS); enabling `local` on wasm is a build error by
|
||||
design. Verify both `cargo test` (default) and
|
||||
`cargo test --all-features` pass. Do not add a feature flag that
|
||||
pulls non-wasm deps into the default crate.
|
||||
|
||||
13. **Naming** — Rust standard: `snake_case` for functions/variables/
|
||||
13. **Naming** follows the Rust standard: `snake_case` for functions/variables/
|
||||
modules, `PascalCase` for types/traits, `SCREAMING_SNAKE_CASE` for
|
||||
constants (`STREAM_STDIN`, `TTY_OPEN_SCOPE`, `CHUNK_HEADER_LEN`).
|
||||
|
||||
14. **No `unsafe`** — the crate has zero `unsafe` blocks outside the
|
||||
14. **No `unsafe`**: the crate has zero `unsafe` blocks outside the
|
||||
`local` feature module's signal-forwarding calls. The PTY bridge's
|
||||
`libc::kill(-pgid, sig)` and `libc::kill(pid, sig)` calls (and the
|
||||
pipe-mode `libc::kill(pid, sig)` / `libc::kill(pid, SIGKILL)`
|
||||
@@ -228,6 +228,17 @@ implementation agents.
|
||||
the pattern. Do not introduce `unsafe` for performance, and do not
|
||||
add `unsafe` outside the `local` module's `libc::kill` calls.
|
||||
|
||||
15. **No em dashes in prose.** Do not use the em dash in source
|
||||
doc comments, markdown docs, agent prompts under `.opencode/agents/`,
|
||||
or commit messages. Restructure instead: split the sentence, use
|
||||
parentheses, commas, a colon, or a semicolon, whichever reads
|
||||
naturally, and vary the choice rather than converting every instance
|
||||
the same way. A spaced hyphen (` - `) is not an acceptable
|
||||
substitute; it reads as a different but equally recognizable writing
|
||||
tell. The same rule covers en dashes used as clause separators,
|
||||
though en dashes inside numeric ranges keep their normal meaning.
|
||||
Hyphens in compound words (`wasm-clean`, `tty-wire`) are untouched.
|
||||
|
||||
## Verification Commands
|
||||
|
||||
Run these before committing. All must pass.
|
||||
@@ -238,27 +249,27 @@ cargo clippy --all-targets -- -D warnings
|
||||
cargo fmt --check
|
||||
cargo doc --no-deps
|
||||
cargo test --all-features
|
||||
cargo test --manifest-path fuzz/shared/Cargo.toml # fuzz corpus replay — the standing fuzz gate
|
||||
cargo test --manifest-path fuzz/shared/Cargo.toml # fuzz corpus replay: the standing fuzz gate
|
||||
cargo check --target wasm32-unknown-unknown # the default crate stays wasm-clean
|
||||
cargo clippy --target wasm32-unknown-unknown -- -D warnings
|
||||
cargo publish --dry-run --allow-dirty # before a release
|
||||
```
|
||||
|
||||
The wasm check is the structural guard for the "default crate is
|
||||
wasm-clean" invariant — run it whenever a non-`local` module changes.
|
||||
wasm-clean" invariant; run it whenever a non-`local` module changes.
|
||||
`cargo test --all-features` exercises the `local` backend (PTY mode is
|
||||
`#[cfg(unix)]`; pipe mode is cross-platform).
|
||||
|
||||
The fuzz corpus replay (`docs/plans/fuzzing.md`) runs every committed
|
||||
seed through the same invariant functions the cargo-fuzz targets run —
|
||||
seed through the same invariant functions the cargo-fuzz targets run,
|
||||
on stable, no nightly needed. Run detached campaigns
|
||||
(`fuzz/run-detached.sh <target>` — never foreground fuzzing) before a
|
||||
(`fuzz/run-detached.sh <target>`, never foreground fuzzing) before a
|
||||
release and after touching `src/wire.rs`, `src/negotiation.rs`,
|
||||
`src/control.rs`, the adapter, or the session pump.
|
||||
|
||||
## Architecture Context
|
||||
|
||||
- `docs/plans/project-setup.md` — the current plan (phases 0–5). All
|
||||
- `docs/plans/project-setup.md`: the current plan (phases 0–5). All
|
||||
five phases are landed: Phase 0 (scaffold hygiene), Phase 1 (port
|
||||
core types), Phase 2 (channels integration + `TtySession`), Phase 3
|
||||
(`local` backend), Phase 4 (architecture docs + BAST schema +
|
||||
@@ -268,46 +279,46 @@ release and after touching `src/wire.rs`, `src/negotiation.rs`,
|
||||
referenced below (052, 053, 054, 055, 056, 057, 077, 093) are ported
|
||||
and renumbered into alktty's ADR range (001..009) in
|
||||
`docs/architecture/decisions/`. ADR-009 (the channels open op's
|
||||
`input` is the negotiation) is alktty-native — it resolves review
|
||||
`input` is the negotiation) is alktty-native; it resolves review
|
||||
#001 L1 and has no alknet original. The alknet originals at
|
||||
`/workspace/@alkdev/alknet/docs/architecture/decisions/` remain the
|
||||
authoritative source for any ADR not yet ported — read them before
|
||||
authoritative source for any ADR not yet ported; read them before
|
||||
non-trivial changes to the wire format, trait, or adapter.
|
||||
- Key ADRs that inform this crate's design (alknet numbers → ported
|
||||
alktty numbers, 1:1 in the order the plan lists them):
|
||||
- ADR-052 → 001 — two-carriage wire format (JSON negotiation + raw
|
||||
- ADR-052 → 001, the two-carriage wire format (JSON negotiation + raw
|
||||
chunks); the 5-byte chunk header. The Phase 7 control-channel
|
||||
split (`STREAM_CTRL_IN` = 3 client→server, `STREAM_CTRL_OUT` = 4
|
||||
server→client) is an amendment inside this ADR, mirroring alknet
|
||||
(it was not a standalone ADR there either).
|
||||
- ADR-053 → 002 — `TtyBackend` trait (the inversion point between
|
||||
- ADR-053 → 002, the `TtyBackend` trait (the inversion point between
|
||||
wire-format adapter and backend crates)
|
||||
- ADR-054 → 003 — local backend placement. Ported with the
|
||||
- ADR-054 → 003, local backend placement. Ported with the
|
||||
single-crate rewrite: alktty folds the local backend in behind a
|
||||
`local` feature (resolves the alknet cyclic-dep workaround that
|
||||
motivated the original sibling-crate decision; the ADR records both
|
||||
the original alknet decision and the alktty consolidation).
|
||||
- ADR-055 → 004 — exit-code reporting (`{"type":"exit","code":N}` on
|
||||
- ADR-055 → 004: exit-code reporting (`{"type":"exit","code":N}` on
|
||||
`ctrl_out`; `code: -1` on wait-failure)
|
||||
- ADR-056 → 005 — backend cleanup on session cancel (drop of
|
||||
- ADR-056 → 005: backend cleanup on session cancel (drop of
|
||||
`exit_code` future kills the session target; the
|
||||
kill-on-`Drop` contract on the `TtyBackend` trait)
|
||||
- ADR-057 → 006 — negotiation framing is self-contained (not reused
|
||||
- ADR-057 → 006, negotiation framing is self-contained (not reused
|
||||
from alkcall's `EventEnvelope` framing)
|
||||
- ADR-077 → 007 — TTY inside channels (reversed by ADR-093/008; kept
|
||||
- ADR-077 → 007, TTY inside channels (reversed by ADR-093/008; kept
|
||||
for historical context with its reversal notice pointing to 008)
|
||||
- ADR-093 → 008 — TTY always uses its 5-byte format inside channels
|
||||
- ADR-093 → 008, TTY always uses its 5-byte format inside channels
|
||||
(reverses ADR-077; channels strips its 8-byte header transparently)
|
||||
- (alktty-native) ADR-009 — the channels open op's `input` IS the
|
||||
- (alktty-native) ADR-009, the channels open op's `input` IS the
|
||||
negotiation (no alknet original; resolves review #001 L1)
|
||||
- ADR-050 (dynamic resource ownership) is an alkcall/alknet-core ADR,
|
||||
not a tty-specific one — it is not ported into alktty's ADR range.
|
||||
not a tty-specific one; it is not ported into alktty's ADR range.
|
||||
The access-control work that declares against the ADR-050 model
|
||||
(scope-gate at negotiation, backend-driven `resource_id()` ownership
|
||||
check) is described in `tty-adapter.md` and the ADR-001/002 ported
|
||||
docs, which reference ADR-050 by its alknet number.
|
||||
- If a TODO references a "Phase 7" note or a design direction that an
|
||||
ADR has since decided against, the TODO is stale — remove it and
|
||||
ADR has since decided against, the TODO is stale; remove it and
|
||||
align with the ADR. Do not implement the rejected design.
|
||||
- The crate was ported from `/workspace/@alkdev/alknet/crates/
|
||||
alknet-tty/src/` (wire, control, negotiation, backend, adapter) and
|
||||
@@ -320,11 +331,11 @@ release and after touching `src/wire.rs`, `src/negotiation.rs`,
|
||||
`BiStream`, `BidiStreamSource`, `AuthContext`, `Identity`,
|
||||
`IdentityProvider`, `AccessControl`, `OwnershipProvider`,
|
||||
`OwnershipStore`, `InMemoryOwnershipStore`, `HandlerError`,
|
||||
`StreamError`) come from `alkcall::core`. alkcall is v0.10.x —
|
||||
`StreamError`) come from `alkcall::core`. alkcall is v0.10.x;
|
||||
breaking changes are expected at this major-zero stage; this is the
|
||||
first real consumer, so we find and fix issues upstream rather than
|
||||
working around them. Pin `alkcall = "0.10.1"` (0.10.1 is the
|
||||
adoption-window ordering fix: the `adopt_channel` install→drain
|
||||
window is atomic, so a routed chunk can no longer jump ahead of
|
||||
parked early arrivals or get parked forever past the drain — and bump
|
||||
parked early arrivals or get parked forever past the drain), and bump
|
||||
deliberately.
|
||||
+64
-64
@@ -6,7 +6,7 @@ this crate adheres to [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [0.8.1] - 2026-10-22
|
||||
|
||||
The alkcall 0.10.1 adoption: a dependency bump only — no source change
|
||||
The alkcall 0.10.1 adoption: a dependency bump only; no source change
|
||||
in alktty. alkcall 0.10.1 is the adoption-window ordering fix (review
|
||||
013, routed from alkgit's wave-2 gate): a chunk routed by the demux
|
||||
inside the `adopt_channel` install→drain window could be delivered
|
||||
@@ -15,7 +15,7 @@ byte-stream order) and a related chunk could sit parked forever past
|
||||
the drain. alktty adopts channels only as a consumer-side client
|
||||
(`TtySession::open_via_channels`, which opens and then adopts the
|
||||
session channel), so the fix applies to every channels-path session
|
||||
under multi-process load — the reassembled chunk stream now preserves
|
||||
under multi-process load: the reassembled chunk stream now preserves
|
||||
wire-arrival order through the adoption window. The surface of the fix
|
||||
is private to alkcall (`ChannelState` and the `install_locked` path):
|
||||
no API, wire-format, or documented-cap change; the behavior only
|
||||
@@ -26,14 +26,14 @@ at all.
|
||||
|
||||
- **`alkcall` requirement updated to `0.10.1`; lockfile bumped from
|
||||
0.10.0 to 0.10.1.** Version-requirement-only (0.10.1 is inside the
|
||||
`0.10` caret family; alkcall 0.10.1 changed no type alktty touches
|
||||
— the fix surface is private). alktty's public signatures reference
|
||||
`0.10` caret family; alkcall 0.10.1 changed no type alktty touches,
|
||||
and the fix surface is private). alktty's public signatures reference
|
||||
alkcall types, so downstream crates composing alktty with the fixed
|
||||
alkcall pick this patch bump up on their next `cargo update`.
|
||||
|
||||
## [0.8.0] - 2026-10-19
|
||||
|
||||
The alkcall 0.10.0 adoption: a dependency bump only — no source change
|
||||
The alkcall 0.10.0 adoption: a dependency bump only; no source change
|
||||
in alktty. alkcall 0.10.0 is the gateway ownership seam release
|
||||
(`GatewayDispatch::with_ownership_provider`, the declared
|
||||
static-fallback opt-in, and the loud unwired-spine default). All of it
|
||||
@@ -46,26 +46,26 @@ trait, and alktty's public API surface are unchanged.
|
||||
|
||||
### Changed
|
||||
|
||||
- **BREAKING (ecosystem-coordination) — `alkcall` bumped to 0.10.0.**
|
||||
- **BREAKING (ecosystem-coordination): `alkcall` bumped to 0.10.0.**
|
||||
alkcall 0.10.0 changed no type that alktty touches: the seam alktty
|
||||
exercises (`ChannelCore`/`register_openable_with_establisher`/
|
||||
`OpenEstablisher`/`OpenHandler`/`Establishment`, `ChannelClient`,
|
||||
`OwnershipProvider`/`OwnershipStore`/`InMemoryOwnershipStore`) is
|
||||
shape-identical to 0.9.0 — the loud unwired-spine default applies
|
||||
shape-identical to 0.9.0; the loud unwired-spine default applies
|
||||
only to `resource_type`-gated ops served through `GatewayDispatch`,
|
||||
which alktty never registers against. alktty's public signatures
|
||||
reference alkcall types, so downstream crates must now unify on
|
||||
alkcall 0.10.0. Minor bump per the 0.x ecosystem-coordination rule
|
||||
(raising the minimum requirement is breaking — hence 0.8.0, not a
|
||||
(raising the minimum requirement is breaking, hence 0.8.0, not a
|
||||
patch release).
|
||||
- **`alkcall` requirement updated to `0.10.0`; lockfile bumped from
|
||||
0.9.0 to 0.10.0.** Verification counts are unchanged from the 0.7.0
|
||||
baseline (113 default / 137 all-features) — the adoption required no
|
||||
baseline (113 default / 137 all-features); the adoption required no
|
||||
source change in alktty.
|
||||
|
||||
## [0.7.0] - 2026-10-14
|
||||
|
||||
The alkcall 0.9.0 adoption: a dependency bump only — no source change
|
||||
The alkcall 0.9.0 adoption: a dependency bump only, with no source change
|
||||
in alktty. alkcall 0.9.0 is the stub-sweep release (the marked-pub open
|
||||
contract, `channel/control`, `channel/resources/subscribe`, and
|
||||
`op/register` forward-through are real implementations). All of
|
||||
@@ -73,19 +73,19 @@ alkcall's semver-relevant changes (`ManagerError::InvalidAdopt`, the
|
||||
`RelayError::PubTypedOpen` removal, the `register_imported`
|
||||
`Result` return, `Establishment` losing `Debug`, the new
|
||||
`register_openable*` refusal of `Pub`-typed specs) touch surfacing
|
||||
alktty never uses — alktty's open op is `Sub`-typed and it only
|
||||
alktty never uses: alktty's open op is `Sub`-typed and it only
|
||||
constructs `Establishment` (whose `new` is unchanged). The wire format,
|
||||
the direct-ALPN path, the `TtyBackend` trait, and alktty's public API
|
||||
surface are unchanged.
|
||||
|
||||
### Changed
|
||||
|
||||
- **BREAKING (ecosystem-coordination) — `alkcall` bumped to 0.9.0.**
|
||||
- **BREAKING (ecosystem-coordination): `alkcall` bumped to 0.9.0.**
|
||||
alkcall 0.9.0 changed no type that alktty touches: the open-op
|
||||
machinery alktty registers through (`ChannelCore`/
|
||||
`register_openable_with_establisher`/`OpenEstablisher`/`OpenHandler`
|
||||
/`Establishment`/`ChannelPlan`, `ChannelClient::open_channel`,
|
||||
`ChannelOpenError::CallFailed`) is shape-identical to 0.8.1 — the
|
||||
`ChannelOpenError::CallFailed`) is shape-identical to 0.8.1: the
|
||||
four stub implementations are net-new surfaces on ops alktty does
|
||||
not exercise, the established-wrapper refusal of `Pub`-typed specs
|
||||
does not apply to alktty's `Sub`-typed open op, and the removed
|
||||
@@ -93,18 +93,18 @@ surface are unchanged.
|
||||
signatures reference alkcall types, so downstream crates must now
|
||||
unify on alkcall 0.9.0. Minor bump per the 0.x
|
||||
ecosystem-coordination rule (raising the minimum requirement is
|
||||
breaking — hence 0.7.0, not a patch release).
|
||||
breaking, hence 0.7.0, not a patch release).
|
||||
- **`alkcall` requirement updated to `0.9.0`; lockfile bumped from
|
||||
0.8.1 to 0.9.0.**
|
||||
|
||||
## [0.6.0] - 2026-09-28
|
||||
|
||||
The fuzzing adoption: a cargo-fuzz workspace over the four wire-facing
|
||||
parse surfaces, with zero findings in the crate — every crash the
|
||||
parse surfaces, with zero findings in the crate; every crash the
|
||||
campaigns produced was a harness-model gap, and every fix landed in the
|
||||
harness, not the crate. The `alkcall` requirement also updates to
|
||||
0.8.1. No API, wire-format, or behavior change; this release ships no
|
||||
source change in `src/` at all — the deltas are the fuzz workspace,
|
||||
source change in `src/` at all: the deltas are the fuzz workspace,
|
||||
the dependency bump, and this changelog. Minor bump per the 0.x
|
||||
ecosystem-coordination rule (alktty's public signatures reference
|
||||
alkcall types, so downstream crates must unify on alkcall 0.8.1).
|
||||
@@ -115,20 +115,20 @@ alkcall types, so downstream crates must unify on alkcall 0.8.1).
|
||||
`docs/plans/fuzzing.md`, adopted from the alkcall fuzzing playbook):
|
||||
four targets over the wire-facing surfaces, with the invariant logic
|
||||
in a stable-toolchain shared crate (`fuzz/shared/`) so the corpus
|
||||
replays on stable — the nightly pin is scoped to the subtree via
|
||||
replays on stable; the nightly pin is scoped to the subtree via
|
||||
`fuzz/rust-toolchain.toml`:
|
||||
- `chunk_frame` — the 5-byte chunk codec
|
||||
- `chunk_frame`: the 5-byte chunk codec
|
||||
(`ChunkReader`/`ChunkWriter`), including the
|
||||
peek/disambiguation seam (`peek_stream_type` vs
|
||||
`read_chunk_after_peek` in both call orders must yield exactly one
|
||||
chunk).
|
||||
- `negotiation_frame` — the 4-byte BE length-prefixed negotiation
|
||||
- `negotiation_frame`: the 4-byte BE length-prefixed negotiation
|
||||
framing + `NegotiateRequest` JSON parse + `error_response_bytes`,
|
||||
and the cross-codec invariant that an error-response frame's
|
||||
length-prefix high byte is `0x00` so a `ChunkReader` peek
|
||||
distinguishes it from a chunk (ADR-001 §5).
|
||||
- `control_json` — `ControlMessage` JSON parse + `signal_from_name`.
|
||||
- `session_opseq` — the stateful target: a `#[derive(Arbitrary)]`
|
||||
- `control_json`: `ControlMessage` JSON parse + `signal_from_name`.
|
||||
- `session_opseq`, the stateful target: a `#[derive(Arbitrary)]`
|
||||
12-op sequence drives the real producer session pump
|
||||
(`drive_session_pre_negotiated`) over a duplex pair against a
|
||||
fuzz-local mock backend, asserting the wire-contract invariants
|
||||
@@ -146,12 +146,12 @@ alkcall types, so downstream crates must unify on alkcall 0.8.1).
|
||||
same invariant functions the targets run, on stable. Added to the
|
||||
verification checklist.
|
||||
- **Detached-runner rule**: campaigns run via `fuzz/run-detached.sh`
|
||||
(setsid/nohup, fork mode, bounded RSS) — never in the foreground of
|
||||
(setsid/nohup, fork mode, bounded RSS), never in the foreground of
|
||||
an agent session.
|
||||
|
||||
### Changed
|
||||
|
||||
- **BREAKING (ecosystem-coordination) — `alkcall` bumped to 0.8.1.**
|
||||
- **BREAKING (ecosystem-coordination): `alkcall` bumped to 0.8.1.**
|
||||
alkcall 0.8.1 is a patch release fixing the duplicate
|
||||
adopt/open channel-state destruction found by alkcall's own fuzz
|
||||
campaign; it changed no type alktty touches. Downstream crates must
|
||||
@@ -170,20 +170,20 @@ alkcall types, so downstream crates must unify on alkcall 0.8.1).
|
||||
- 0 findings across all four targets: detached campaigns (10 min each
|
||||
on the three wire targets; 10 min on `session_opseq` at 42.3k execs,
|
||||
cov 3934 → 4075 and still growing at budget end) plus a 60s smoke
|
||||
run on target 4 — no crash/OOM/timeout/leak.
|
||||
run on target 4: no crash/OOM/timeout/leak.
|
||||
- Corpus replay 9 tests green on stable (340 seeds); crate tests 113
|
||||
default / 156 all-features; clippy stable + wasm; fmt; doc; wasm
|
||||
check — all pass.
|
||||
check: all pass.
|
||||
- The three crashes the harness-model smoke runs surfaced were
|
||||
harness gaps, each fixed in the fuzz harness (the alkcall §7.7
|
||||
pattern): a failed client write can mean the session completed and
|
||||
dropped the server duplex half mid-write — the harness drains and
|
||||
dropped the server duplex half mid-write; the harness drains and
|
||||
requires the exit chunk on any write/shutdown error (a premature
|
||||
close is a finding). No adapter code changed.
|
||||
|
||||
## [0.5.0] - 2026-09-18
|
||||
|
||||
The alkcall 0.8.0 adoption: a dependency bump only — no source change
|
||||
The alkcall 0.8.0 adoption: a dependency bump only; no source change
|
||||
in alktty. alkcall 0.8.0 landed review 008's remediation (the
|
||||
establisher reply projection, flavor-form open-op ids in discovery, the
|
||||
in-tree `ChannelRelay` + `HubLegImports`/`HubLegTemplate`) plus a
|
||||
@@ -194,12 +194,12 @@ trait, and alktty's public API surface are unchanged.
|
||||
|
||||
### Changed
|
||||
|
||||
- **BREAKING (ecosystem-coordination) — `alkcall` bumped to 0.8.0.**
|
||||
- **BREAKING (ecosystem-coordination): `alkcall` bumped to 0.8.0.**
|
||||
alkcall 0.8.0 changed no type that alktty touches: the open-op
|
||||
machinery alktty registers through (`ChannelCore`/
|
||||
`register_openable_with_establisher`/`OpenEstablisher`/`OpenHandler`
|
||||
/`Establishment`/`ChannelPlan`, `ChannelClient::open_channel`,
|
||||
`ChannelOpenError::CallFailed`) is shape-identical to 0.7.1 — the
|
||||
`ChannelOpenError::CallFailed`) is shape-identical to 0.7.1: the
|
||||
new `open_channel_with_reply` / reply-field / relay / hub-leg
|
||||
surfaces are additive and alktty exercises none of them. alktty's
|
||||
own open op is the standard-shape name `channels/tty/sub`, so the
|
||||
@@ -211,13 +211,13 @@ trait, and alktty's public API surface are unchanged.
|
||||
the 0.x ecosystem-coordination rule.
|
||||
- **`alkcall` requirement updated to `0.8.0`; lockfile bumped from
|
||||
0.7.1 to 0.8.0.** Verification counts are unchanged from the 0.7.1
|
||||
baseline (113 default / 156 all-features) — the adoption required no
|
||||
baseline (113 default / 156 all-features); the adoption required no
|
||||
source change in alktty.
|
||||
|
||||
## [0.4.1] - 2026-09-10
|
||||
|
||||
The MSRV floor becomes honest: `rust-version` raises from 1.85 to
|
||||
1.88. The 1.85 claim was already false at the dependency level — the
|
||||
1.88. The 1.85 claim was already false at the dependency level: the
|
||||
lockfile pins `alkcall 0.7.x`, and alkcall 0.7.1 raised its MSRV floor
|
||||
to 1.88 (the ecosystem MSRV audit: noq's 1.88 matching the QUIC path;
|
||||
iroh sits above at 1.91), so no 1.85 toolchain could build 0.4.0
|
||||
@@ -229,9 +229,9 @@ update`.
|
||||
### Changed
|
||||
|
||||
- **`rust-version` 1.85 → 1.88.** Verified on a real 1.88 toolchain
|
||||
(full test suite, clippy). alktty's own code is 1.85-clean —
|
||||
`cargo check` under a 1.85 toolchain passes with the pre-bump
|
||||
lockfile — so the raise is dependency-driven only and needed no
|
||||
(full test suite, clippy). alktty's own code is 1.85-clean
|
||||
(`cargo check` under a 1.85 toolchain passes with the pre-bump
|
||||
lockfile), so the raise is dependency-driven only and needed no
|
||||
clippy fixes. Behavior-identical.
|
||||
- **`alkcall` requirement updated to `0.7.1`; lockfile bumped from
|
||||
0.7.0 to 0.7.1.** Version-requirement-only (0.7.x is already the
|
||||
@@ -246,7 +246,7 @@ on the channels path. The wire format, the direct-ALPN path, the
|
||||
|
||||
### Changed
|
||||
|
||||
- **BREAKING (ecosystem-coordination) — `alkcall` bumped to 0.7.0.**
|
||||
- **BREAKING (ecosystem-coordination): `alkcall` bumped to 0.7.0.**
|
||||
alkcall 0.7.0 changed no type that alktty touches
|
||||
(`OpenEstablisher`/`OpenHandler`/`Establishment`/`ChannelPlan` are
|
||||
identical to 0.6.0; alktty never constructs `ServingConfig`), but
|
||||
@@ -256,7 +256,7 @@ on the channels path. The wire format, the direct-ALPN path, the
|
||||
- **The channels establisher and pump handler take their identity
|
||||
from the per-call `auth` (CF-006 alignment).** `register_openable`
|
||||
no longer captures the install-time identity and overrides the
|
||||
establisher/handler `auth.identity` with it — post-alkcall-0.7 the
|
||||
establisher/handler `auth.identity` with it: post-alkcall-0.7, the
|
||||
per-call context already carries the dispatch-resolved opener (the
|
||||
same identity view the registry's ACL gate checked; the end client,
|
||||
not the hub, on hub-forwarded opens). Behavior-identical in the
|
||||
@@ -268,7 +268,7 @@ on the channels path. The wire format, the direct-ALPN path, the
|
||||
|
||||
## [0.3.0] - 2026-09-07
|
||||
|
||||
The alkcall 0.6.0 adoption (review 007 R-01 — the `Establishment`
|
||||
The alkcall 0.6.0 adoption (review 007 R-01, the `Establishment`
|
||||
plan payload): backend allocation moves into the channels establisher.
|
||||
Breaking on the channels path's allocation-failure shape; the wire
|
||||
format, the direct-ALPN path, and the `TtyBackend` trait are
|
||||
@@ -276,11 +276,11 @@ unchanged.
|
||||
|
||||
### Changed
|
||||
|
||||
- **BREAKING — `alkcall` bumped to 0.6.0; channels-path allocation
|
||||
- **BREAKING: `alkcall` bumped to 0.6.0; channels-path allocation
|
||||
failure changes shape (ADR-010 §2A).** alkcall 0.6.0 filled the
|
||||
reserved `Establishment` field (`plan: Option<ChannelPlan>`,
|
||||
review 007 R-01), so `backend.allocate` moved from the pump handler
|
||||
into the establisher — the allocated `TtyHandle` crosses to the
|
||||
into the establisher; the allocated `TtyHandle` crosses to the
|
||||
handler via the plan payload (a private per-open one-shot slot; the
|
||||
handle is not `Sync`). Allocation failure now fails the open op
|
||||
itself: `TtySessionError::ChannelsOpen(CallFailed{
|
||||
@@ -296,7 +296,7 @@ unchanged.
|
||||
- Handler closures in `make_tty_open_handler` gain the
|
||||
`Option<ChannelPlan>` parameter (alkcall 0.6 `OpenHandler` shape);
|
||||
a `None` plan (no-establisher registration) falls back to the
|
||||
pre-0.3.0 inline validate-and-allocate — defense-in-depth, keeping
|
||||
pre-0.3.0 inline validate-and-allocate, defense-in-depth, keeping
|
||||
the in-band error-frame vocabulary for that registration shape.
|
||||
- New tests: establisher unit gate for allocate-in-establisher (plan
|
||||
slot carries the live handle; failure maps to `dial_failed`), the
|
||||
@@ -305,7 +305,7 @@ unchanged.
|
||||
|
||||
## [0.2.0] - 2026-09-06
|
||||
|
||||
The alkcall 0.5.0 adoption (ADR-049 — review 006 E-01/N-1): the
|
||||
The alkcall 0.5.0 adoption (ADR-049, review 006 E-01/N-1): the
|
||||
channels-path establishment phase. Breaking on the channels path's
|
||||
error surface; the wire format, the direct-ALPN path, and the
|
||||
`TtyBackend` trait are unchanged.
|
||||
@@ -316,15 +316,15 @@ error surface; the wire format, the direct-ALPN path, and the
|
||||
review 006 E-01).** `register_openable` now registers
|
||||
`channels/tty/sub` with an establisher
|
||||
(`ChannelCore::register_openable_with_establisher`): the semantic
|
||||
validation that used to be post-open in-band error frames — the
|
||||
validation that used to be post-open in-band error frames (the
|
||||
full `NegotiateRequest` parse of schema-valid `input`, `carriage ==
|
||||
"raw"`, non-empty `cmd`, backend lookup, and the ADR-050 ownership
|
||||
check — runs before the open reply. Rejections resolve the open op
|
||||
check) runs before the open reply. Rejections resolve the open op
|
||||
with `channel:open_failed` carrying `details: { reason, message }`
|
||||
(`unknown_resource` for an unknown backend, `handler_error` for
|
||||
malformed-negotiation/ownership-denial, `timeout` on the
|
||||
establishment deadline) and no channel ever exists opener-side —
|
||||
the SSH contract. The phantom-channel workaround
|
||||
establishment deadline) and no channel ever exists opener-side (the
|
||||
SSH contract). The phantom-channel workaround
|
||||
(allocate → succeed → in-band-fail) is retired.
|
||||
- **`channel:open_failed` ErrorDefinition on `tty_open_spec()`
|
||||
(ADR-016).** The establishment-failure contract (the reachable
|
||||
@@ -338,13 +338,13 @@ error surface; the wire format, the direct-ALPN path, and the
|
||||
|
||||
### Changed
|
||||
|
||||
- **BREAKING — `TtySessionError::ChannelsOpen` carries alkcall's
|
||||
- **BREAKING: `TtySessionError::ChannelsOpen` carries alkcall's
|
||||
typed `ChannelOpenError` (`#[from]`) instead of a flattened
|
||||
`String`** (alkcall 0.5.0 ADR-049 §4 / review 006 N-1, applied at
|
||||
alktty's layer). Consumers branch on
|
||||
`ChannelOpenError::establishment_reason()` instead of parsing a
|
||||
debug string.
|
||||
- **BREAKING — channels-path semantic failures change shape.** An
|
||||
- **BREAKING: channels-path semantic failures change shape.** An
|
||||
unknown backend (and schema-valid-but-unparseable params,
|
||||
ownership denial) previously surfaced as
|
||||
`TtySessionError::NegotiationRejected` (an in-band `0x00`-prefixed
|
||||
@@ -355,8 +355,8 @@ error surface; the wire format, the direct-ALPN path, and the
|
||||
`allocate_failed` (still `NegotiationRejected`): the establisher
|
||||
cannot carry the allocated `TtyHandle` across to the pump handler
|
||||
(`Establishment` is payloadless), and re-allocating would violate
|
||||
ADR-005's kill-on-`Drop` contract — revisit when alkcall gives
|
||||
`Establishment` a payload. The direct-ALPN path's failure surface
|
||||
ADR-005's kill-on-`Drop` contract (revisit when alkcall gives
|
||||
`Establishment` a payload). The direct-ALPN path's failure surface
|
||||
is unchanged (two transports, two contracts).
|
||||
- `alkcall = "0.5.0"`.
|
||||
|
||||
@@ -369,7 +369,7 @@ error surface; the wire format, the direct-ALPN path, and the
|
||||
|
||||
## [0.1.0] - 2026-09-05
|
||||
|
||||
Initial crates.io release: the `alk/tty` terminal-session protocol —
|
||||
Initial crates.io release: the `alk/tty` terminal-session protocol, a
|
||||
producer/consumer protocol crate on top of alkcall channels, ported and
|
||||
consolidated from the `alknet-tty` + `alknet-tty-local` crates of the
|
||||
alknet mono-repo.
|
||||
@@ -378,21 +378,21 @@ alknet mono-repo.
|
||||
|
||||
- **Wire format (ADR-001).** Two-carriage protocol: a 4-byte
|
||||
big-endian length-prefixed JSON negotiation frame (`NegotiateRequest`
|
||||
— `carriage`/`backend`/`tty`/`cmd`/`cwd`/`env` plus opaque
|
||||
with `carriage`/`backend`/`tty`/`cmd`/`cwd`/`env` plus opaque
|
||||
`serde(flatten)` backend params), then raw chunks
|
||||
(`[stream_type: u8][length: u32 be][payload]`) with five stream types
|
||||
(`STREAM_STDIN`=0, `STREAM_STDOUT`=1, `STREAM_STDERR`=2,
|
||||
`STREAM_CTRL_IN`=3, `STREAM_CTRL_OUT`=4). Zero-length data chunks are
|
||||
sentinels (zero-length stdin = client EOF; zero-length stdout =
|
||||
server drained); control chunks are never zero-length. Payloads are
|
||||
capped at 16 MiB (`MAX_CHUNK_LEN`) — this keeps the length prefix's
|
||||
capped at 16 MiB (`MAX_CHUNK_LEN`); this keeps the length prefix's
|
||||
high byte `0x00`, which is the disambiguation invariant between an
|
||||
error frame and a raw chunk (ADR-001 §5). The framing is
|
||||
self-contained (ADR-006): not reused from alkcall's `EventEnvelope`
|
||||
framing. The control channel is split into `STREAM_CTRL_IN`/`STREAM_CTRL_OUT`
|
||||
so it is genuinely bidirectional on the wire (the Phase 7 amendment
|
||||
inside ADR-001). TTY always uses its own 5-byte chunk format inside
|
||||
channels — the channels layer carries it transparently in the channel
|
||||
channels; the channels layer carries it transparently in the channel
|
||||
payload (ADR-008, reversing ADR-007). The machine-readable contract
|
||||
is the BAST document (`docs/architecture/tty-bast.md`); a unit test
|
||||
guards the BAST's `StreamType` enum against drift from the wire
|
||||
@@ -406,9 +406,9 @@ alknet mono-repo.
|
||||
is a one-way door once backends exist.
|
||||
- **Local backend behind the `local` feature (ADR-003).** The
|
||||
`alknet-tty-local` crate folded in as a feature-gated module:
|
||||
PTY mode via `portable_pty` (real terminal semantics — resize,
|
||||
PTY mode via `portable_pty` (real terminal semantics: resize,
|
||||
process-group signal forwarding, merged stdout/stderr) and pipe mode
|
||||
via `tokio::process::Command` (the runner case — separate
|
||||
via `tokio::process::Command` (the runner case: separate
|
||||
stdout/stderr, no-op resize, pid-only signal), selected by
|
||||
`TtyParams::terminal`. The blocking→async bridge uses three dedicated
|
||||
std threads feeding tokio mpsc/oneshot channels. Non-wasm by design;
|
||||
@@ -422,14 +422,14 @@ alknet mono-repo.
|
||||
via `tty:open`, optional `OwnershipProvider` resource check,
|
||||
`backend.allocate()`, then three concurrent pumps (stdin→backend,
|
||||
stdout/stderr→client, exit→exit chunk) with the **exit-chunk-is-last**
|
||||
invariant — `{"type":"exit","code":N}` (`-1` on wait failure, ADR-004;
|
||||
invariant: `{"type":"exit","code":N}` (`-1` on wait failure, ADR-004;
|
||||
negative = signal-terminated) is enqueued only after both stream
|
||||
pumps complete and `exit_code` resolves. A concurrent drainer task
|
||||
owns the client write half, so a burst of >64 chunks from the backend
|
||||
cannot deadlock the session (the drainer starts before the pumps
|
||||
join; the single FIFO preserves exit-chunk-last). On session cancel
|
||||
the `TtyHandle` drops without driving `exit_code` to completion,
|
||||
which triggers the backend's kill-on-`Drop` guard — the adapter has
|
||||
which triggers the backend's kill-on-`Drop` guard; the adapter has
|
||||
no separate kill path (the session-cancel contract, ADR-005: dropping
|
||||
the future kills the session target).
|
||||
- **`channels` integration (ADR-008, ADR-009).** `register_openable` +
|
||||
@@ -439,12 +439,12 @@ alknet mono-repo.
|
||||
scope gate, the op's input schema validates the shared
|
||||
`NegotiateRequest` fields (`carriage`/`backend`/`cmd` required;
|
||||
alkcall 0.4 enforces at dispatch), and the `OpenHandler` spawns the
|
||||
session driver pre-negotiated — **the open op's params ARE the
|
||||
negotiation** (ADR-009), no second frame on the channel stream.
|
||||
session driver pre-negotiated; **the open op's params ARE the
|
||||
negotiation** (ADR-009), with no second frame on the channel stream.
|
||||
Access control is enforced by the registry before the handler runs;
|
||||
the handler validates the backend exists and drives the session.
|
||||
- **`TtySession` consumer.** The typed client handle with two
|
||||
constructors — `connect_direct` (direct `alk/tty` connection:
|
||||
constructors, `connect_direct` (direct `alk/tty` connection:
|
||||
writes the negotiation frame, then typed methods) and
|
||||
`open_via_channels` (opens `channels/tty/sub` on a `ChannelClient`,
|
||||
adopts the channel, starts in raw-chunk mode). Typed methods:
|
||||
@@ -460,7 +460,7 @@ alknet mono-repo.
|
||||
`TtySessionError` is `#[non_exhaustive]`. Dropping the session
|
||||
aborts the read pump and closes the write half.
|
||||
- **Control messages.** `ControlMessage` (`Resize`/`Signal`/`Eof` on
|
||||
`ctrl_in`; `Exit` on `ctrl_out`) — JSON, tagged by `"type"`, unknown
|
||||
`ctrl_in`; `Exit` on `ctrl_out`): JSON, tagged by `"type"`, with unknown
|
||||
types ignored by policy. `signal_from_name` maps the common signal
|
||||
set (`HUP`/`INT`/`QUIT`/`TERM`/`KILL`/`USR1`/`USR2`/`TSTP`/`CONT`)
|
||||
to libc numbers (unix).
|
||||
@@ -470,7 +470,7 @@ alknet mono-repo.
|
||||
`LocalTtyBackend` under `local`) are re-exported at the crate root;
|
||||
the module paths remain the full surface.
|
||||
- **Wire write-path validation.** All `ChunkWriter` methods validate
|
||||
before touching the transport — a `stream_type` > 4 or a payload over
|
||||
before touching the transport; a `stream_type` > 4 or a payload over
|
||||
`MAX_CHUNK_LEN` fails locally instead of writing a header the peer
|
||||
cannot frame (the length is validated before the `u32` cast, so an
|
||||
oversized payload cannot truncate past the check and corrupt the
|
||||
@@ -488,13 +488,13 @@ alknet mono-repo.
|
||||
stream-types discard tests.
|
||||
- **PTY bridge robustness (`local` feature).** The stdin sink's EOF
|
||||
(`poll_shutdown`) parks an in-flight send on a full channel so the
|
||||
waker is registered — a stdin blast followed by EOF always delivers
|
||||
waker is registered, and a stdin blast followed by EOF always delivers
|
||||
the EOF to the child. Lock-poisoning no longer cascades (the five
|
||||
lock sites adopt `into_inner()`), and thread-spawn failure maps to
|
||||
`TtyError::AllocFailed` instead of panicking.
|
||||
- **Producer hardening.** The `tty:open` scope gate runs before the
|
||||
backend lookup (an unscoped identity gets `forbidden` regardless of
|
||||
the request body — no backend-name enumeration differential), and
|
||||
the request body, with no backend-name enumeration differential), and
|
||||
the client→backend pump is aborted at session end instead of
|
||||
lingering until the client disconnects.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ be][payload]`), a backend-agnostic `TtyBackend` trait, a
|
||||
backend-agnostic producer adapter, and a typed consumer client.
|
||||
|
||||
A producer/consumer protocol crate on top of
|
||||
[alkcall](https://crates.io/crates/alkcall) channels — no networking, no
|
||||
[alkcall](https://crates.io/crates/alkcall) channels: no networking, no
|
||||
transport dependencies. Downstream crates (a `alknet-docker` or
|
||||
`alknet-ssh` backend, a sandboxed TS/Python adapter) compose on top of
|
||||
it: implement [`TtyBackend`](https://docs.rs/alktty/latest/alktty/trait.TtyBackend.html)
|
||||
@@ -14,7 +14,7 @@ for your execution environment, and the adapter handles the wire.
|
||||
|
||||
## Quick start
|
||||
|
||||
### Producer — register backends and run the adapter
|
||||
### Producer: register backends and run the adapter
|
||||
|
||||
```rust
|
||||
use std::collections::HashMap;
|
||||
@@ -27,11 +27,11 @@ backends.insert("local".into(), Arc::new(alktty::LocalTtyBackend::new()));
|
||||
|
||||
let tty_adapter = alktty::TtyAdapter::new(backends);
|
||||
// tty_adapter implements alkcall::core::ProtocolHandler on the `alk/tty`
|
||||
// ALPN — hand it to your transport accept loop:
|
||||
// ALPN; hand it to your transport accept loop:
|
||||
// tty_adapter.handle(connection, &auth).await
|
||||
```
|
||||
|
||||
### Producer — via `alk/channels` instead of a dedicated ALPN
|
||||
### Producer via `alk/channels` instead of a dedicated ALPN
|
||||
|
||||
```rust
|
||||
use std::collections::HashMap;
|
||||
@@ -44,10 +44,10 @@ let backends: HashMap<String, Arc<dyn alktty::TtyBackend>> =
|
||||
// In your channels acceptor's `install_channel_zero` hook (per-connection):
|
||||
// alktty::register_openable(&channel_core, Arc::new(backends), None, &mut registry, auth)
|
||||
// Consumers open `channels/tty/sub`; the open op's params ARE the
|
||||
// negotiation (ADR-009) — no second frame on the channel stream.
|
||||
// negotiation (ADR-009); no second frame on the channel stream.
|
||||
```
|
||||
|
||||
### Consumer — open a session over the direct ALPN
|
||||
### Consumer: open a session over the direct ALPN
|
||||
|
||||
```rust
|
||||
use alkcall::core::Connection;
|
||||
@@ -73,14 +73,14 @@ session.close_stdin().await?;
|
||||
|
||||
let mut stdout = session.recv_stdout().await;
|
||||
while let Some(bytes) = futures::StreamExt::next(&mut stdout).await {
|
||||
// The stream ends on the zero-length stdout sentinel ("drained") —
|
||||
// The stream ends on the zero-length stdout sentinel ("drained");
|
||||
// it is never yielded as an item.
|
||||
// ... render bytes ...
|
||||
}
|
||||
let code = session.wait().await?;
|
||||
```
|
||||
|
||||
### Consumer — open a session via `alk/channels`
|
||||
### Consumer: open a session via `alk/channels`
|
||||
|
||||
```rust
|
||||
use alkcall::channels::client::ChannelClient;
|
||||
@@ -106,14 +106,14 @@ both sides of a connection can be either):
|
||||
|
||||
| Role | Direct `alk/tty` | Via `alk/channels` |
|
||||
|------|------------------|--------------------|
|
||||
| **Producer** (runs backends, emits terminal bytes) | [`TtyAdapter`](https://docs.rs/alktty/latest/alktty/adapter/struct.TtyAdapter.html) as a `ProtocolHandler` — negotiation frame → access control → backend allocation → three-pump session driver | [`register_openable`](https://docs.rs/alktty/latest/alktty/channels/fn.register_openable.html) — the registry's ACL gates the open op; the handler spawns the same session driver pre-negotiated |
|
||||
| **Consumer** (types input, renders output) | [`TtySession::connect_direct`](https://docs.rs/alktty/latest/alktty/struct.TtySession.html#method.connect_direct) — writes the negotiation frame, exposes typed stdin/stdout/stderr/control/exit methods | [`TtySession::open_via_channels`](https://docs.rs/alktty/latest/alktty/struct.TtySession.html#method.open_via_channels) — the open op's params are the negotiation |
|
||||
| **Producer** (runs backends, emits terminal bytes) | [`TtyAdapter`](https://docs.rs/alktty/latest/alktty/adapter/struct.TtyAdapter.html) as a `ProtocolHandler`: negotiation frame → access control → backend allocation → three-pump session driver | [`register_openable`](https://docs.rs/alktty/latest/alktty/channels/fn.register_openable.html): the registry's ACL gates the open op; the handler spawns the same session driver pre-negotiated |
|
||||
| **Consumer** (types input, renders output) | [`TtySession::connect_direct`](https://docs.rs/alktty/latest/alktty/struct.TtySession.html#method.connect_direct), which writes the negotiation frame and exposes typed stdin/stdout/stderr/control/exit methods | [`TtySession::open_via_channels`](https://docs.rs/alktty/latest/alktty/struct.TtySession.html#method.open_via_channels), where the open op's params are the negotiation |
|
||||
|
||||
One connection hosts many sessions (one per bidi stream on the direct
|
||||
path, one per channel on the channels path); sessions are independent.
|
||||
|
||||
The wire format (ADR-001) is two carriages: a 4-byte
|
||||
length-prefixed JSON negotiation frame, then raw chunks —
|
||||
length-prefixed JSON negotiation frame, then raw chunks of the form
|
||||
`[stream_type: u8][length: u32 be][payload]` with five stream types
|
||||
(`STREAM_STDIN`=0 … `STREAM_CTRL_OUT`=4; see
|
||||
[`alktty::wire`](https://docs.rs/alktty/latest/alktty/wire/index.html)).
|
||||
@@ -126,7 +126,7 @@ document at `docs/architecture/tty-bast.md`.
|
||||
|
||||
### WASM target
|
||||
|
||||
The default crate (no features) compiles to `wasm32-unknown-unknown` —
|
||||
The default crate (no features) compiles to `wasm32-unknown-unknown`;
|
||||
this is what makes the downstream TS/Python adapter story work: a
|
||||
wasm-compiled alktty is the protocol layer for a sandboxed adapter. The
|
||||
`local` feature (the [`LocalTtyBackend`](https://docs.rs/alktty/latest/alktty/struct.LocalTtyBackend.html)
|
||||
@@ -135,10 +135,10 @@ real OS and is non-wasm by design.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Architecture docs](docs/architecture/README.md) — the authoritative
|
||||
- [Architecture docs](docs/architecture/README.md), the authoritative
|
||||
spec: ADRs (001–009), the wire format, the backend trait contract,
|
||||
and the local backend's cancel-cleanup semantics.
|
||||
- [API docs](https://docs.rs/alktty) — full crate documentation on
|
||||
- [API docs](https://docs.rs/alktty): full crate documentation on
|
||||
docs.rs.
|
||||
|
||||
## License
|
||||
|
||||
+16
-16
@@ -1,4 +1,4 @@
|
||||
# alktty — Architecture
|
||||
# alktty: Architecture
|
||||
|
||||
Terminal session protocol for the `alk/tty` ALPN: a
|
||||
producer/consumer protocol crate on top of alkcall channels. This
|
||||
@@ -12,7 +12,7 @@ Syntax Tree) document for the wire format, and the ADRs.
|
||||
| [overview.md](overview.md) | draft | Crate purpose, the two-carriage model in brief, dependencies, ALPN, backend location map, feature gates |
|
||||
| [tty-wire.md](tty-wire.md) | draft | The wire format: negotiation frame (JSON carriage), raw chunk codec (`[stream_type: u8][length: u32 be][payload]`), control channel split into `STREAM_CTRL_IN` / `STREAM_CTRL_OUT` halves, sentinels |
|
||||
| [tty-bast.md](tty-bast.md) | draft | The BAST (Binary Abstract Syntax Tree) document for the binary framing layer of the `alk/tty` wire format (5-byte chunk header + negotiation length prefix); a normative JSON spec conforming to the BAST meta-schema at `https://alk.dev/bast/v1/schema`. JSON payloads are specified in `tty-wire.md` and the Rust source, not in the BAST |
|
||||
| [tty-backend.md](tty-backend.md) | draft | `TtyBackend` trait, `TtyParams`, `TtyHandle`, `TtyControl` — the inversion point between the wire-format adapter and the backends. Carries REQ-TTY-01 (backends need not be natively async) |
|
||||
| [tty-backend.md](tty-backend.md) | draft | `TtyBackend` trait, `TtyParams`, `TtyHandle`, `TtyControl`: the inversion point between the wire-format adapter and the backends. Carries REQ-TTY-01 (backends need not be natively async) |
|
||||
| [tty-adapter.md](tty-adapter.md) | draft | `TtyAdapter` (`ProtocolHandler` on `alk/tty`): session lifecycle, three-pump bidirectional driver, negotiation errors, exit-chunk ordering (ADR-004), access control, session-cancel cleanup (ADR-005) |
|
||||
| [tty-local.md](tty-local.md) | draft | `LocalTtyBackend` (the `local` feature module): `portable_pty` (PTY) and `tokio::process::Command` (pipe/runner). Carries REQ-TTY-02 (signal forwarding to the process group) |
|
||||
|
||||
@@ -28,16 +28,16 @@ tty-specific and therefore not ported into alktty's ADR range).
|
||||
|
||||
| ADR | Title | Origin | Status |
|
||||
|-----|-------|--------|--------|
|
||||
| [001](decisions/001-wire-format-and-two-carriage.md) | alktty Wire Format and Two-Carriage Model | alknet ADR-052 | Accepted (amended 2026-07-18 — Phase 7 control-channel split) |
|
||||
| [002](decisions/002-ttybackend-trait-and-ttyhandle.md) | TtyBackend Trait and TtyHandle — the Backend Inversion Point | alknet ADR-053 | Accepted |
|
||||
| [001](decisions/001-wire-format-and-two-carriage.md) | alktty Wire Format and Two-Carriage Model | alknet ADR-052 | Accepted (amended 2026-07-18, Phase 7 control-channel split) |
|
||||
| [002](decisions/002-ttybackend-trait-and-ttyhandle.md) | TtyBackend Trait and TtyHandle: the Backend Inversion Point | alknet ADR-053 | Accepted |
|
||||
| [003](decisions/003-local-backend-placement.md) | Local TTY Backend Placement (Single Crate with `local` Feature) | alknet ADR-054 | Accepted (records both the alknet sibling-crate decision and the alktty single-crate consolidation) |
|
||||
| [004](decisions/004-exit-code-on-control-chunk.md) | Exit Code on a Control Chunk (the Last Chunk Before Stream Close) | alknet ADR-055 | Accepted |
|
||||
| [005](decisions/005-backend-cleanup-on-session-cancel.md) | Backend Cleanup on Session Cancel (Drop of `exit_code` Kills) | alknet ADR-056 | Accepted |
|
||||
| [006](decisions/006-negotiation-framing-self-contained.md) | Self-Contained Negotiation Framing (No alkcall-Internal-Wire-Types Dependency) | alknet ADR-057 | Accepted |
|
||||
| [007](decisions/007-tty-inside-channels.md) | TTY Inside Channels — Sub-Streams, Not Wire Format | alknet ADR-077 | Accepted (**reversed by ADR-008** — kept for historical context) |
|
||||
| [007](decisions/007-tty-inside-channels.md) | TTY Inside Channels (Sub-Streams, Not Wire Format) | alknet ADR-077 | Accepted (**reversed by ADR-008**; kept for historical context) |
|
||||
| [008](decisions/008-channels-pure-channel-multiplexing.md) | Channels Pure Channel Multiplexing (8-Byte Header, No `stream_type`) | alknet ADR-093 | Accepted (amends alknet ADR-071/074; reverses ADR-007) |
|
||||
| [009](decisions/009-channels-open-op-is-the-negotiation.md) | The Channels Open Op's `input` Is the Negotiation | alktty-native | Accepted (resolves review #001 L1; amended by review #002 R4 — parse failure is a client-visible error frame; amended by ADR-010 — semantic failures move into the establisher) |
|
||||
| [010](decisions/010-channels-establisher-migration.md) | Channels-Path Establishment Failures Move into an Establisher | alktty-native | Accepted (adopts alkcall 0.5.0 ADR-049; amends ADR-009's R4 amendment; amended 2026-09-07 §2A for alkcall 0.6's plan payload — allocation moves into the establisher, no in-band frame from a registered producer) |
|
||||
| [009](decisions/009-channels-open-op-is-the-negotiation.md) | The Channels Open Op's `input` Is the Negotiation | alktty-native | Accepted (resolves review #001 L1; amended by review #002 R4 (parse failure is a client-visible error frame); amended by ADR-010 (semantic failures move into the establisher)) |
|
||||
| [010](decisions/010-channels-establisher-migration.md) | Channels-Path Establishment Failures Move into an Establisher | alktty-native | Accepted (adopts alkcall 0.5.0 ADR-049; amends ADR-009's R4 amendment; amended 2026-09-07 §2A for alkcall 0.6's plan payload, with allocation moves into the establisher and no in-band frame from a registered producer) |
|
||||
|
||||
## Key Design Principles
|
||||
|
||||
@@ -75,7 +75,7 @@ tty-specific and therefore not ported into alktty's ADR range).
|
||||
depend on any backend. This preserves alknet ADR-003's
|
||||
no-handler-depends-on-another-handler rule. alktty does not depend
|
||||
on alkcall's internal wire types either (the negotiation framing is
|
||||
self-contained — [ADR-006](decisions/006-negotiation-framing-self-contained.md)).
|
||||
self-contained ([ADR-006](decisions/006-negotiation-framing-self-contained.md)).
|
||||
See [tty-backend.md](tty-backend.md) and
|
||||
[ADR-002](decisions/002-ttybackend-trait-and-ttyhandle.md).
|
||||
|
||||
@@ -92,7 +92,7 @@ tty-specific and therefore not ported into alktty's ADR range).
|
||||
([ADR-004](decisions/004-exit-code-on-control-chunk.md)).**
|
||||
`{"type":"exit","code":N}` rides on `STREAM_CTRL_OUT` (stream_type 4)
|
||||
and is the last chunk before the server closes the write half. This
|
||||
gives coordinators deterministic completion notification — no
|
||||
gives coordinators deterministic completion notification (no
|
||||
polling, no plugin state. The adapter owns the ordering; backends
|
||||
resolve `exit_code` and the adapter awaits, sends the chunk, closes.
|
||||
See [tty-adapter.md](tty-adapter.md).
|
||||
@@ -103,14 +103,14 @@ tty-specific and therefore not ported into alktty's ADR range).
|
||||
the `TtyHandle`, which drops the `exit_code` future without driving
|
||||
it to completion. The backend's `exit_code` future's `Drop`-on-cancel
|
||||
MUST kill the child/container/SSH process. This is a behavioral
|
||||
contract on the `TtyBackend` trait — the adapter has no separate kill
|
||||
contract on the `TtyBackend` trait: the adapter has no separate kill
|
||||
method; the cleanup is wired into the `exit_code` future's `Drop` by
|
||||
the backend. See [tty-adapter.md](tty-adapter.md) and
|
||||
[tty-local.md](tty-local.md).
|
||||
|
||||
8. **The runner pattern is preserved, not specialized.** The local
|
||||
backend in pipe mode (`terminal: None`) is a process-streaming
|
||||
endpoint — the same shape as GitHub/Gitea Actions runners, just over
|
||||
endpoint, the same shape as GitHub/Gitea Actions runners, just over
|
||||
alk's transport instead of HTTP polling. alktty provides the
|
||||
*mechanism* (framed byte stream + exit code); runner *policy* (job
|
||||
management, log persistence, task graph) is a downstream crate's
|
||||
@@ -130,7 +130,7 @@ tty-specific and therefore not ported into alktty's ADR range).
|
||||
|
||||
| OQ | Title | Status | Relevance |
|
||||
|----|-------|--------|-----------|
|
||||
| OQ-43 | `TtyControl` trait object `Clone` constraint | resolved | `control: Option<TtyControlHandle>` via a `#[derive(Clone)]` newtype wrapping `Arc<dyn TtyControl + Send + Sync>`; the trait is NOT `Clone` (not object-safe) — the newtype carries `Clone`-ability |
|
||||
| OQ-43 | `TtyControl` trait object `Clone` constraint | resolved | `control: Option<TtyControlHandle>` via a `#[derive(Clone)]` newtype wrapping `Arc<dyn TtyControl + Send + Sync>`; the trait is NOT `Clone` (not object-safe); the newtype carries `Clone`-ability |
|
||||
| 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 | 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 alktty; runner policy (job management, log persistence, task graph) is a downstream crate, not in scope here |
|
||||
@@ -138,20 +138,20 @@ tty-specific and therefore not ported into alktty's ADR range).
|
||||
|
||||
## References
|
||||
|
||||
- `docs/plans/project-setup.md` — the current plan (phases 0–5). All
|
||||
- `docs/plans/project-setup.md`: the current plan (phases 0–5). All
|
||||
five phases are landed: Phase 4 landed this directory (architecture
|
||||
docs + BAST schema + renumbered ADRs); Phase 5 landed the tests,
|
||||
including the integration tests in `tests/` at the crate root.
|
||||
- alknet originals of the ported ADRs (alknet ADR-052, 053, 054, 055,
|
||||
056, 057, 077, 093) at
|
||||
`/workspace/@alkdev/alknet/docs/architecture/decisions/` — the
|
||||
`/workspace/@alkdev/alknet/docs/architecture/decisions/`: the
|
||||
authoritative source for any ADR not yet ported, and for the alknet
|
||||
ADRs referenced by alknet number in the docs above (which are not
|
||||
tty-specific and therefore not ported into alktty's ADR range).
|
||||
- [alktype BAST format spec](https://alk.dev/bast/v1/schema) — the
|
||||
- [alktype BAST format spec](https://alk.dev/bast/v1/schema): the
|
||||
normative format spec for BAST documents (the meta-schema
|
||||
`tty-bast.md` conforms to); see also
|
||||
`/workspace/@alkdev/alktype/docs/architecture/bast-format.md`.
|
||||
- alkcall architecture README at
|
||||
`/workspace/@alkdev/alkcall/docs/architecture/README.md` — the
|
||||
`/workspace/@alkdev/alkcall/docs/architecture/README.md`: the
|
||||
producer/consumer protocol-crate pattern alktty follows.
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (amended 2026-07-18 — Phase 7: the control channel is split
|
||||
Accepted (amended 2026-07-18, Phase 7: the control channel is split
|
||||
into `STREAM_CTRL_IN = 3` (client→server) and `STREAM_CTRL_OUT = 4`
|
||||
(server→client) halves; see §"Control channel split" below. Ported
|
||||
from alknet ADR-052 2026-08-17; cross-references renumbered to
|
||||
alktty's ADR range — ADR-052→001, ADR-053→002, ADR-054→003,
|
||||
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.
|
||||
Alknet ADRs referenced by alknet number (001, 002, 003, 006, 007, 012,
|
||||
044, 049, 050) are not ported into alktty's ADR range because they are
|
||||
@@ -16,15 +16,15 @@ authoritative.)
|
||||
|
||||
## Context
|
||||
|
||||
The alknet-docker POC validated that interactive attach — bidirectional
|
||||
The alknet-docker POC validated that interactive attach (bidirectional
|
||||
byte pumping over a framed bidi stream with a 1-byte stream-type
|
||||
multiplexer — is the same problem regardless of whether the backend is
|
||||
multiplexer) is the same problem regardless of whether the backend is
|
||||
`bollard::attach_container()` or russh's `pty_request`. The POC's raw
|
||||
chunk format (`[stream_type: u8][length: u32 be][payload bytes]`,
|
||||
stream_type 0=stdin, 1=stdout, 2=stderr) is a deliberately impoverished
|
||||
version of SSH's channel multiplexer: fixed set of channel types, no
|
||||
negotiation, no open/close handshake, no windowing (QUIC provides flow
|
||||
control on the bidi stream). That impoverishment is the feature — a
|
||||
control on the bidi stream). That impoverishment is the feature: a
|
||||
terminal session needs exactly those channels and no more.
|
||||
|
||||
The alknet-tty POC (built 2026-07-05) extended that format with a 4th
|
||||
@@ -44,8 +44,8 @@ Three load-bearing questions are decided here:
|
||||
rejected: JSON-encoding every byte chunk is wasteful (base64 for
|
||||
binary, per-chunk `EventEnvelope` overhead) and lossy (a TTY streams
|
||||
partial bytes with no message boundary that maps to a JSON object).
|
||||
The two-carriage model — a single JSON negotiation frame, then raw
|
||||
chunks — keeps the call protocol's JSON-RPC shape for the *request*
|
||||
The two-carriage model (a single JSON negotiation frame, then raw
|
||||
chunks) keeps the call protocol's JSON-RPC shape for the *request*
|
||||
and switches to bytes for the *body*, which is the part that is
|
||||
actually bytes. This is the pattern the docker POC validated and the
|
||||
SSH research independently arrived at for PTY.
|
||||
@@ -53,7 +53,7 @@ Three load-bearing questions are decided here:
|
||||
2. **Fixed channel set, not extensible.** SSH multiplexes arbitrary
|
||||
services (forwarding, SFTP, agent, X11) over `ChannelId(u32)` with
|
||||
string-named types negotiated per channel. alktty multiplexes one
|
||||
service — a terminal session — with a fixed `u8` set and no
|
||||
service, a terminal session, with a fixed `u8` set and no
|
||||
negotiation. Adding a 6th channel type is a wire-format change
|
||||
(one-way door). The ALPN model handles extensibility at the protocol
|
||||
level: a genuinely new sideband (e.g., file transfer alongside the
|
||||
@@ -64,7 +64,7 @@ Three load-bearing questions are decided here:
|
||||
(`[control_type: u8][params...]`) would be faster but harder to
|
||||
extend and inconsistent with the negotiation layer. Control messages
|
||||
are rare (resize on window drag, signal on Ctrl-C, one eof, one exit
|
||||
per session) — serialization cost is negligible against the data
|
||||
per session), so serialization cost is negligible against the data
|
||||
chunks. If a hot control path appears, a binary `control_type` can
|
||||
be added without breaking the chunk format (additive within the
|
||||
control channel).
|
||||
@@ -79,7 +79,7 @@ implements `ProtocolHandler` (alknet ADR-002, revised by alknet ADR-007
|
||||
to receive a `Connection`). The handler owns the entire connection
|
||||
lifecycle and accepts one bidi stream per terminal session. This is a
|
||||
separate ALPN, not a set of operations in the call protocol's
|
||||
`OperationRegistry` — the raw-carriage byte pump is not a
|
||||
`OperationRegistry`: the raw-carriage byte pump is not a
|
||||
`StreamingHandler` (alknet ADR-049); it is its own wire format after a
|
||||
single JSON negotiation frame.
|
||||
|
||||
@@ -88,7 +88,7 @@ single JSON negotiation frame.
|
||||
The bidi stream has two phases:
|
||||
|
||||
- **Negotiation (JSON carriage).** The client opens a bidi stream and
|
||||
writes a single length-prefixed JSON frame — a 4-byte big-endian
|
||||
writes a single length-prefixed JSON frame: a 4-byte big-endian
|
||||
length prefix + UTF-8 JSON body. The frame carries the terminal
|
||||
parameters and backend selector the server needs to allocate the
|
||||
session:
|
||||
@@ -113,13 +113,13 @@ The bidi stream has two phases:
|
||||
|
||||
The `carriage` field is `"raw"` for terminal sessions (the only
|
||||
carriage in v1). The `tty` block is `null` for the pipe/runner case
|
||||
(no PTY — see ADR-003). The `backend` field selects the `TtyBackend`
|
||||
(no PTY; see ADR-003). The `backend` field selects the `TtyBackend`
|
||||
(ADR-002); backend-specific fields (e.g., `container` for docker)
|
||||
ride alongside.
|
||||
|
||||
- **Raw carriage.** After the negotiation frame, the stream switches to
|
||||
the chunk format below for the life of the session. There is no
|
||||
`call.responded`/`call.completed` — this is not the call protocol.
|
||||
`call.responded`/`call.completed`; this is not the call protocol.
|
||||
|
||||
### 3. Chunk format
|
||||
|
||||
@@ -127,24 +127,24 @@ The bidi stream has two phases:
|
||||
[stream_type: u8][length: u32 be][payload bytes]
|
||||
```
|
||||
|
||||
- `stream_type` — fixed set, no negotiation:
|
||||
- `stream_type` (fixed set, no negotiation):
|
||||
|
||||
| stream_type | channel | direction | payload |
|
||||
|---|---|---|---|
|
||||
| 0 | data-in (stdin) | client→server | raw bytes |
|
||||
| 1 | data-out (stdout) | server→client | raw bytes |
|
||||
| 2 | data-err (stderr) | server→client | raw bytes |
|
||||
| 3 | ctrl-in | client→server | JSON control message (`Resize`, `Signal`, `Eof` — see §4a) |
|
||||
| 4 | ctrl-out | server→client | JSON control message (`Exit` — see §4a) |
|
||||
| 3 | ctrl-in | client→server | JSON control message (`Resize`, `Signal`, `Eof`; see §4a) |
|
||||
| 4 | ctrl-out | server→client | JSON control message (`Exit`; see §4a) |
|
||||
|
||||
`stream_type > 4` is a protocol error (`InvalidStreamType`). (Phase 7
|
||||
amendment, §4a: the original `3 = control (bidirectional)` is split
|
||||
into `3 = ctrl-in` and `4 = ctrl-out`; the original bound was
|
||||
`> 3`.) There is no extension escape hatch in the byte — a 6th
|
||||
`> 3`.) There is no extension escape hatch in the byte: a 6th
|
||||
channel is a wire-format change requiring a new ALPN (`alk/tty/v2`
|
||||
per alknet ADR-006), not a negotiated addition to this format.
|
||||
|
||||
- `length` — payload length in bytes, u32 big-endian, max 16 MiB. A
|
||||
- `length`: payload length in bytes, u32 big-endian, max 16 MiB. A
|
||||
chunk larger than 16 MiB is a protocol error (`ChunkTooLarge`). The
|
||||
16 MiB bound accommodates large paste operations and large `env`
|
||||
blocks while bounding memory per chunk (a reader can allocate a
|
||||
@@ -166,20 +166,20 @@ Control chunks carry a small JSON payload, tagged by `type`:
|
||||
| client→server | eof | `{"type":"eof"}` |
|
||||
| server→client | exit | `{"type":"exit","code":0}` |
|
||||
|
||||
- **resize** — window-size change. Maps to SSH `window-change`, docker
|
||||
- **resize**: window-size change. Maps to SSH `window-change`, docker
|
||||
exec resize, or `ioctl(TIOCSWINSZ)` on a local PTY.
|
||||
- **signal** — signal forwarding. `name` is an uppercase string
|
||||
- **signal**: signal forwarding. `name` is an uppercase string
|
||||
(`"INT"`, `"TERM"`, `"HUP"`, `"QUIT"`, `"TSTP"`, `"CONT"`, `"KILL"`,
|
||||
`"USR1"`, `"USR2"`). Unknown names fall back to the backend's default
|
||||
kill (see ADR-002 / `tty-local.md` REQ-TTY-02).
|
||||
- **eof** — client signals no more stdin. Maps to SSH channel EOF,
|
||||
- **eof**: client signals no more stdin. Maps to SSH channel EOF,
|
||||
docker stdin close, or `ChildStdin::drop` / PTY writer close. This is
|
||||
one of two canonical "stdin done" signals; the other is a zero-length
|
||||
stdin chunk (the docker POC's sentinel). Both are accepted as
|
||||
equivalent — `eof` is recommended for explicitness (it's a control
|
||||
equivalent: `eof` is recommended for explicitness (it's a control
|
||||
message, not a data-length hack); the zero-length stdin chunk is kept
|
||||
for compatibility with the docker POC's pattern. See OQ-47.
|
||||
- **exit** — server signals process exit with code. This is the last
|
||||
- **exit**: server signals process exit with code. This is the last
|
||||
control chunk before stream close (see ADR-004).
|
||||
|
||||
The `type` tag is the extensibility seam: new control message types are
|
||||
@@ -187,7 +187,7 @@ added by extending the tagged enum. Unknown `type` values are ignored
|
||||
(not a protocol error) so that a newer client sending a control message
|
||||
an older server doesn't recognize degrades gracefully rather than
|
||||
tearing down the session. This is a two-way-door extension point within
|
||||
the one-way-door wire format — adding a control message type is
|
||||
the one-way-door wire format: adding a control message type is
|
||||
additive; changing the chunk header is not.
|
||||
|
||||
### 4a. Control channel split (Phase 7 amendment, 2026-07-18)
|
||||
@@ -207,7 +207,7 @@ The chunk header is otherwise unchanged (5 bytes: 1 type + 4 length).
|
||||
**Why the split.** The original §4 documented stream_type 3 as
|
||||
"bidirectional" and listed the four control messages with their
|
||||
directions. But the adapter had no way to distinguish the two
|
||||
directions on the same stream_type — `Exit` from the client was always
|
||||
directions on the same stream_type: `Exit` from the client was always
|
||||
ignored (the adapter's `pump_client_to_backend` matched `Exit` and
|
||||
logged "ignoring Exit control from client (server→client only)"). The
|
||||
spec said "bidirectional"; the code was half-duplex. The split makes
|
||||
@@ -220,7 +220,7 @@ exit vs. control without parsing the JSON `type` tag first.
|
||||
**Door type.** One-way, same as the original §3 / §4. The stream_type
|
||||
set is bytes clients and servers parse. A client written against the
|
||||
old single-`STREAM_CONTROL` shape will misread `STREAM_CTRL_OUT = 4` as
|
||||
`InvalidStreamType (> 3)` and tear down the session — the split is a
|
||||
`InvalidStreamType (> 3)` and tear down the session; the split is a
|
||||
wire-format change, not an additive extension. The reversal path is
|
||||
the same as the original: a new ALPN (`alk/tty/v2`), which coexists
|
||||
rather than replaces. The trade is one new stream_type byte now vs.
|
||||
@@ -231,7 +231,7 @@ the half-duplex-in-disguise flaw forever.
|
||||
direction table is unchanged in content (the four messages keep their
|
||||
directions and shapes) but the direction is now encoded in the
|
||||
stream_type, not just in the adapter's behavior. ADR-004's "exit chunk
|
||||
is last" invariant is unchanged — the exit chunk still rides the
|
||||
is last" invariant is unchanged: the exit chunk still rides the
|
||||
control channel, just on `STREAM_CTRL_OUT` (stream_type 4) instead of
|
||||
the old single `STREAM_CONTROL` (stream_type 3).
|
||||
|
||||
@@ -243,7 +243,7 @@ response in the same 4-byte length-prefixed framing as the negotiation
|
||||
frame and closes the stream without entering raw mode. The error
|
||||
response shape is `{"error":"<code>","message":"..."}` (see
|
||||
[tty-adapter.md](../tty-adapter.md) §"Negotiation errors" for the
|
||||
codes). This is not `call.error` — this is not the call protocol; the
|
||||
codes). This is not `call.error` (this is not the call protocol); the
|
||||
error is a JSON response in the negotiation framing, and the stream
|
||||
closes after it.
|
||||
|
||||
@@ -252,11 +252,11 @@ allocation (raw chunks) and a failed allocation (JSON error frame)
|
||||
begin with bytes the client must read before knowing which framing
|
||||
applies. The disambiguation is by the first byte: a JSON error frame's
|
||||
4-byte big-endian length prefix always starts with `0x00` (error frames
|
||||
MUST be under 16 MiB — `MAX_CHUNK_LEN` — so the high byte is zero; this
|
||||
is a wire-format invariant, not an assumption), while a raw chunk's
|
||||
MUST be under 16 MiB, the `MAX_CHUNK_LEN` cap, so the high byte is
|
||||
zero; this is a wire-format invariant, not an assumption), while a raw chunk's
|
||||
first byte is a `stream_type` in `{0, 1, 2, 3, 4}`. A stream_type of
|
||||
`0` (stdin from server) is invalid — the server never sends stdin
|
||||
chunks — so the client distinguishes: read the first byte; if it is
|
||||
`0` (stdin from server) is invalid, because the server never sends
|
||||
stdin chunks; the client distinguishes: read the first byte; if it is
|
||||
`0x00`, interpret the next 4 bytes as a big-endian length prefix and
|
||||
read that many bytes as a JSON error frame; otherwise interpret it as
|
||||
a `stream_type` byte and continue reading the raw chunk header. This is
|
||||
@@ -265,7 +265,7 @@ framing (length prefix) and MUST be under 16 MiB; success uses the raw
|
||||
chunk framing (stream_type byte first); the `0x00`-as-length-prefix vs
|
||||
`0x00`-as-invalid-stream_type disambiguation is what makes the two
|
||||
distinguishable on the wire. (With the Phase 7 split, the server-sent
|
||||
set is `{1, 2, 4}` — stdout, stderr, `STREAM_CTRL_OUT`; `0x00` is
|
||||
set is `{1, 2, 4}`: stdout, stderr, `STREAM_CTRL_OUT`; `0x00` is
|
||||
still unambiguous.)
|
||||
|
||||
### 6. Negotiation framing is self-contained in alktty (no alkcall-internal-wire-types dependency)
|
||||
@@ -274,7 +274,7 @@ The 4-byte length prefix + JSON body is implemented in alktty as a
|
||||
small, self-contained module (`src/negotiation.rs`, ~30 lines: read
|
||||
4-byte BE length, bounds-check, read N bytes; write the inverse). The
|
||||
format coincides with alkcall's `EventEnvelope` framing by convention
|
||||
(both are length-prefixed JSON) — not by code reuse. alktty does not
|
||||
(both are length-prefixed JSON), not by code reuse. alktty does not
|
||||
depend on alkcall's internal wire types: the negotiation payload is a
|
||||
tty-specific struct (`NegotiateRequest`), not a `call.requested`
|
||||
`EventEnvelope`, and alkcall's `FrameFramedReader::read_frame()` is
|
||||
@@ -294,7 +294,7 @@ clarification.
|
||||
- The wire format is POC-validated twice (docker POC for stream_type
|
||||
0/1/2 + bidirectional pump; tty POC for stream_type 3 + control
|
||||
messages + local PTY). No new wire-format invention in Phase 1.
|
||||
- The fixed channel set is a `match`, not a hash lookup — fast on the
|
||||
- The fixed channel set is a `match`, not a hash lookup, fast on the
|
||||
hot path where every chunk is data.
|
||||
- The two-carriage model keeps the call protocol's JSON-RPC shape for
|
||||
the structured request while letting the body be raw bytes, which is
|
||||
@@ -312,21 +312,21 @@ clarification.
|
||||
**Negative:**
|
||||
|
||||
- A 6th channel type is a wire-format change (one-way door). The
|
||||
justification is that the use cases are bounded — a terminal session
|
||||
justification is that the use cases are bounded: a terminal session
|
||||
has stdin, stdout, stderr, and control (now split into ctrl-in /
|
||||
ctrl-out). New sideband needs are different ALPNs, not 6th channels.
|
||||
If this proves wrong, the reversal is a new ALPN string
|
||||
(`alk/tty/v2`), which coexists with the old one rather than replacing
|
||||
it — but every client and server implementing the old format would
|
||||
it, but every client and server implementing the old format would
|
||||
need updating to speak the new one.
|
||||
- Control as JSON means a `serde_json` deserialize per control chunk.
|
||||
Control chunks are rare (one per resize, one per signal, one eof, one
|
||||
exit), so this is negligible. A hot control path would warrant a
|
||||
binary format — the `type`-tagged enum leaves that door open without
|
||||
binary format; the `type`-tagged enum leaves that door open without
|
||||
a wire-format change.
|
||||
- The negotiation frame is a custom JSON shape, not a `call.requested`
|
||||
event, so a client library can't reuse its call-protocol client to
|
||||
open a tty session — it speaks the tty wire format directly. This is
|
||||
open a tty session; it speaks the tty wire format directly. This is
|
||||
intentional (the tty session is not a call-protocol operation) but
|
||||
means the tty client is a separate small client, not a `CallClient`
|
||||
method.
|
||||
@@ -337,8 +337,8 @@ clarification.
|
||||
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 the control
|
||||
channel — a two-way-door extension to the control channel, not a
|
||||
wire-format header change. The `type`-tagged enum's "unknown types
|
||||
channel (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.
|
||||
|
||||
@@ -348,7 +348,7 @@ clarification.
|
||||
stream_type set (0-4), and the two-carriage sequence (JSON frame → raw
|
||||
chunks) are bytes clients and servers parse. Changing any of them breaks
|
||||
every client and server implementing the format. The reversal path is a
|
||||
new ALPN (`alk/tty/v2`), which coexists rather than replaces — but the
|
||||
new ALPN (`alk/tty/v2`), which coexists rather than replaces, but the
|
||||
cost of migrating every consumer is the one-way-door cost.
|
||||
|
||||
The control message `type` enum is a two-way-door extension point within
|
||||
@@ -362,14 +362,14 @@ not.
|
||||
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
|
||||
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.
|
||||
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 +
|
||||
@@ -386,26 +386,26 @@ not.
|
||||
|
||||
## References
|
||||
|
||||
- alknet ADR-001 — ALPN-based dispatch
|
||||
- alknet ADR-002 — ProtocolHandler trait
|
||||
- alknet ADR-006 — `alk/<name>` ALPN convention; one ALPN per
|
||||
- alknet ADR-001: ALPN-based dispatch
|
||||
- alknet ADR-002: ProtocolHandler trait
|
||||
- alknet ADR-006: `alk/<name>` ALPN convention; one ALPN per
|
||||
connection; new ALPN for incompatible versions
|
||||
- alknet ADR-007 — handler receives a `Connection`, accepts bidi streams
|
||||
- alknet ADR-003 Amendment 2 — alktty does not depend on alknet-call;
|
||||
- alknet ADR-007: handler receives a `Connection`, accepts bidi streams
|
||||
- alknet ADR-003 Amendment 2: alktty does not depend on alknet-call;
|
||||
self-contained negotiation framing
|
||||
- [ADR-006](006-negotiation-framing-self-contained.md) — the
|
||||
- [ADR-006](006-negotiation-framing-self-contained.md): the
|
||||
dependency-edge decision this ADR's §6 reflects
|
||||
- alknet ADR-012 — the call protocol's stream model (which tty is *not*
|
||||
- alknet ADR-012 (the call protocol's stream model, which tty is *not*
|
||||
using for the body, by design)
|
||||
- alknet ADR-049 — the `StreamingHandler` path tty explicitly does not
|
||||
- alknet ADR-049: the `StreamingHandler` path tty explicitly does not
|
||||
use for the byte body
|
||||
- [ADR-002](002-ttybackend-trait-and-ttyhandle.md) — the backend trait
|
||||
- [ADR-002](002-ttybackend-trait-and-ttyhandle.md): the backend trait
|
||||
the negotiation frame's `backend` field dispatches to
|
||||
- [ADR-003](003-local-backend-placement.md) — the local backend's
|
||||
- [ADR-003](003-local-backend-placement.md): the local backend's
|
||||
module placement (the `tty: null` pipe-mode case)
|
||||
- [ADR-004](004-exit-code-on-control-chunk.md) — the exit-chunk
|
||||
- [ADR-004](004-exit-code-on-control-chunk.md): the exit-chunk
|
||||
ordering this ADR's control channel carries
|
||||
- [ADR-008](008-channels-pure-channel-multiplexing.md) — TTY always
|
||||
- [ADR-008](008-channels-pure-channel-multiplexing.md): TTY always
|
||||
uses its 5-byte format, including inside channels (the channels layer
|
||||
carries this wire format transparently in its payload)
|
||||
- Spec: [tty-wire.md](../tty-wire.md), [tty-bast.md](../tty-bast.md)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# ADR-002: TtyBackend Trait and TtyHandle — the Backend Inversion Point
|
||||
# ADR-002: TtyBackend Trait and TtyHandle: the Backend Inversion Point
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (ported from alknet ADR-053 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. Alknet ADRs referenced by alknet number (003, 007, 017,
|
||||
050) are not ported into alktty's ADR range because they are not
|
||||
@@ -13,7 +13,7 @@ authoritative.)
|
||||
|
||||
## Context
|
||||
|
||||
alktty's wire format (ADR-001) is backend-agnostic — the chunk codec
|
||||
alktty's wire format (ADR-001) is backend-agnostic: the chunk codec
|
||||
pumps bytes and JSON control messages without knowing whether the
|
||||
backend is a docker container, an SSH session channel, or a local
|
||||
process. The question this ADR answers: **what is the seam between the
|
||||
@@ -22,7 +22,7 @@ wire-format adapter and the backends?**
|
||||
The alknet-tty research identified the `TtyBackend` trait as the
|
||||
inversion point. The guiding insight:
|
||||
|
||||
> A terminal session is not an SSH concern, or a Docker concern — it is
|
||||
> A terminal session is not an SSH concern, or a Docker concern; it is
|
||||
> a terminal concern. SSH and Docker are just two backends that can
|
||||
> allocate a PTY.
|
||||
|
||||
@@ -32,7 +32,7 @@ alknet-ssh, alktty's `local` feature module) implement the trait. This
|
||||
preserves alknet ADR-003's no-handler-depends-on-another-handler rule:
|
||||
alktty depends on alkcall; backend crates depend on alktty for the
|
||||
trait; alktty does not depend on any backend (and, per ADR-006, does
|
||||
not depend on alkcall's internal wire types either — the negotiation
|
||||
not depend on alkcall's internal wire types either; the negotiation
|
||||
framing is self-contained).
|
||||
|
||||
### What the local-PTY POC discovered about the trait shape
|
||||
@@ -46,7 +46,7 @@ in the alknet findings doc):
|
||||
`portable_pty` is a blocking `std::io::{Read, Write}` API with a
|
||||
blocking `Child::wait()`. The POC bridges it to async via three
|
||||
dedicated std threads (reader, writer, waiter) feeding tokio
|
||||
mpsc/oneshot channels — the same pattern wezterm (portable_pty's
|
||||
mpsc/oneshot channels, the same pattern wezterm (portable_pty's
|
||||
primary consumer) uses. The trait's adapter-facing types
|
||||
(`AsyncWrite`, `Stream<Item = Bytes>`, `BoxFuture`) are the
|
||||
*adapter's* contract; a backend may expose blocking handles
|
||||
@@ -71,9 +71,9 @@ The POC used a separate cloneable `PtyControl` struct for resize/signal,
|
||||
not a trait object. The research noted this worked cleanly because the
|
||||
control-chunk dispatcher needs to be `Clone` to hand off to the spawned
|
||||
pump task. Phase 1 confirms the `control` field as a separate
|
||||
`TtyControlHandle` newtype — a concrete `#[derive(Clone)]` struct
|
||||
`TtyControlHandle` newtype: a concrete `#[derive(Clone)]` struct
|
||||
wrapping `Arc<dyn TtyControl + Send + Sync>` (the trait is NOT `Clone`;
|
||||
`Clone` is not object-safe — see OQ-43). The newtype carries the
|
||||
`Clone` is not object-safe; see OQ-43). The newtype carries the
|
||||
`Clone`-ability; the trait stays object-safe.
|
||||
|
||||
## Decision
|
||||
@@ -90,7 +90,7 @@ pub trait TtyBackend: Send + Sync {
|
||||
|
||||
/// The pre-existing resource this session targets, for ownership
|
||||
/// checks (alknet ADR-050). `None` = no pre-existing resource (the session
|
||||
/// creates its own — local process, SSH channel). `Some((kind, id))`
|
||||
/// creates its own: local process, SSH channel). `Some((kind, id))`
|
||||
/// = the session targets an existing resource the caller must own
|
||||
/// (e.g., DockerTtyBackend returns `Some(("container", id))`). The
|
||||
/// adapter calls this at negotiation to gate access; the backend
|
||||
@@ -106,11 +106,11 @@ The assembly layer registers backends at startup; the adapter dispatches
|
||||
by the `backend` field. A backend is the *thing that allocates a
|
||||
session*; the wire-format pump is backend-agnostic.
|
||||
|
||||
### 2. `TtyParams` — the allocation request
|
||||
### 2. `TtyParams`: the allocation request
|
||||
|
||||
```rust
|
||||
pub struct TtyParams {
|
||||
/// Terminal parameters. `None` = pipe mode (no PTY — the runner case,
|
||||
/// Terminal parameters. `None` = pipe mode (no PTY; the runner case,
|
||||
/// ADR-003). `Some` = allocate a PTY with these dimensions.
|
||||
pub terminal: Option<TerminalParams>,
|
||||
/// Command vector (argv[0] + args).
|
||||
@@ -133,12 +133,12 @@ pub struct TerminalParams {
|
||||
pub rows: u16,
|
||||
pub pixel_width: u16,
|
||||
pub pixel_height: u16,
|
||||
pub modes: serde_json::Value, // reserved — OQ-44; backends MUST ignore content in v1
|
||||
pub modes: serde_json::Value, // reserved (OQ-44); backends MUST ignore content in v1
|
||||
}
|
||||
```
|
||||
|
||||
`terminal: None` is the pipe/runner case — no PTY, separate
|
||||
stdout/stderr (ADR-003). `terminal: Some` is the PTY case —
|
||||
`terminal: None` is the pipe/runner case: no PTY, separate
|
||||
stdout/stderr (ADR-003). `terminal: Some` is the PTY case:
|
||||
stdout/stderr merged into the single stdout stream
|
||||
(`TtyHandle.stderr` is `None`), real terminal semantics (resize, signal
|
||||
delivery to process group).
|
||||
@@ -149,7 +149,7 @@ delivery to process group).
|
||||
a typed enum. The adapter passes the negotiation frame's
|
||||
backend-specific fields through verbatim; the backend deserializes its
|
||||
own strongly-typed params struct. alktty has zero knowledge of any
|
||||
backend's params shape — not docker's `container`, not an SSH host
|
||||
backend's params shape: not docker's `container`, not an SSH host
|
||||
selector, not anything.
|
||||
|
||||
Each backend defines its own params struct:
|
||||
@@ -164,7 +164,7 @@ struct DockerBackendParams { container: String }
|
||||
struct SshBackendParams { /* host selector if multi-host; else empty */ }
|
||||
|
||||
// in alktty's local feature module
|
||||
// no backend-specific params — backend_params is empty
|
||||
// no backend-specific params; backend_params is empty
|
||||
```
|
||||
|
||||
And deserializes from `params.backend_params` inside `allocate()`:
|
||||
@@ -183,7 +183,7 @@ impl TtyBackend for DockerTtyBackend {
|
||||
This is a complete inversion: the *trait* is inverted (backends
|
||||
implement, alktty doesn't depend on them) and the *params* are inverted
|
||||
(backends define their own typed shape, alktty doesn't carry it). A new
|
||||
backend crate requires zero changes to alktty — no enum variant to add,
|
||||
backend crate requires zero changes to alktty: no enum variant to add,
|
||||
no forward-reference type to place, no dependency edge.
|
||||
|
||||
**Why not a typed enum.** The earlier draft of this ADR defined
|
||||
@@ -203,7 +203,7 @@ problems:
|
||||
`ChannelId` and session reference." If it lives in alknet-ssh,
|
||||
alktty depends on alknet-ssh (violates the inversion). If it lives in
|
||||
alktty, alktty pulls in russh types (same violation). Opaque params
|
||||
dissolve the contradiction — there is no `SshChannelRef` type in
|
||||
dissolve the contradiction: there is no `SshChannelRef` type in
|
||||
alktty at all.
|
||||
|
||||
The earlier draft rejected `serde_json::Value` because "it loses type
|
||||
@@ -212,40 +212,40 @@ shouldn't interpret." The first concern doesn't apply (each backend has
|
||||
its own strongly-typed struct via serde; type safety moves from alktty
|
||||
to the backend where it belongs). The second was already inconsistent
|
||||
with the adapter, which hardcoded extraction of docker's `container`
|
||||
field for the ownership check — the adapter *was* parsing
|
||||
field for the ownership check; the adapter *was* parsing
|
||||
backend-specific JSON. The opaque approach removes that: the adapter
|
||||
delegates the resource-id extraction to the backend via `resource_id()`
|
||||
below.
|
||||
|
||||
### 3. `TtyHandle` — what a backend produces
|
||||
### 3. `TtyHandle`: what a backend produces
|
||||
|
||||
```rust
|
||||
pub struct TtyHandle {
|
||||
/// Stdin writer — bytes the adapter pumps from client stdin chunks.
|
||||
/// Stdin writer: bytes the adapter pumps from client stdin chunks.
|
||||
/// `tokio::io::AsyncWrite` (the tokio flavor, not the `futures::io`
|
||||
/// one — they are incompatible traits; the tokio stack is the
|
||||
/// one; they are incompatible traits; the tokio stack is the
|
||||
/// adapter's runtime).
|
||||
pub stdin: Box<dyn tokio::io::AsyncWrite + Send + Unpin>,
|
||||
/// Stdout stream — bytes the adapter pumps to client stdout chunks.
|
||||
/// Stdout stream: bytes the adapter pumps to client stdout chunks.
|
||||
/// Ends when the backend's stdout reaches EOF (process exited,
|
||||
/// container output stream ended, SSH channel closed).
|
||||
/// `futures_core::Stream<Item = bytes::Bytes>` (re-exported by
|
||||
/// `tokio_stream::StreamExt` for extension methods).
|
||||
pub stdout: Pin<Box<dyn futures_core::Stream<Item = bytes::Bytes> + Send>>,
|
||||
/// Stderr stream — `None` for PTY backends (stdout/stderr merged
|
||||
/// Stderr stream: `None` for PTY backends (stdout/stderr merged
|
||||
/// into `stdout`). `Some` for pipe backends (separate streams).
|
||||
pub stderr: Option<Pin<Box<dyn futures_core::Stream<Item = bytes::Bytes> + Send>>>,
|
||||
/// Exit code — a `Future` the adapter awaits. Resolves when the
|
||||
/// Exit code: a `Future` the adapter awaits. Resolves when the
|
||||
/// process/container/SSH exec exits. The adapter sends the result
|
||||
/// as the `{"type":"exit","code":N}` control chunk (ADR-004) and
|
||||
/// closes the stream. This is `BoxFuture`, not a method on
|
||||
/// `TtyHandle`, so the adapter can `select` between exit and
|
||||
/// stream-close without coupling to the other fields. (REQ-TTY-01.)
|
||||
pub exit_code: BoxFuture<'static, Result<i32, TtyError>>,
|
||||
/// Control handle (resize, signal) — `Clone` so the adapter can
|
||||
/// Control handle (resize, signal). `Clone` so the adapter can
|
||||
/// hand it to the spawned control-chunk dispatcher. `None` only
|
||||
/// when the backend genuinely has no control path (e.g., a pipe
|
||||
/// backend with no PTY — signal still works via `kill(pid, sig)`,
|
||||
/// backend with no PTY; signal still works via `kill(pid, sig)`,
|
||||
/// but resize is a no-op). See OQ-43 for the `TtyControlHandle`
|
||||
/// newtype rationale.
|
||||
pub control: Option<TtyControlHandle>,
|
||||
@@ -265,7 +265,7 @@ pub trait TtyControl: Send + Sync {
|
||||
}
|
||||
|
||||
/// The `Clone`-able handle to a backend's control path. The `TtyControl`
|
||||
/// trait is NOT `Clone` (`Clone` is not object-safe — `fn clone(&self) ->
|
||||
/// trait is NOT `Clone` (`Clone` is not object-safe: `fn clone(&self) ->
|
||||
/// Self` returns `Self`, which forbids `dyn` dispatch); the `Clone`-ability
|
||||
/// lives on this concrete newtype, which holds the trait object behind an
|
||||
/// `Arc`. The adapter clones the `Arc` to hand a handle to the spawned
|
||||
@@ -279,9 +279,9 @@ pub struct TtyControlHandle(Arc<dyn TtyControl + Send + Sync>);
|
||||
The trait's adapter-facing types (`AsyncWrite`, `Stream<Item = Bytes>`,
|
||||
`BoxFuture`, the `TtyControl` trait object) are the **adapter's
|
||||
contract**. A backend may expose blocking handles internally and bridge
|
||||
them to these async-facing types. The bridging pattern — blocking
|
||||
them to these async-facing types. The bridging pattern (blocking
|
||||
`std::io` on dedicated std threads or `tokio::task::spawn_blocking`,
|
||||
feeding tokio mpsc/oneshot channels — is a **documented, supported
|
||||
feeding tokio mpsc/oneshot channels) is a **documented, supported
|
||||
implementation strategy**, not a workaround.
|
||||
|
||||
The local backend (ADR-003) uses this pattern: `portable_pty` is a
|
||||
@@ -293,7 +293,7 @@ the threading. See `tty-local.md` for the bridge details.
|
||||
|
||||
### 5. Backend registration and the assembly layer
|
||||
|
||||
The `TtyAdapter` does not know the set of backends at compile time — it
|
||||
The `TtyAdapter` does not know the set of backends at compile time; it
|
||||
holds a `HashMap<String, Arc<dyn TtyBackend>>` populated at
|
||||
construction. The assembly layer (the CLI binary) constructs backends
|
||||
with their dependencies (a `DockerTtyBackend` wraps a `bollard::Docker`
|
||||
@@ -326,21 +326,21 @@ layer chooses what's available.
|
||||
`OperationAdapter` (alknet ADR-017): the trait lives where the types
|
||||
live; the implementations live where their transport dependencies
|
||||
live.
|
||||
- Blocking-API backends (portable_pty) are first-class — the trait
|
||||
- Blocking-API backends (portable_pty) are first-class: the trait
|
||||
accommodates them by making the adapter-facing types the contract and
|
||||
the bridging pattern a documented strategy. No re-spec required when
|
||||
a future backend is also blocking.
|
||||
- `exit_code` as a `Future` (not a method on the handle) lets the
|
||||
adapter `select` between exit and stream-close — the load-bearing
|
||||
adapter `select` between exit and stream-close, the load-bearing
|
||||
composition the session lifecycle needs (the exit chunk is sent after
|
||||
the child is reaped, then the stream closes — ADR-004).
|
||||
the child is reaped, then the stream closes; ADR-004).
|
||||
|
||||
**Negative:**
|
||||
|
||||
- **Backend params are opaque (`serde_json::Map`), not a typed enum.**
|
||||
Each backend deserializes its own params struct; the adapter passes
|
||||
the JSON through verbatim. The cost is one serde deserialize per
|
||||
`allocate()` call (negligible — allocation is once per session, not on
|
||||
`allocate()` call (negligible; allocation is once per session, not on
|
||||
the hot path). The benefit is a complete inversion: alktty has zero
|
||||
knowledge of any backend's params shape, and a new backend crate
|
||||
requires zero changes to alktty (no enum variant, no forward-reference
|
||||
@@ -372,7 +372,7 @@ layer chooses what's available.
|
||||
`TtyHandle` field set, and the `TtyControl` trait are the API surface
|
||||
every backend crate implements and the adapter consumes. Changing them
|
||||
after backends exist is a rewrite across crates. `backend_params` as an
|
||||
opaque `serde_json::Map` is part of the one-way `TtyParams` shape — the
|
||||
opaque `serde_json::Map` is part of the one-way `TtyParams` shape: the
|
||||
*carrier* is fixed (opaque JSON), but the *contents* are
|
||||
backend-defined and require no alktty change for new backends. The
|
||||
`resource_id()` default method is additive (a new method with a default
|
||||
@@ -384,7 +384,7 @@ impl doesn't break existing implementors); its return type
|
||||
1. **The `TtyControl` trait is kept object-safe by NOT putting `Clone`
|
||||
on it; the `TtyControlHandle` newtype holds the trait object behind
|
||||
an `Arc` and implements `Clone` by cloning the `Arc`.** The POC used
|
||||
a concrete `PtyControl` struct (inherently `Clone` — it held
|
||||
a concrete `PtyControl` struct (inherently `Clone`: it held
|
||||
`Arc<Mutex<...>>` fields). The newtype generalizes the POC's shape so
|
||||
a backend produces its own control type via
|
||||
`TtyControlHandle::new(Arc::new(MyControl))` without the adapter
|
||||
@@ -393,43 +393,43 @@ impl doesn't break existing implementors); its return type
|
||||
|
||||
2. **Backends produce a single session per `allocate()` call.** The
|
||||
adapter calls `allocate()` once per accepted bidi stream (one session
|
||||
per stream — ADR-001). A backend that multiplexed multiple sessions
|
||||
per stream; ADR-001). A backend that multiplexed multiple sessions
|
||||
over one `allocate()` would not fit the trait; no such backend is
|
||||
contemplated.
|
||||
|
||||
3. **The adapter, not the backend, owns the exit-chunk ordering.** The
|
||||
backend resolves `exit_code`; the adapter awaits it, sends the exit
|
||||
control chunk, and closes the stream (ADR-004). The backend does not
|
||||
write to the wire — it produces handles; the adapter pumps. This
|
||||
write to the wire: it produces handles; the adapter pumps. This
|
||||
keeps the wire-format logic in one place (the adapter) and the
|
||||
backend focused on its allocation target (docker, ssh, local
|
||||
process).
|
||||
|
||||
## References
|
||||
|
||||
- alknet ADR-003 + Amendments 1 & 2 — no-handler-depends-on-another-
|
||||
- alknet ADR-003 + Amendments 1 & 2: no-handler-depends-on-another-
|
||||
handler; alktty depends on alkcall (no alkcall-internal-wire-types per
|
||||
Am. 2 / ADR-006); backends depend on alktty for the trait
|
||||
- [ADR-006](006-negotiation-framing-self-contained.md) — alktty does not
|
||||
- [ADR-006](006-negotiation-framing-self-contained.md): alktty does not
|
||||
depend on alkcall's internal wire types (self-contained negotiation
|
||||
framing)
|
||||
- alknet ADR-007 — `Connection`, `SendStream`, `RecvStream` (the adapter
|
||||
- alknet ADR-007: `Connection`, `SendStream`, `RecvStream` (the adapter
|
||||
receives a `Connection`, accepts bidi streams, pumps per-session)
|
||||
- [ADR-001](001-wire-format-and-two-carriage.md) — the wire format this
|
||||
- [ADR-001](001-wire-format-and-two-carriage.md): the wire format this
|
||||
trait's backends feed
|
||||
- [ADR-003](003-local-backend-placement.md) — the local backend's
|
||||
- [ADR-003](003-local-backend-placement.md): the local backend's
|
||||
module placement (folded into alktty behind a `local` feature)
|
||||
- [ADR-004](004-exit-code-on-control-chunk.md) — the exit chunk ordering
|
||||
- [ADR-004](004-exit-code-on-control-chunk.md): the exit chunk ordering
|
||||
this trait's `exit_code` field feeds into
|
||||
- [ADR-005](005-backend-cleanup-on-session-cancel.md) — the cancel-
|
||||
- [ADR-005](005-backend-cleanup-on-session-cancel.md): the cancel-
|
||||
cleanup contract on this trait (`exit_code` future's `Drop`-on-cancel
|
||||
kills the session target)
|
||||
- alknet ADR-017 — the adapter-location-map pattern (trait where types
|
||||
- alknet ADR-017: the adapter-location-map pattern (trait where types
|
||||
live, implementation where deps live) this ADR follows
|
||||
- alknet ADR-050 — the ownership model the `resource_id()` default
|
||||
- alknet ADR-050: the ownership model the `resource_id()` default
|
||||
declares against
|
||||
- OQ-43 — `TtyControl` as `Clone` trait object (resolved: confirmed)
|
||||
- OQ-44 — terminal modes (deferred(scope): not needed for current scope)
|
||||
- OQ-43: `TtyControl` as `Clone` trait object (resolved: confirmed)
|
||||
- OQ-44: terminal modes (deferred(scope): not needed for current scope)
|
||||
- Spec: [tty-backend.md](../tty-backend.md)
|
||||
- Port origin: alknet ADR-053 at
|
||||
`/workspace/@alkdev/alknet/docs/architecture/decisions/053-ttybackend-trait-and-ttyhandle.md`
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Accepted (ported from alknet ADR-054 2026-08-17, with the single-crate
|
||||
consolidation recorded as the alktty resolution. 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. Alknet ADRs referenced by alknet number (003, 009, 017)
|
||||
are not ported into alktty's ADR range because they are not tty-specific;
|
||||
@@ -29,15 +29,15 @@ local-process backend (`std::process::Command` with piped stdio, or
|
||||
|
||||
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
|
||||
over a framed bidi connection, the same shape as GitHub/Gitea Actions
|
||||
runners, just over alk's transport instead of HTTP polling). It has no
|
||||
heavy dependencies in the pipe case — just `std` — but the PTY case
|
||||
heavy dependencies in the pipe case (just `std`), but the PTY case
|
||||
pulls in `portable_pty` (a non-trivial native dependency that builds on
|
||||
Unix `openpty`/`ioctl` and Windows ConPTY).
|
||||
|
||||
The relevant constraint from ADR-002: alktty itself depends only on
|
||||
alkcall and the wire-format codec (ADR-001). The `portable_pty`
|
||||
dependency does not belong in the core tty crate — a docker-only
|
||||
dependency does not belong in the core tty crate: a docker-only
|
||||
deployment or an SSH-PTY-only deployment should not pull in PTY
|
||||
allocation code. This is the same inversion as `OperationAdapter`
|
||||
(alknet ADR-017): the trait lives where the types live; the
|
||||
@@ -56,7 +56,7 @@ in `alknet-tty-local`; alknet-tty itself never depends on
|
||||
The sibling-crate placement was motivated by a cyclic-dependency
|
||||
workaround: `alknet-tty-local` depends on `alknet-tty` for the trait,
|
||||
and alknet ADR-054 wanted `alknet-tty` to re-export `LocalTtyBackend`
|
||||
behind a `local` feature — which cargo rejects (a crate cannot re-export
|
||||
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 assembly-layer workaround (consumer
|
||||
depends on both crates directly) worked but was awkward.
|
||||
@@ -74,7 +74,7 @@ ssh-only deployment enables neither feature and never pulls in
|
||||
This ADR records both decisions: the original alknet sibling-crate
|
||||
decision (preserved as the historical context for why the local backend
|
||||
is a separable concern), and the alktty consolidation (the operative
|
||||
decision — the local backend is folded into alktty behind a `local`
|
||||
decision: the local backend is folded into alktty behind a `local`
|
||||
feature).
|
||||
|
||||
## Decision
|
||||
@@ -100,8 +100,8 @@ tokio-util = { version = "0.7", features = ["io"], optional = true }
|
||||
A consumer that wants the local backend enables `features = ["local"]`
|
||||
on alktty and gets `alktty::local::LocalTtyBackend`. A consumer that
|
||||
only wants docker/ssh backends uses the default features and depends on
|
||||
`alknet-docker` / `alknet-ssh` (or their own backend crate) directly —
|
||||
no `portable_pty` in the dependency tree.
|
||||
`alknet-docker` / `alknet-ssh` (or their own backend crate) directly,
|
||||
with no `portable_pty` in the dependency tree.
|
||||
|
||||
This is the same feature-re-export pattern the Rust ecosystem uses for
|
||||
optional heavy dependencies (e.g., `tokio`'s `full` feature pulling in
|
||||
@@ -118,15 +118,15 @@ coupled to the trait.
|
||||
alktty (local feature enabled)
|
||||
├── alktty (default) (TtyBackend trait, TtyHandle, TtyControl, wire types)
|
||||
├── alkcall::core (via alktty's re-export; not direct)
|
||||
├── portable_pty (PTY allocation — the heavy dep, here not in alktty default)
|
||||
├── libc (signal forwarding — REQ-TTY-02, Unix only)
|
||||
├── portable_pty (PTY allocation, the heavy dep, not in alktty default)
|
||||
├── 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 — this is what makes the downstream
|
||||
(no features) stays wasm-clean; this is what makes the downstream
|
||||
TS/Python adapter story work (a wasm-compiled alktty is the protocol
|
||||
layer for a sandboxed adapter; the local-process backend runs on a real
|
||||
OS, never in a sandbox).
|
||||
@@ -135,19 +135,19 @@ OS, never in a sandbox).
|
||||
|
||||
`TtyParams.terminal: Option<TerminalParams>` (ADR-002) selects the mode:
|
||||
|
||||
- **`terminal: Some(TerminalParams { ... })`** — allocate a real PTY
|
||||
- **`terminal: Some(TerminalParams { ... })`**: allocate a real PTY
|
||||
via `portable_pty`. Terminal semantics: resize (via
|
||||
`ioctl(TIOCSWINSZ)`), signal delivery to the foreground process group
|
||||
(via `libc::kill(-pgid, sig)`, REQ-TTY-02), escape-sequence handling
|
||||
(the kernel PTY's line discipline). stdout and stderr are merged
|
||||
(kernel PTY property — one output stream from the slave), so
|
||||
(kernel PTY property: one output stream from the slave), so
|
||||
`TtyHandle.stderr` is `None`.
|
||||
- **`terminal: None`** — pipe mode, no PTY. `tokio::process::Command`
|
||||
- **`terminal: None`**: pipe mode, no PTY. `tokio::process::Command`
|
||||
with `Stdio::piped()` for stdin/stdout/stderr. No resize, no
|
||||
escape-sequence handling, but `kill(pid, sig)` still works for signal
|
||||
forwarding. stdout and stderr are separate streams, so
|
||||
`TtyHandle.stderr` is `Some`. This is the **runner** case — a
|
||||
command-streaming endpoint with no terminal semantics.
|
||||
`TtyHandle.stderr` is `Some`. This is the **runner** case, a
|
||||
command-streaming endpoint with no terminal semantics.
|
||||
|
||||
The same `LocalTtyBackend` serves both; the `allocate()` call branches
|
||||
on `params.terminal`. A deployment that only does terminals always
|
||||
@@ -169,7 +169,7 @@ polling:
|
||||
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.
|
||||
control chunk + stream close), no polling.
|
||||
|
||||
The runner-specific API surface (job management, log persistence, task
|
||||
graph integration) is **out of scope for alktty**. alktty provides the
|
||||
@@ -186,7 +186,7 @@ alktty. See OQ-46.
|
||||
wire-format codec). A docker-only or ssh-only deployment never pulls
|
||||
in `portable_pty`.
|
||||
- The local backend is a one-feature opt-in (`features = ["local"]`)
|
||||
for the common case — a consumer that wants a terminal/runner
|
||||
for the common case: a consumer that wants a terminal/runner
|
||||
endpoint with no docker or SSH gets it with one feature flag, not a
|
||||
separate dependency.
|
||||
- PTY vs pipe is per-session, so one `LocalTtyBackend` serves terminals
|
||||
@@ -198,8 +198,8 @@ alktty. See OQ-46.
|
||||
no-handler-depends-on-another-handler rule: the local backend is in
|
||||
the same crate as the trait, so there is no cross-crate dependency
|
||||
edge to worry about. The docker and SSH backends remain separate
|
||||
crates (real external deps — `bollard`, `russh` — and their own
|
||||
resource models).
|
||||
crates with real external deps (`bollard`, `russh`) and their own
|
||||
resource models.
|
||||
- The alktty consolidation resolves the alknet cyclic-dep workaround
|
||||
that motivated the original sibling-crate decision. No assembly-layer
|
||||
"depend on both crates directly" pattern; no cargo-rejected
|
||||
@@ -209,11 +209,11 @@ alktty. See OQ-46.
|
||||
|
||||
- A consumer that wants the local backend must enable a feature flag
|
||||
(`features = ["local"]`). Forgetting the flag results in the
|
||||
`alktty::local` module not existing — a compile error, not a silent
|
||||
miss. This is the standard Rust feature-flag trade and is
|
||||
`alktty::local` module not existing (a compile error, not a silent
|
||||
miss). This is the standard Rust feature-flag trade and is
|
||||
self-documenting.
|
||||
- The `local` feature is non-wasm by design. A consumer that targets
|
||||
`wasm32-unknown-unknown` must not enable `local` — the build error is
|
||||
`wasm32-unknown-unknown` must not enable `local`; the build error is
|
||||
intentional (the local-process backend cannot run in a sandbox). The
|
||||
default crate stays wasm-clean so the downstream TS/Python adapter
|
||||
story works.
|
||||
@@ -230,7 +230,7 @@ reversible: if the local backend ever grows large enough to warrant
|
||||
splitting out (e.g., a future docker or SSH backend shares enough
|
||||
portable-pty bridge code to motivate a shared "blocking-backend bridge"
|
||||
crate), extracting `src/local/` into a sibling crate behind the same
|
||||
`local` feature is mechanical — the trait is the seam, and the feature
|
||||
`local` feature is mechanical: the trait is the seam, and the feature
|
||||
gate already exists. The cost of reversal is low (the module becomes a
|
||||
crate; the feature gate switches from `dep:portable-pty` to
|
||||
`dep:alktty-local`), and no downstream consumer breaks (the
|
||||
@@ -240,28 +240,28 @@ re-exports it).
|
||||
This is a two-way door that is **decided** (single crate + `local`
|
||||
feature), not deferred. The decision is made now; the reversal is cheap
|
||||
if a future split warrants it. See alknet ADR-009 §"What this framework
|
||||
is NOT" — door type classifies reversal cost, not urgency.
|
||||
is NOT"; door type classifies reversal cost, not urgency.
|
||||
|
||||
## References
|
||||
|
||||
- alknet ADR-003 + Amendments 1 & 2 — crate decomposition rule (the
|
||||
- alknet ADR-003 + Amendments 1 & 2: crate decomposition rule (the
|
||||
single-crate placement preserves it; the local backend is in the same
|
||||
crate as the trait, not a separate crate that depends back)
|
||||
- alknet ADR-009 — door-type-as-deferral anti-pattern (this ADR's
|
||||
- alknet ADR-009: door-type-as-deferral anti-pattern (this ADR's
|
||||
two-way-door classification is reversal cost, not a deferral)
|
||||
- alknet ADR-017 — the adapter-location-map pattern (trait where types
|
||||
- alknet ADR-017: the adapter-location-map pattern (trait where types
|
||||
live, implementation where deps live) this ADR follows (the `local`
|
||||
feature is where the deps live)
|
||||
- [ADR-002](002-ttybackend-trait-and-ttyhandle.md) — the `TtyBackend`
|
||||
- [ADR-002](002-ttybackend-trait-and-ttyhandle.md), the `TtyBackend`
|
||||
trait this module implements
|
||||
- [ADR-001](001-wire-format-and-two-carriage.md) — the wire format the
|
||||
- [ADR-001](001-wire-format-and-two-carriage.md), the wire format the
|
||||
adapter pumps to/from this backend
|
||||
- [ADR-004](004-exit-code-on-control-chunk.md) — the exit-chunk
|
||||
- [ADR-004](004-exit-code-on-control-chunk.md): the exit-chunk
|
||||
ordering the local backend's waiter thread feeds
|
||||
- [ADR-005](005-backend-cleanup-on-session-cancel.md) — the cancel-
|
||||
- [ADR-005](005-backend-cleanup-on-session-cancel.md): the cancel-
|
||||
cleanup contract the local backend's `exit_code` future's `Drop`
|
||||
implements
|
||||
- OQ-46 — runner API surface (deferred(scope): mechanism in alktty,
|
||||
- OQ-46, runner API surface (deferred(scope): mechanism in alktty,
|
||||
policy is a downstream crate)
|
||||
- Spec: [tty-local.md](../tty-local.md)
|
||||
- Port origin: alknet ADR-054 at
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
## Status
|
||||
|
||||
Accepted (ported from alknet ADR-056 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, 054,
|
||||
055) is not ported into alktty's ADR range as a separate ADR — it is
|
||||
055) is not ported into alktty's ADR range as a separate ADR; it is
|
||||
ADR-001, 002, 003, 004 here. The alknet originals at
|
||||
`/workspace/@alkdev/alknet/docs/architecture/decisions/` remain
|
||||
authoritative for any ADR not yet ported.)
|
||||
@@ -17,31 +17,31 @@ A `alk/tty` session can be cancelled before the child process exits
|
||||
naturally. Three cancel paths exist (see `tty-adapter.md` §"Connection
|
||||
and Stream Lifecycle"):
|
||||
|
||||
1. **Connection drop** — the QUIC connection closes; all in-flight
|
||||
1. **Connection drop**: the QUIC connection closes; all in-flight
|
||||
sessions on it are cancelled.
|
||||
2. **Stream reset** — the client (or transport) resets the bidi stream
|
||||
2. **Stream reset**: the client (or transport) resets the bidi stream
|
||||
mid-session.
|
||||
3. **Task panic / adapter shutdown** — the session pump task exits
|
||||
3. **Task panic / adapter shutdown**: the session pump task exits
|
||||
without completing the normal exit-chunk sequence.
|
||||
|
||||
In all three, the adapter's per-session pump tasks are dropped (Rust
|
||||
`Drop`). The pump tasks hold the `TtyHandle`, whose fields — `stdin`,
|
||||
`stdout`, `stderr`, `exit_code`, `control` — are dropped too.
|
||||
`Drop`). The pump tasks hold the `TtyHandle`, whose fields (`stdin`,
|
||||
`stdout`, `stderr`, `exit_code`, `control`) are dropped too.
|
||||
|
||||
The problem the local-PTY POC surfaced: **`Drop` alone is not sufficient
|
||||
cleanup for a backend whose child may outlive the session.** The local
|
||||
backend's three std threads illustrate the gap:
|
||||
|
||||
- **Reader thread** — blocking `MasterPty::try_clone_reader()` reads.
|
||||
- **Reader thread** (blocking `MasterPty::try_clone_reader()` reads).
|
||||
On `mpsc::Sender::drop` (the stdout channel's sender side drops when
|
||||
the handle drops), the reader's next `blocking_send` returns a
|
||||
`SendError`; the thread exits. ✓ `Drop` works.
|
||||
- **Writer thread** — drains an `mpsc::Receiver<StdinCmd>`. On
|
||||
- **Writer thread** (drains an `mpsc::Receiver<StdinCmd>`). On
|
||||
`Receiver::drop`, the writer's `recv()` returns `None`; the thread
|
||||
drops the master writer (EOF to the slave's stdin) and exits. ✓
|
||||
`Drop` works.
|
||||
- **Waiter thread** — blocking wait (`Child::wait()`; on Unix now the
|
||||
`libc::waitpid` reap — ADR-004). This is a syscall that
|
||||
- **Waiter thread** (blocking wait; `Child::wait()`, on Unix now the
|
||||
`libc::waitpid` reap, ADR-004). This is a syscall that
|
||||
returns **only when the child is reaped**. It does not observe
|
||||
channel close. If the child ignores stdin EOF (a daemon, a
|
||||
long-lived process with no stdin reader, a process in an
|
||||
@@ -58,7 +58,7 @@ in it.**
|
||||
The earlier spec text (`tty-adapter.md` §"Connection and Stream
|
||||
Lifecycle" pre-this-ADR) asserted:
|
||||
|
||||
> No explicit cleanup is needed — `Drop` is the cleanup.
|
||||
> No explicit cleanup is needed: `Drop` is the cleanup.
|
||||
|
||||
That is wrong for the waiter thread and any backend in the same shape.
|
||||
This ADR corrects the claim and commits a cleanup contract that closes
|
||||
@@ -71,7 +71,7 @@ contract (ADR-002), not a backend-internal detail, for two reasons:
|
||||
|
||||
1. **The adapter depends on the property.** The adapter's session
|
||||
driver holds the `TtyHandle` and, on cancel, drops it. The adapter
|
||||
cannot itself call a backend-specific kill — it doesn't know the
|
||||
cannot itself call a backend-specific kill; it doesn't know the
|
||||
child's pid (the local backend owns it; the adapter never sees it).
|
||||
The kill must be wired into the backend's handle shape, specifically
|
||||
into the `exit_code` future the adapter drops on cancel. The
|
||||
@@ -90,9 +90,9 @@ contract (ADR-002), not a backend-internal detail, for two reasons:
|
||||
### 1. The `TtyBackend` cleanup contract: cancelling the `exit_code` future kills the session target
|
||||
|
||||
When the adapter cancels a session (drops the pump tasks, which drops
|
||||
the `TtyHandle`), the backend's `exit_code` future — the
|
||||
`BoxFuture<'static, Result<i32, TtyError>>` field of `TtyHandle`
|
||||
(ADR-002) — is dropped *without being driven to completion*. The
|
||||
the `TtyHandle`), the backend's `exit_code` future (the
|
||||
`BoxFuture<'static, Result<i32, TtyError>>` field of `TtyHandle`,
|
||||
ADR-002) is dropped *without being driven to completion*. The
|
||||
cleanup contract:
|
||||
|
||||
> **Dropping the `exit_code` future MUST kill the session target (the
|
||||
@@ -104,7 +104,7 @@ cleanup contract:
|
||||
> whose process ignores channel close).
|
||||
|
||||
`exit_code`'s `Drop` is the cancel path. The adapter drives the future
|
||||
to completion (the happy path — the child exits, the future resolves,
|
||||
to completion (the happy path: the child exits, the future resolves,
|
||||
the adapter sends the exit chunk); on cancel, the adapter drops the
|
||||
future (their `Drop`), which runs the kill.
|
||||
|
||||
@@ -118,8 +118,8 @@ contract is backend-agnostic.
|
||||
|
||||
If the future resolved normally (the adapter awaited it, got the exit
|
||||
code, sent the exit chunk), the `Drop` runs on an already-resolved
|
||||
future. The kill MUST be a no-op in that case — the child is already
|
||||
reaped, the kill is delivered to a nonexistent pid, etc. This is the
|
||||
future. The kill MUST be a no-op in that case (the child is already
|
||||
reaped, the kill is delivered to a nonexistent pid, etc.). This is the
|
||||
"best-effort" qualifier: a kill on an already-exited child is not an
|
||||
error. Backends implement this with a guard that distinguishes
|
||||
"resolved" from "cancelled" (a flag, an `Option` taken on resolve, an
|
||||
@@ -128,7 +128,7 @@ error. Backends implement this with a guard that distinguishes
|
||||
### 3. Local backend mechanism: `ChildKiller` held in the `exit_code` future's `Drop` guard
|
||||
|
||||
The local backend (ADR-003) implements the contract using
|
||||
`portable_pty::ChildKiller` — the kill handle `portable_pty` exposes
|
||||
`portable_pty::ChildKiller`, the kill handle `portable_pty` exposes
|
||||
alongside `Child::wait()`. The pattern:
|
||||
|
||||
- `allocate()` spawns the child via `portable_pty`, obtaining a `Child`
|
||||
@@ -139,7 +139,7 @@ alongside `Child::wait()`. The pattern:
|
||||
- The `exit_code` future's `poll` delegates to the inner
|
||||
`oneshot::Receiver::poll` (resolves when the waiter thread sends the
|
||||
exit code).
|
||||
- The `exit_code` future's `Drop` (runs on cancel only — on resolve,
|
||||
- The `exit_code` future's `Drop` (runs on cancel only; on resolve,
|
||||
the `Drop` is a no-op via the guard) calls `ChildKiller::kill(SIGHUP)`
|
||||
(or the backend's configured cancel signal). The kill causes the
|
||||
child to exit; the waiter thread's blocking wait returns; the waiter
|
||||
@@ -155,13 +155,13 @@ the waiter task).
|
||||
|
||||
### 4. Future backends (docker, SSH) follow the same contract
|
||||
|
||||
- **Docker (`DockerTtyBackend`)** — `bollard`'s exec stream is
|
||||
- **Docker (`DockerTtyBackend`)**: `bollard`'s exec stream is
|
||||
cancelled by dropping the `AttachContainer` / `start_exec` stream
|
||||
and calling `bollard::container::kill_container` (or `exec::kill_exec`
|
||||
if available). The `exit_code` future's `Drop` holds the
|
||||
container/exec id and the `bollard::Docker` client; on cancel, it
|
||||
issues the kill.
|
||||
- **SSH (`SshTtyBackend`)** — russh's `Channel::close()` and/or
|
||||
- **SSH (`SshTtyBackend`)**: russh's `Channel::close()` and/or
|
||||
`Channel::signal(SIGHUP)` terminate the remote process. The
|
||||
`exit_code` future's `Drop` holds the russh channel handle; on
|
||||
cancel, it closes the channel.
|
||||
@@ -169,23 +169,23 @@ the waiter task).
|
||||
The docker and SSH backends are future work (out of scope for this
|
||||
spec set); the contract is what they implement. A future backend that
|
||||
does NOT fit the contract (e.g., a "recorded session replay" backend
|
||||
with no live process) implements a no-op `Drop`-on-cancel — the
|
||||
contract is "kill if there is a killable target; no-op if not."
|
||||
with no live process) implements a no-op `Drop`-on-cancel (the
|
||||
contract is "kill if there is a killable target; no-op if not.").
|
||||
|
||||
### 5. The adapter does not call a backend kill method
|
||||
|
||||
The adapter has no `TtyBackend::cancel()` or `TtyHandle::kill()` method
|
||||
to call — the cleanup is wired into the `exit_code` future's `Drop`,
|
||||
to call; the cleanup is wired into the `exit_code` future's `Drop`,
|
||||
which the adapter triggers by dropping the future. This keeps the
|
||||
trait surface unchanged (no new method) and the cleanup in the backend
|
||||
(where the kill handle lives). The adapter's only responsibility is to
|
||||
drop the `TtyHandle` (and therefore the `exit_code` future) when the
|
||||
session is cancelled — which it already does by virtue of dropping the
|
||||
session is cancelled, which it already does by virtue of dropping the
|
||||
pump tasks.
|
||||
|
||||
The `TtyControl::signal("HUP")` path (ADR-002) is the
|
||||
*client-initiated* signal forwarding path — a client sends a
|
||||
`{"type":"signal","name":"HUP"}` control chunk. It is NOT the
|
||||
*client-initiated* signal forwarding path (a client sends a
|
||||
`{"type":"signal","name":"HUP"}` control chunk). It is NOT the
|
||||
cancel-cleanup path. The cancel-cleanup path is server-internal (the
|
||||
adapter drops the handle) and does not involve the wire format. These
|
||||
are two different signal paths; both end in the child receiving SIGHUP
|
||||
@@ -199,18 +199,18 @@ different actors (client vs. server cancel).
|
||||
- A backend that conforms to the contract cannot orphan a process on
|
||||
cancel. The local-PTY POC's waiter-thread gap is closed at the
|
||||
contract level, not left to each backend to rediscover.
|
||||
- The cleanup is idiomatic Rust — `Drop`-on-cancel of a `Future` is
|
||||
- The cleanup is idiomatic Rust: `Drop`-on-cancel of a `Future` is
|
||||
the standard pattern for resource cleanup in async Rust (the same
|
||||
pattern `tokio::process::Child` uses; the same pattern `tokio::io`
|
||||
AsyncRead guards use). No new trait method; no adapter-side kill
|
||||
call.
|
||||
- The contract is backend-agnostic — the mechanism (`ChildKiller` for
|
||||
- The contract is backend-agnostic: the mechanism (`ChildKiller` for
|
||||
local, `kill_container` for docker, `channel::close` for SSH) lives
|
||||
in the backend; the contract ("drop the future, the target dies")
|
||||
lives at the seam.
|
||||
- The happy path (the child exits, the adapter drives `exit_code` to
|
||||
completion, sends the exit chunk, then drops the resolved future) is
|
||||
unaffected — the `Drop`-on-resolve is a no-op via the guard.
|
||||
unaffected (the `Drop`-on-resolve is a no-op via the guard).
|
||||
|
||||
**Negative:**
|
||||
|
||||
@@ -240,8 +240,8 @@ different actors (client vs. server cancel).
|
||||
**One-way.** The cleanup contract is part of the `TtyBackend`
|
||||
behavioral contract. Clients (the adapter) depend on "drop the handle,
|
||||
the session is cleaned up." Changing the contract after backends exist
|
||||
— e.g., adding a separate `TtyBackend::cancel()` method and migrating
|
||||
the cleanup out of `exit_code`'s `Drop` — would require every backend
|
||||
(for example, adding a separate `TtyBackend::cancel()` method and migrating
|
||||
the cleanup out of `exit_code`'s `Drop`) would require every backend
|
||||
to change. The `exit_code`-future-`Drop`-on-cancel mechanism is the
|
||||
seam.
|
||||
|
||||
@@ -264,33 +264,33 @@ seam.
|
||||
exits; the waiter thread's blocking wait returns and reaps it (no
|
||||
zombie). The waiter thread then exits. The `oneshot::send` from the
|
||||
waiter thread fails silently (the receiver was dropped with the
|
||||
future) — this is expected and not an error.
|
||||
future), which is expected and not an error.
|
||||
|
||||
## References
|
||||
|
||||
- [tty-adapter.md](../tty-adapter.md) §"Connection and Stream
|
||||
Lifecycle" — the cancel paths (connection drop, stream reset) that
|
||||
Lifecycle": the cancel paths (connection drop, stream reset) that
|
||||
trigger the contract
|
||||
- [tty-local.md](../tty-local.md) §"Cancel-Cleanup (ADR-005)" — the
|
||||
- [tty-local.md](../tty-local.md) §"Cancel-Cleanup (ADR-005)": the
|
||||
local backend's three-thread bridge and the waiter-thread gap this
|
||||
ADR closes
|
||||
- [ADR-001](001-wire-format-and-two-carriage.md) — the wire format the
|
||||
- [ADR-001](001-wire-format-and-two-carriage.md): the wire format the
|
||||
cancel does not involve (the cleanup is server-internal)
|
||||
- [ADR-002](002-ttybackend-trait-and-ttyhandle.md) — the `TtyBackend`
|
||||
- [ADR-002](002-ttybackend-trait-and-ttyhandle.md): the `TtyBackend`
|
||||
trait this contract is part of; the `exit_code` field the cleanup
|
||||
wires into; the `TtyControl::signal()` path the cancel-cleanup path
|
||||
is distinct from
|
||||
- [ADR-003](003-local-backend-placement.md) — the local backend this
|
||||
- [ADR-003](003-local-backend-placement.md): the local backend this
|
||||
ADR's reference mechanism (`ChildKiller`) is for
|
||||
- [ADR-004](004-exit-code-on-control-chunk.md) — the happy-path
|
||||
- [ADR-004](004-exit-code-on-control-chunk.md): the happy-path
|
||||
exit-chunk sequence (the cancel path bypasses it; no exit chunk is
|
||||
sent on cancel — see `tty-adapter.md` §"Stream reset")
|
||||
- `src/local/pty.rs` — the local backend's `LocalExitFuture` (the
|
||||
sent on cancel, see `tty-adapter.md` §"Stream reset")
|
||||
- `src/local/pty.rs`: the local backend's `LocalExitFuture` (the
|
||||
`Future` + `Drop` guard this ADR specifies, with the `ChildKiller`
|
||||
held in the guard)
|
||||
- `src/local/pipe.rs` — the pipe-mode equivalent
|
||||
- `src/local/pipe.rs`: the pipe-mode equivalent
|
||||
(`tokio::process::Child::start_kill()` on `Drop`-on-cancel)
|
||||
- `portable-pty` 0.9 `ChildKiller` — the kill handle the local
|
||||
- `portable-pty` 0.9 `ChildKiller`: the kill handle the local
|
||||
backend's cancel-cleanup uses
|
||||
- Spec: [tty-backend.md](../tty-backend.md),
|
||||
[tty-adapter.md](../tty-adapter.md),
|
||||
|
||||
@@ -2,32 +2,33 @@
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (amends alknet ADR-071 — wire format is 8 bytes, not 9, and
|
||||
Accepted (amends alknet ADR-071: wire format is 8 bytes, not 9, and
|
||||
the channels layer has no `stream_type` concept; amends alknet ADR-074
|
||||
— `into_sub_streams()` removed, `accept_bi` is the only accessor and
|
||||
yields one `BiStream` per channel; reverses [ADR-007](007-tty-inside-channels.md)
|
||||
— TTY always uses its 5-byte format, the channels layer carries it
|
||||
transparently in the payload. Ported from alknet ADR-093 2026-08-17;
|
||||
cross-references 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 ADRs referenced by alknet number
|
||||
(052, 070, 071, 072, 073, 074, 075, 076, 078, 079, 080, 081, 092) are
|
||||
not ported into alktty's ADR range — they are alknet-core /
|
||||
alknet-channels ADRs. The alknet originals at
|
||||
(`into_sub_streams()` removed, `accept_bi` is the only accessor and
|
||||
yields one `BiStream` per channel); reverses
|
||||
[ADR-007](007-tty-inside-channels.md): TTY always uses its 5-byte
|
||||
format, the channels layer carries it transparently in the payload.
|
||||
Ported from alknet ADR-093 2026-08-17; cross-references 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 ADRs referenced by alknet number (052, 070, 071, 072, 073, 074,
|
||||
075, 076, 078, 079, 080, 081, 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.)
|
||||
|
||||
## Context
|
||||
|
||||
alknet ADR-071 committed the channels wire format as a 9-byte chunk
|
||||
header (`[channel_id:u32][stream_type:u8][length:u32]`) — a 4-byte
|
||||
header (`[channel_id:u32][stream_type:u8][length:u32]`), a 4-byte
|
||||
extension of TTY's 5-byte format, with `stream_type` carried in the
|
||||
channels header and decomposed into unidirectional halves (0/1/2 =
|
||||
data write/read/err, 3/4/5 = control write/read/err, `% 3` formula).
|
||||
alknet ADR-074 added a second accessor (`into_sub_streams()`)
|
||||
alongside `accept_bi` for handlers that need typed sub-streams (TTY's
|
||||
stdin/stdout/stderr/ctrl-in/ctrl-out). [ADR-007](007-tty-inside-channels.md)
|
||||
split TTY's wire format into two modes — direct (5-byte) and
|
||||
split TTY's wire format into two modes: direct (5-byte) and
|
||||
inside-channels (the channels layer de-chunks and the adapter
|
||||
destructures via `into_sub_streams()`).
|
||||
|
||||
@@ -39,18 +40,18 @@ because the channels layer reassembles per-`stream_type` and needs to
|
||||
expose the result. The two-mode TTY design exists because the channels
|
||||
layer's `stream_type` overlaps with TTY's own `stream_type`. The mod
|
||||
2/mod 3/mod 4 numbering question (settled as mod 3 in alknet ADR-071
|
||||
revised) was a symptom of this overlap — a numbering convention for a
|
||||
revised) was a symptom of this overlap: a numbering convention for a
|
||||
concept the channels layer shouldn't carry.
|
||||
|
||||
### The structural question
|
||||
|
||||
The channels layer has two objectives in tension:
|
||||
|
||||
1. **"Pass a stream to/from any ALPN"** — every channel is a
|
||||
1. **"Pass a stream to/from any ALPN"**: every channel is a
|
||||
`BiStream`; any handler gets `accept_bi()` and treats the channel
|
||||
as a duplex stream. Uniform, transport-agnostic,
|
||||
recursive-composition-friendly.
|
||||
2. **"Channels carry N sub-streams"** — a TTY channel carries
|
||||
2. **"Channels carry N sub-streams"**: a TTY channel carries
|
||||
stdin/stdout/stderr/control; the handler destructures via
|
||||
`into_sub_streams()`. Carries what the source produces.
|
||||
|
||||
@@ -61,7 +62,7 @@ symmetry; you can't make the channel a single `BiStream` without
|
||||
losing the stdout/stderr distinction.
|
||||
|
||||
alknet ADR-074's two-accessor design resolves this by making the "pass
|
||||
a stream to/from any ALPN" objective *qualified* — it applies to
|
||||
a stream to/from any ALPN" objective *qualified*: it applies to
|
||||
single-stream channels (tunnel, SSH, call), not multi-stream channels
|
||||
(TTY). The mod 2/mod 3/mod 4 numbering was a symptom of that qualified
|
||||
design.
|
||||
@@ -82,19 +83,19 @@ sub-multiplexing on the `BiStream` the channels layer gives them.
|
||||
SSH runs its own channel protocol. The channels layer carries the
|
||||
bytes transparently.
|
||||
- **The mod 2/mod 3/mod 4 question dissolves at the channels layer.**
|
||||
The channels layer has no `stream_type` concept — not in its header,
|
||||
The channels layer has no `stream_type` concept: not in its header,
|
||||
not in its code, not in its mental model. `stream_type` is the inner
|
||||
layer's framing byte, carried transparently.
|
||||
- **The control channel is handler-internal.** TTY sub-demuxes control
|
||||
from its io `BiStream` using its 5-byte format (`STREAM_CTRL_IN = 3`,
|
||||
`STREAM_CTRL_OUT = 4` — ADR-001 amended by Phase 7). The channels
|
||||
`STREAM_CTRL_OUT = 4`; ADR-001 amended by Phase 7). The channels
|
||||
layer doesn't carry control. The "control isn't actually
|
||||
bidirectional" flaw is fixed at the TTY layer, not the channels
|
||||
layer.
|
||||
- **Recursive composition is literal.** A channel with ALPN
|
||||
`alk/channels` runs another channels demux on its `BiStream`. The
|
||||
outer layer strips its 8-byte header; the inner layer parses its
|
||||
own 8-byte header from the payload. Each level is the same shape —
|
||||
own 8-byte header from the payload. Each level is the same shape:
|
||||
`BiStream → accept_bi → N BiStreams`.
|
||||
|
||||
### The wire format decision: 8 bytes
|
||||
@@ -109,7 +110,7 @@ was considered and rejected. The 9-byte format puts `stream_type` in the
|
||||
channels header, which means the channels layer carries a concept it
|
||||
doesn't own. For TTY this composes cleanly (the 9-byte header is TTY's
|
||||
5-byte header with `channel_id` prepended), but for non-TTY handlers
|
||||
(tunnel, call, SSH) the `stream_type` byte is dead weight — the
|
||||
(tunnel, call, SSH) the `stream_type` byte is dead weight: the
|
||||
channels layer carries a byte it doesn't understand, and the handler
|
||||
ignores a byte in a header it doesn't control.
|
||||
|
||||
@@ -133,11 +134,11 @@ payload). The handler layer (e.g. TTY) parses its own framing from the
|
||||
payload bytes per its existing `wire.rs`. The handler doesn't know or
|
||||
care that a `channel_id` was stripped before it saw the bytes.
|
||||
|
||||
The composition is uniform — the same shape at every level. This is
|
||||
The composition is uniform: the same shape at every level. This is
|
||||
SSH's model (layered headers, each layer strips its own at its
|
||||
boundary), applied to channels. A `alk/channels`-inside-`alk/channels`
|
||||
recursive composition is the outer layer stripping its 8-byte header,
|
||||
the inner layer parsing its own 8-byte header from the payload — same
|
||||
the inner layer parsing its own 8-byte header from the payload; same
|
||||
code, same shape, each level.
|
||||
|
||||
### Why this can land now
|
||||
@@ -150,13 +151,13 @@ Three things changed since alknet ADR-071/074/077 were accepted:
|
||||
The join moves into core's quinn/iroh/stream impls (once per
|
||||
source, invisible to handlers). This ADR's "every channel is a
|
||||
`BiStream`" is the channels-layer consequence of alknet ADR-092's
|
||||
handler-leaf decision — the research-then-sync pattern applied:
|
||||
handler-leaf decision (the research-then-sync pattern applied:
|
||||
alknet ADR-092 settled the transport leaf, this ADR settles the
|
||||
multiplexing layer above it.
|
||||
multiplexing layer above it).
|
||||
2. **Phase 7 fixed the TTY control channel at the TTY layer.** The
|
||||
`STREAM_CONTROL = 3` "bidirectional" flaw is fixed by splitting it
|
||||
into `STREAM_CTRL_IN = 3` / `STREAM_CTRL_OUT = 4` — *inside TTY's
|
||||
5-byte format*, not at the channels layer. This removed the
|
||||
into `STREAM_CTRL_IN = 3` / `STREAM_CTRL_OUT = 4`, *inside TTY's
|
||||
5-byte format*, not at the channels layer. This removed the
|
||||
load-bearing reason for the channels layer to carry `stream_type`:
|
||||
the control bidirectionality fix is a TTY-internal concern, not a
|
||||
channels-layer concern. ADR-007's two-mode TTY design was motivated
|
||||
@@ -175,19 +176,19 @@ Three things changed since alknet ADR-071/074/077 were accepted:
|
||||
- **The add/strip API shape** (built into read/write vs. a separate
|
||||
utility): the stream-unification research proposed `add_channel_id`
|
||||
/ `strip_channel_id` as standalone functions. Ideally the header is
|
||||
built into the read/write path so the utility isn't needed at the
|
||||
handler boundary — but there may be a generalized reason to expose
|
||||
it (recursive composition, test helpers, the hub relay's
|
||||
`channel_id` rewrite). The exact API shape is an implementation
|
||||
detail for the channels crate. The *contract* — the channels layer
|
||||
strips its 8-byte header on read and the handler parses its own
|
||||
framing from the payload — is decided here; the *function surface*
|
||||
is not.
|
||||
built into the read/write path so the utility isn't needed at the
|
||||
handler boundary, but there may be a generalized reason to expose
|
||||
it (recursive composition, test helpers, the hub relay's
|
||||
`channel_id` rewrite). The exact API shape is an implementation
|
||||
detail for the channels crate. The *contract* (the channels layer
|
||||
strips its 8-byte header on read and the handler parses its own
|
||||
framing from the payload) is decided here; the *function surface*
|
||||
is not.
|
||||
- **TTY's `wire.rs` adaptation:** TTY's `ChunkReader` currently reads
|
||||
from an `AsyncRead`. Adapting it to read from a payload buffer
|
||||
(`&[u8]` or `Cursor<Bytes>`) is a small, well-scoped change (the
|
||||
framing logic — stream_type constants, length validation, control
|
||||
message parsing — is unchanged). This is an implementation concern
|
||||
(`&[u8]` or `Cursor<Bytes>`) is a small, well-scoped change; the
|
||||
framing logic (stream_type constants, length validation, control
|
||||
message parsing) is unchanged. This is an implementation concern
|
||||
for the channels + TTY integration, not an architecture decision.
|
||||
In alktty, `drive_session` already runs against a `BiStream` whose
|
||||
`AsyncRead` yields the post-channels-strip payload bytes; the same
|
||||
@@ -212,11 +213,11 @@ handler's framing, carried transparently.
|
||||
| field | offset | width | meaning |
|
||||
|-------|--------|-------|---------|
|
||||
| `channel_id` | 0 | 4 (BE) | The logical channel this chunk belongs to. Channel 0 is pre-negotiated as `alk/call` (alknet ADR-072). Channels 1..N are opened dynamically via `channel/open` (alknet ADR-073). |
|
||||
| `length` | 4 | 4 (BE) | The payload length in bytes. 0 = EOF sentinel. Max `MAX_CHUNK_LEN` (16 MiB, matching TTY's cap — ADR-001 §5). |
|
||||
| `length` | 4 | 4 (BE) | The payload length in bytes. 0 = EOF sentinel. Max `MAX_CHUNK_LEN` (16 MiB, matching TTY's cap; ADR-001 §5). |
|
||||
|
||||
The `stream_type` byte is **removed** from the channels header. The
|
||||
channels layer has no `stream_type` concept — not in its header, not in
|
||||
its code, not in its mental model. What was the channels header's
|
||||
channels layer has no `stream_type` concept (nothing in its header,
|
||||
nothing in its code, nothing in its mental model). What was the channels header's
|
||||
`stream_type` byte is now the first byte of the payload, owned by the
|
||||
handler's framing (TTY's 5-byte format, call's length-prefixed JSON,
|
||||
tunnel's raw bytes, SSH's channel protocol).
|
||||
@@ -232,7 +233,7 @@ layer carries transparently.
|
||||
alknet ADR-074's `into_sub_streams()` / `ChannelSubStreams` /
|
||||
`SubStreamHandle` are removed. The channels layer exposes one accessor:
|
||||
`accept_bi()`, which yields one `BiStream` per channel (per alknet
|
||||
ADR-092). Every handler — TTY, tunnel, SSH, call — receives a
|
||||
ADR-092). Every handler (TTY, tunnel, SSH, call) receives a
|
||||
`Connection`, calls `accept_bi()` once, gets a `BiStream`, and
|
||||
sub-multiplexes it however it wants.
|
||||
|
||||
@@ -240,7 +241,7 @@ This amends alknet ADR-074: the two-accessor design (`accept_bi` for
|
||||
generic handlers, `into_sub_streams` for typed handlers) collapses to
|
||||
one accessor. The "typed handler path" (alknet ADR-074's motivating
|
||||
case for TTY) is replaced by TTY sub-demuxing its `BiStream` via its
|
||||
own 5-byte format — the same code TTY runs in direct mode. alknet
|
||||
own 5-byte format, the same code TTY runs in direct mode. alknet
|
||||
ADR-074's yield-once `accept_bi` contract is preserved; the
|
||||
`into_sub_streams()` accessor is the amended part.
|
||||
|
||||
@@ -257,16 +258,16 @@ same `wire.rs` code runs in both modes.
|
||||
When TTY is inside channels, the channels layer strips its 8-byte
|
||||
header 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 leaked
|
||||
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`).
|
||||
|
||||
This reverses ADR-007: the 5-byte format is NOT scoped to direct —
|
||||
This reverses ADR-007: the 5-byte format is NOT scoped to direct;
|
||||
it's TTY's internal format, carried transparently in the channels
|
||||
payload. The `channels` feature on alktty (if 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
|
||||
TTY's sub-demux on a channels-backed `BiStream`" (the same code as
|
||||
direct mode, 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.
|
||||
|
||||
@@ -291,65 +292,65 @@ TTY inside channels:
|
||||
(8 bytes) carried as channels payload
|
||||
```
|
||||
|
||||
The composition is uniform — the same shape at every level. A
|
||||
The composition is uniform: the same shape at every level. A
|
||||
`alk/channels`-inside-`alk/channels` recursive composition is the
|
||||
outer layer stripping its 8-byte header, the inner layer parsing its
|
||||
own 8-byte header from the payload — same code, same shape, each
|
||||
own 8-byte header from the payload; same code, same shape, each
|
||||
level.
|
||||
|
||||
### 5. What does NOT change
|
||||
|
||||
- **alknet ADR-092's `BiStream` leaf** — unchanged. This ADR is the
|
||||
- **alknet ADR-092's `BiStream` leaf** is unchanged. This ADR is the
|
||||
channels-layer consequence of alknet ADR-092: `accept_bi` yields a
|
||||
`BiStream`, handlers sub-multiplex it. The two ADRs compose (alknet
|
||||
ADR-092 settles the transport leaf; this ADR settles the
|
||||
multiplexing layer above it).
|
||||
- **`ProtocolHandler` trait shape** (alknet ADR-002) — unchanged.
|
||||
- **`ProtocolHandler` trait shape** (alknet ADR-002): unchanged.
|
||||
Handlers receive a `Connection` and call `accept_bi()`.
|
||||
- **Channel 0 pre-negotiated as `alk/call`** (alknet ADR-072) —
|
||||
- **Channel 0 pre-negotiated as `alk/call`** (alknet ADR-072):
|
||||
unchanged. Channel 0's chunks have `channel_id = 0` in the 8-byte
|
||||
header. The call protocol's `EventEnvelope` framing is the payload;
|
||||
the channels layer carries it transparently.
|
||||
- **Channel lifecycle operations** (alknet ADR-073) — unchanged. The
|
||||
- **Channel lifecycle operations** (alknet ADR-073): unchanged. The
|
||||
four operations (`channel/open`/`close`/`control`/`resources/subscribe`)
|
||||
and their `direction` semantics are call-protocol operations on
|
||||
channel 0, not channels-wire-format concerns.
|
||||
- **`ChannelsAdapter` / `ChannelManager` split** (alknet ADR-075) —
|
||||
- **`ChannelsAdapter` / `ChannelManager` split** (alknet ADR-075):
|
||||
structurally unchanged. The demux loop reads 8-byte headers (not
|
||||
9-byte); the `ChannelManager` is ALPN-blind, auth-blind,
|
||||
transport-blind. The `stream_types` field on `channel/open` and
|
||||
`ChannelState` is removed (the channels layer doesn't track
|
||||
per-stream-type reassembly buffers; it tracks one reassembly buffer
|
||||
per `channel_id`, yielding a `BiStream`).
|
||||
- **Backpressure, channel limits, ID reuse** (alknet ADR-076) —
|
||||
- **Backpressure, channel limits, ID reuse** (alknet ADR-076):
|
||||
unchanged. The bounded-buffer backpressure is per-`channel_id` (was
|
||||
per-`(channel_id, stream_type)`; now per-`channel_id` since there's
|
||||
one reassembly buffer per channel). The 256-channel cap, 1 MiB
|
||||
default, and monotonic-ID-with-wrap strategy are unchanged.
|
||||
- **Two-pump shutdown-on-completion** (alknet ADR-078) — unchanged.
|
||||
- **Two-pump shutdown-on-completion** (alknet ADR-078): unchanged.
|
||||
Tunnel/SSH handlers call `tokio::io::split(bidi)` for their two pump
|
||||
halves; the shutdown-on-completion contract applies to the
|
||||
`ReadHalf` / `WriteHalf` unchanged.
|
||||
- **Hub relay** (alknet ADR-079) — unchanged in contract. The hub
|
||||
- **Hub relay** (alknet ADR-079): unchanged in contract. The hub
|
||||
translates `channel/open` on channel 0 and byte-forwards data
|
||||
channels with `channel_id` rewrite. The relay reads 8-byte headers
|
||||
(not 9-byte) and rewrites the `channel_id` field (a 4-byte rewrite
|
||||
within the 8-byte header, not a 9-byte header). The relay does not
|
||||
parse the payload.
|
||||
- **`ChannelClient`** (alknet ADR-080) — unchanged in API.
|
||||
- **`ChannelClient`** (alknet ADR-080): unchanged in API.
|
||||
`from_connection` primary, `open_channel` returns a `Channel`. The
|
||||
`stream_types` field on `open_channel` and `Channel` is removed (the
|
||||
channels layer doesn't negotiate per-stream-type sets; the handler
|
||||
owns its sub-stream multiplexing). The
|
||||
`channel:stream_type_unavailable` error code is removed (the
|
||||
channels layer can't refuse a `stream_type` it doesn't know about).
|
||||
- **Sub-crate decomposition** (alknet ADR-081) — unchanged.
|
||||
- **Sub-crate decomposition** (alknet ADR-081): unchanged.
|
||||
`channels-core` (pure multiplexer, depends on alknet-core only) /
|
||||
`channels-call` (channel 0 pre-negotiation + lifecycle op
|
||||
registrations, depends on `channels-core` + alknet-call). The 8-byte
|
||||
wire format, demux/mux, and `ChannelBidiStreamSource` are in
|
||||
`channels-core`; the call-protocol coupling is in `channels-call`.
|
||||
- **`BidiStreamSource` trait** (alknet ADR-070) — unchanged in shape.
|
||||
- **`BidiStreamSource` trait** (alknet ADR-070): unchanged in shape.
|
||||
`ChannelBidiStreamSource` implements it; `accept_bi` yields a
|
||||
`BiStream` (per alknet ADR-092, already landed).
|
||||
|
||||
@@ -358,7 +359,7 @@ level.
|
||||
**Positive:**
|
||||
|
||||
- **Clean separation of concerns.** The channels layer has no
|
||||
`stream_type` concept — not in its header, not in its code, not in
|
||||
`stream_type` concept: not in its header, not in its code, not in
|
||||
its mental model. The handler owns its framing entirely. This
|
||||
dissolves the mod 2/mod 3/mod 4 question at the channels layer
|
||||
(there's nothing to decompose) and fixes the "control isn't actually
|
||||
@@ -373,7 +374,7 @@ level.
|
||||
another channels demux on its `BiStream`. The outer layer strips its
|
||||
8-byte header; the inner layer parses its own 8-byte header from
|
||||
the payload. Same code, same shape, each level. This is a property,
|
||||
not a feature — the primary use case is one level of multiplexing,
|
||||
not a feature: the primary use case is one level of multiplexing,
|
||||
but the add/strip composition makes the recursion cleaner than
|
||||
alknet ADR-071's group framing did.
|
||||
- **The `into_sub_streams()` accessor and its consuming handler code
|
||||
@@ -392,7 +393,7 @@ level.
|
||||
top-level one. (In alktty as built, `drive_session` already takes a
|
||||
generic `AsyncRead + AsyncWrite` pair, so both the direct
|
||||
`TtyAdapter::handle` path and the channels `TtyOpenHandler` path
|
||||
call the same function — see `src/adapter.rs` and
|
||||
call the same function (see `src/adapter.rs` and
|
||||
`src/channels.rs`.)
|
||||
- **The channels layer is WASM-compatible by construction.** The
|
||||
8-byte header's core is pure byte manipulation (the sync core
|
||||
@@ -415,9 +416,9 @@ level.
|
||||
cause this ADR addresses.
|
||||
- **TTY's `wire.rs` needs a small adaptation.** `ChunkReader` currently
|
||||
reads from an `AsyncRead` (the transport stream). Inside channels,
|
||||
it reads from a payload buffer (`&[u8]` or `Cursor<Bytes>`) — the
|
||||
bytes the channels layer handed it after stripping its 8-byte
|
||||
header. The framing logic (stream_type constants, length validation,
|
||||
it reads from a payload buffer (`&[u8]` or `Cursor<Bytes>`), the
|
||||
bytes the channels layer handed it after stripping its 8-byte
|
||||
header. The framing logic (stream_type constants, length validation,
|
||||
control message parsing) is unchanged. This is a bounded,
|
||||
well-scoped implementation change, not an architecture change. The
|
||||
same adaptation applies to any handler that parses its own framing
|
||||
@@ -425,13 +426,13 @@ level.
|
||||
from a buffer; tunnel and SSH don't parse the payload, so no
|
||||
adaptation). In alktty as built, `drive_session` already reads from
|
||||
a `BiStream` whose `AsyncRead` yields the post-channels-strip
|
||||
payload bytes — the same `wire.rs` code runs unchanged in both
|
||||
modes.
|
||||
payload bytes; the same `wire.rs` code runs unchanged in both
|
||||
modes.
|
||||
- **`channel/open` loses the `stream_types` field.** alknet ADR-073's
|
||||
`channel/open` input included `stream_types: [u8]` (the active
|
||||
sub-stream set) and the response echoed the negotiated set. Under
|
||||
this ADR, the channels layer doesn't negotiate sub-stream sets —
|
||||
the handler owns its sub-stream multiplexing. The `stream_types`
|
||||
this ADR, the channels layer doesn't negotiate sub-stream sets; the
|
||||
handler owns its sub-stream multiplexing. The `stream_types`
|
||||
field is removed from `channel/open` (and from the
|
||||
`channel:stream_type_unavailable` error code). The `alpn` and
|
||||
`params` fields remain; the handler's sub-stream set is implicit in
|
||||
@@ -444,7 +445,7 @@ level.
|
||||
simplification (fewer reassembly buffers, simpler drain logic) but
|
||||
is an implementation change, not an architecture one. The
|
||||
bounded-buffer backpressure (alknet ADR-076) is per-`channel_id`
|
||||
now, not per-`(channel_id, stream_type)` — the 1 MiB default and
|
||||
now, not per-`(channel_id, stream_type)`: the 1 MiB default and
|
||||
the 256-channel cap are unchanged; the per-channel memory ceiling is
|
||||
1 MiB (was up to 5 MiB for a TTY channel with 5 active
|
||||
stream_types). This is a net improvement (lower memory ceiling per
|
||||
@@ -459,7 +460,7 @@ removal of `stream_type` from the channels header, and the removal of
|
||||
them after the channels crate is implemented and handlers are written
|
||||
against them requires a version migration. Since the channels crate
|
||||
doesn't exist yet, the one-way door is being cast now, before
|
||||
implementation — the right time to cast a one-way door.
|
||||
implementation, the right time to cast a one-way door.
|
||||
|
||||
The reversal of ADR-007 (TTY always uses its 5-byte format) is one-way
|
||||
in the same sense: once TTY's `wire.rs` runs in both modes (direct and
|
||||
@@ -467,53 +468,53 @@ inside-channels), re-introducing a separate inside-channels mode would
|
||||
be a rewrite of TTY's session driver. The trade is one unified session
|
||||
driver now vs. two-mode maintenance forever.
|
||||
|
||||
The add/strip API shape (alknet OQ-68) is a **two-way door** — whether
|
||||
The add/strip API shape (alknet OQ-68) is a **two-way door**: whether
|
||||
the header add/strip is built into the read/write path or exposed as a
|
||||
standalone utility is an implementation detail that can change without
|
||||
breaking the wire format or the handler contract.
|
||||
|
||||
## References
|
||||
|
||||
- alknet ADR-071: channels wire format (amended — wire format is 8
|
||||
- alknet ADR-071: channels wire format (amended: wire format is 8
|
||||
bytes, not 9; `stream_type` removed from the channels header; the
|
||||
stream_type decomposition is removed from the channels layer)
|
||||
- alknet ADR-074: ChannelBidiStreamSource (amended —
|
||||
- alknet ADR-074: ChannelBidiStreamSource (amended:
|
||||
`into_sub_streams()` removed; `accept_bi` is the only accessor,
|
||||
yields one `BiStream` per channel)
|
||||
- [ADR-007](007-tty-inside-channels.md): TTY inside channels (reversed
|
||||
— TTY always uses its 5-byte format; the channels layer carries it
|
||||
- [ADR-007](007-tty-inside-channels.md): TTY inside channels (reversed:
|
||||
TTY always uses its 5-byte format; the channels layer carries it
|
||||
transparently in the payload; the two-mode design is preserved but
|
||||
differs only in `BiStream` source, not in parsing)
|
||||
- alknet ADR-092: `BiStream` as the handler leaf (the transport-leaf
|
||||
layer this ADR builds on — `accept_bi` returns `BiStream`;
|
||||
layer this ADR builds on: `accept_bi` returns `BiStream`;
|
||||
`from_bidi` is the only public stream constructor)
|
||||
- alknet ADR-070: `BidiStreamSource` trait (the extension point
|
||||
`ChannelBidiStreamSource` implements; `accept_bi` yields `BiStream`)
|
||||
- alknet ADR-072: channel 0 pre-negotiated `alk/call` (unchanged —
|
||||
- alknet ADR-072: channel 0 pre-negotiated `alk/call` (unchanged:
|
||||
channel 0's chunks have `channel_id = 0` in the 8-byte header; the
|
||||
call protocol's framing is the payload)
|
||||
- alknet ADR-073: channel lifecycle operations (amended —
|
||||
- alknet ADR-073: channel lifecycle operations (amended:
|
||||
`stream_types` field removed from `channel/open`;
|
||||
`channel:stream_type_unavailable` error code removed)
|
||||
- alknet ADR-075: `ChannelsAdapter` and `ChannelManager` (structurally
|
||||
unchanged — demux reads 8-byte headers; one reassembly buffer per
|
||||
unchanged: demux reads 8-byte headers; one reassembly buffer per
|
||||
channel)
|
||||
- alknet ADR-076: backpressure, channel limits, ID reuse (unchanged —
|
||||
- alknet ADR-076: backpressure, channel limits, ID reuse (unchanged:
|
||||
bounded-buffer is per-`channel_id`; 256-channel cap, 1 MiB default,
|
||||
monotonic IDs)
|
||||
- alknet ADR-078: two-pump shutdown-on-completion (unchanged — the
|
||||
- alknet ADR-078: two-pump shutdown-on-completion (unchanged: the
|
||||
contract applies to `tokio::io::split(bidi)` halves)
|
||||
- alknet ADR-079: hub relay (unchanged in contract — 8-byte header,
|
||||
- alknet ADR-079: hub relay (unchanged in contract: 8-byte header,
|
||||
4-byte `channel_id` rewrite, payload byte-forwarded)
|
||||
- alknet ADR-080: `ChannelClient` (amended — `stream_types` field
|
||||
- alknet ADR-080: `ChannelClient` (amended: `stream_types` field
|
||||
removed from `open_channel` and `Channel`)
|
||||
- alknet ADR-081: sub-crate decomposition (unchanged — 8-byte wire
|
||||
- alknet ADR-081: sub-crate decomposition (unchanged: 8-byte wire
|
||||
format in `channels-core`; call-protocol coupling in
|
||||
`channels-call`)
|
||||
- [ADR-001](001-wire-format-and-two-carriage.md): alktty wire format
|
||||
(the 5-byte format carried transparently in the channels payload;
|
||||
the control channel split from Phase 7 is TTY-internal)
|
||||
- `src/channels.rs` — alktty's channels integration (the
|
||||
- `src/channels.rs`: alktty's channels integration (the
|
||||
`register_openable` helper + `TtyOpenHandler` that runs
|
||||
`drive_session` on a channels-backed `BiStream`, the same code as
|
||||
the direct path)
|
||||
|
||||
@@ -8,22 +8,22 @@ Accepted (2026-09-05). Resolves review #001 L1. Prerequisites: alkcall
|
||||
|
||||
Amended 2026-09-05 (review #002 R4): a `NegotiateRequest` parse failure
|
||||
of the schema-validated `input` is now a client-visible
|
||||
`malformed_negotiation` error frame, not a silent teardown — see
|
||||
`malformed_negotiation` error frame, not a silent teardown; see
|
||||
§"Parse-failure error frame (R4 amendment, 2026-09-05)".
|
||||
|
||||
Amended 2026-09-06 (ADR-010): with alkcall 0.5.0 (ADR-049 — review 006
|
||||
Amended 2026-09-06 (ADR-010): with alkcall 0.5.0 (ADR-049, review 006
|
||||
E-01), the semantic-failure classes the R4 amendment reported in-band
|
||||
(malformed negotiation, unknown backend, ownership denial) move into
|
||||
the producer's establisher and resolve as `channel:open_failed` call
|
||||
errors — no phantom channel. The in-band error-frame path on this
|
||||
errors; no phantom channel. The in-band error-frame path on this
|
||||
channel shrinks to one class: `allocate_failed` (the establisher
|
||||
cannot carry the allocated handle across — see ADR-010 §2). The R4
|
||||
cannot carry the allocated handle across; see ADR-010 §2). The R4
|
||||
tests that asserted the in-band frames for those classes now assert
|
||||
the call-error shape.
|
||||
|
||||
Amended 2026-09-07 (ADR-010 §2A, alkcall 0.6.0 — review 007 R-01):
|
||||
Amended 2026-09-07 (ADR-010 §2A, alkcall 0.6.0, review 007 R-01):
|
||||
`Establishment` gained its plan payload, so `allocate` also moved
|
||||
into the establisher — the in-band error-frame path on this channel
|
||||
into the establisher; the in-band error-frame path on this channel
|
||||
shrinks to nothing from a registered producer (the frames remain the
|
||||
defense-in-depth fallback for no-establisher registrations and the
|
||||
direct path). See ADR-010 §2A.
|
||||
@@ -34,7 +34,7 @@ Before this ADR, the channels path carried the negotiation twice. The
|
||||
consumer opened a channel via the `channels/tty/sub` open op, passing
|
||||
the `NegotiateRequest` params as the op's `input`; the producer-side
|
||||
open handler then ignored that `input` (`let _ = input;`) and read a
|
||||
second, full negotiation frame from the channel's data stream — the
|
||||
second, full negotiation frame from the channel's data stream, the
|
||||
same frame the direct-ALPN path reads. The registry validated `input`
|
||||
against the op's schema, but the backend selection actually came from
|
||||
the unvalidated wire frame. Two independent gates (the registry's
|
||||
@@ -45,19 +45,19 @@ divergent frame.
|
||||
Review #001 (L1) flagged this as a latent inconsistency and asked for
|
||||
a publisher decision: either drop the `input` parameter or pass it
|
||||
through as the authoritative negotiation. Investigation of alkcall
|
||||
found the review's premise wrong in an important way — alkcall (≤0.3.1)
|
||||
found the review's premise wrong in an important way: alkcall (≤0.3.1)
|
||||
never enforced `input_schema` on any dispatch path at all: the schema
|
||||
was advertise-only (metadata in discovery). The registry gate this
|
||||
design leaned on did not exist.
|
||||
|
||||
Two upstream changes landed to make the design sound:
|
||||
|
||||
1. **alkcall 0.4.0** — `OperationSpec.input_schema` is enforced at
|
||||
1. **alkcall 0.4.0**: `OperationSpec.input_schema` is enforced at
|
||||
call time by all three registry dispatch entry points (`invoke`,
|
||||
`invoke_streaming`, `invoke_sink`). The schema is compiled once at
|
||||
registration (fail-closed, the same rule as `publish_schema` /
|
||||
CF-003). Violations return `INVALID_INPUT` before the handler runs.
|
||||
2. **alkcall 0.4.1** — the demux parks early-arrival chunks for a
|
||||
2. **alkcall 0.4.1**: the demux parks early-arrival chunks for a
|
||||
not-yet-adopted channel (bounded FIFO, 64 payloads per channel) and
|
||||
`adopt_channel` drains them into the new receiver. Before this, the
|
||||
open-op-response / producer's-first-write race silently dropped the
|
||||
@@ -70,30 +70,30 @@ Two upstream changes landed to make the design sound:
|
||||
|
||||
The open op's registry-validated `input` **is** the negotiation
|
||||
(design 2 of the L1 decision). The channels path carries **no second
|
||||
negotiation frame** on the channel's data stream — the stream starts
|
||||
negotiation frame** on the channel's data stream; the stream starts
|
||||
directly in raw-chunk mode (TTY's 5-byte format, ADR-001/ADR-008).
|
||||
|
||||
### Producer side
|
||||
|
||||
`make_tty_open_handler` parses the open op's `input` into a
|
||||
`NegotiateRequest` and drives the new `drive_session_pre_negotiated`
|
||||
(`src/adapter.rs`) — the same three-pump session driver as the direct
|
||||
(`src/adapter.rs`), the same three-pump session driver as the direct
|
||||
path, minus the wire-frame negotiation phase. Validation is factored
|
||||
into `validate_and_allocate`, shared by both paths: `carriage ==
|
||||
"raw"`, non-empty `cmd`, backend lookup, the ADR-050 ownership check,
|
||||
and `backend.allocate`. On the channels path the `tty:open` scope gate
|
||||
is NOT re-checked in the handler — the registry's `AccessControl`
|
||||
is NOT re-checked in the handler; the registry's `AccessControl`
|
||||
enforced it before the handler ran (the identity the registry checked,
|
||||
resolved from the connection, is the authoritative one; the
|
||||
handler-side identity is only the ownership-check subject).
|
||||
|
||||
Post-open failures (unknown backend, `allocate_failed`, ownership
|
||||
denial) still go to the client as a `0x00`-prefixed negotiation error
|
||||
frame on the channel stream — the same framing the direct path uses —
|
||||
frame on the channel stream (the same framing the direct path uses),
|
||||
so the consumer's ADR-001 §5 disambiguation read applies unchanged.
|
||||
|
||||
A `NegotiateRequest` parse failure of the schema-validated `input`
|
||||
(the schema is deliberately partial — `carriage`/`backend`/`cmd`
|
||||
(the schema is deliberately partial: `carriage`/`backend`/`cmd`
|
||||
required, `tty`/`cwd`/`env`/backend-params free-form so the opaque
|
||||
ADR-053 params pass through) was originally a handler-side failure
|
||||
logged and returned without an error frame. The R4 amendment below
|
||||
@@ -106,7 +106,7 @@ failure.
|
||||
a `NegotiateRequest` locally first (fail-fast before a channel is
|
||||
allocated), opens the channel via `ChannelClient::open_channel`,
|
||||
builds the channel `Connection`, and starts raw-chunk mode directly
|
||||
(`from_halves_raw` — no negotiation frame is written). The
|
||||
(`from_halves_raw`; no negotiation frame is written). The
|
||||
`0x00`-error-frame peek is retained: the first byte of the producer's
|
||||
response disambiguates a post-open negotiation error frame (length
|
||||
prefix starting `0x00`) from a raw chunk (`stream_type` in `{1, 2, 4}`).
|
||||
@@ -132,9 +132,9 @@ client-visible through the same `0x00` error-frame peek
|
||||
(`from_halves_raw`):
|
||||
|
||||
1. **Semantic validation failures** (`carriage != "raw"`, empty `cmd`,
|
||||
unknown backend, `allocate_failed`, ownership denial) — error
|
||||
frames from `validate_and_allocate` (unchanged).
|
||||
2. **`NegotiateRequest` parse failure of schema-valid `input`** — the
|
||||
unknown backend, `allocate_failed`, ownership denial); error
|
||||
frames from `validate_and_allocate` (unchanged).
|
||||
2. **`NegotiateRequest` parse failure of schema-valid `input`**: the
|
||||
open handler accepts the channel's `BiStream` and writes a
|
||||
`malformed_negotiation` frame
|
||||
(`{"error":"malformed_negotiation","message":"..."}`) via the shared
|
||||
@@ -143,12 +143,12 @@ client-visible through the same `0x00` error-frame peek
|
||||
deliberately partial: e.g. `cwd` typed as a number passes the schema
|
||||
(unknown-key/type fields pass through for the opaque ADR-053
|
||||
params) but fails the typed parse.
|
||||
3. **Schema-invalid `input`** — rejected at dispatch by the registry
|
||||
3. **Schema-invalid `input`**: rejected at dispatch by the registry
|
||||
(alkcall 0.4) before any handler runs: a `CallError` on the open
|
||||
op, no channel allocated, no error frame (unchanged).
|
||||
|
||||
This replaces the original behavior (log + return, channel teardown,
|
||||
consumer observes `NoExitChunk` — indistinguishable from a crashed
|
||||
consumer observes `NoExitChunk`, indistinguishable from a crashed
|
||||
producer). The error-frame layout is unchanged (ADR-001's wire-stable
|
||||
contract); no new frame type, no new stream type. Tests: the
|
||||
`make_tty_open_handler` seam test (hand-built `input` bypassing the
|
||||
@@ -161,8 +161,8 @@ schema, `channels.rs::tests`) and the real-registry end-to-end test
|
||||
**Positive:**
|
||||
|
||||
- **One negotiation per session.** The registry's schema check and the
|
||||
semantic validation run on the same value — the two-gate
|
||||
disagreement is structurally impossible.
|
||||
semantic validation run on the same value; the two-gate
|
||||
disagreement is structurally impossible.
|
||||
- **Fewer bytes and one less round trip** on the channels path: no
|
||||
4-byte length prefix + JSON frame on the data stream, and the
|
||||
consumer needs no negotiation write before its first read.
|
||||
@@ -190,7 +190,7 @@ schema, `channels.rs::tests`) and the real-registry end-to-end test
|
||||
- **Consumer/producer version skew.** A consumer that writes the old
|
||||
second negotiation frame to a producer built on this ADR will have
|
||||
that frame parsed as a raw chunk (stream_type byte = first JSON
|
||||
byte, garbage length) — the session fails confusingly. Acceptable
|
||||
byte, garbage length), so the session fails confusingly. Acceptable
|
||||
pre-1.0 (no released channels-path consumers); the direct path is
|
||||
unaffected.
|
||||
|
||||
@@ -198,7 +198,7 @@ schema, `channels.rs::tests`) and the real-registry end-to-end test
|
||||
|
||||
**Two-way for now, one-way once a released consumer exists.** The
|
||||
channels-path negotiation location (open-op `input` vs. a wire frame)
|
||||
is reversible pre-1.0 — the `NegotiateRequest` shape, the 5-byte chunk
|
||||
is reversible pre-1.0: the `NegotiateRequest` shape, the 5-byte chunk
|
||||
format, and the error-frame layout (the wire-stable contracts) are
|
||||
untouched. Once a released consumer depends on "open op, then raw
|
||||
chunks," removing or re-adding the wire-frame negotiation on this path
|
||||
@@ -207,19 +207,19 @@ public API is additive; `drive_session` is unchanged.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR-001](001-wire-format-and-two-carriage.md) — the two-carriage
|
||||
- [ADR-001](001-wire-format-and-two-carriage.md), the two-carriage
|
||||
model (JSON negotiation + raw chunks) and the §5 framing
|
||||
disambiguation this ADR reuses for the post-open error frame
|
||||
- [ADR-002](002-ttybackend-trait-and-ttyhandle.md) — the backend
|
||||
- [ADR-002](002-ttybackend-trait-and-ttyhandle.md), the backend
|
||||
selection and allocation this ADR's validation runs
|
||||
- [ADR-006](006-negotiation-framing-self-contained.md) — the
|
||||
- [ADR-006](006-negotiation-framing-self-contained.md): the
|
||||
negotiation frame layout (unchanged; the direct path still uses it)
|
||||
- [ADR-008](008-channels-pure-channel-multiplexing.md) — the channels
|
||||
- [ADR-008](008-channels-pure-channel-multiplexing.md): the channels
|
||||
layer strips its 8-byte header transparently; TTY owns its framing
|
||||
(this ADR decides what TTY's framing on the channels path contains)
|
||||
- `docs/reviews/001-code-review.md` §"Resolution (2026-09-05, L1 + L3)"
|
||||
- `src/adapter.rs` — `drive_session`, `drive_session_pre_negotiated`,
|
||||
- `src/adapter.rs`: `drive_session`, `drive_session_pre_negotiated`,
|
||||
`validate_and_allocate`
|
||||
- `src/channels.rs` — `make_tty_open_handler`, `tty_open_spec`
|
||||
- `src/session.rs` — `open_via_channels`, `from_halves_raw`
|
||||
- `src/testing.rs` — the shared producer/consumer channels harness
|
||||
- `src/channels.rs`: `make_tty_open_handler`, `tty_open_spec`
|
||||
- `src/session.rs`: `open_via_channels`, `from_halves_raw`
|
||||
- `src/testing.rs`: the shared producer/consumer channels harness
|
||||
@@ -3,14 +3,14 @@
|
||||
## Status
|
||||
|
||||
Accepted (2026-09-06). Migrates the channels-path semantic-failure
|
||||
surface per alkcall 0.5.0 / ADR-049 (review 006 E-01 + N-1) — the
|
||||
sequencing ADR-049 §5 names ("alktty migration — channels-path
|
||||
surface per alkcall 0.5.0 / ADR-049 (review 006 E-01 + N-1): the
|
||||
sequencing ADR-049 §5 names ("alktty migration: channels-path
|
||||
semantic failures move into an establisher; the direct-ALPN in-band
|
||||
error frame is retained — two transports, two contracts").
|
||||
error frame is retained (two transports, two contracts)").
|
||||
Prerequisites: alkcall 0.5.0 (`ChannelCore::register_openable_with_establisher`,
|
||||
`channel:open_failed`, typed `ChannelOpenError`).
|
||||
|
||||
**Amended (2026-09-07, alkcall 0.6.0 / ADR-049 amendment 2 — review
|
||||
**Amended (2026-09-07, alkcall 0.6.0 / ADR-049 amendment 2, review
|
||||
007 R-01):** `Establishment` gained its plan payload
|
||||
(`ChannelPlan = Arc<dyn Any + Send + Sync>`), removing §2's
|
||||
payloadless-`Establishment` blocker. Backend `allocate` moves into
|
||||
@@ -35,7 +35,7 @@ semantic-failure classes had nowhere to go but the channel stream:
|
||||
the handler wrote a `0x00`-prefixed negotiation error frame
|
||||
(`malformed_negotiation`, `unknown_backend`, `allocate_failed`,
|
||||
ownership-denial `forbidden`) on a channel the open op had already
|
||||
reported as succeeding. The consumer observed success-then-frame —
|
||||
reported as succeeding. The consumer observed success-then-frame,
|
||||
a per-crate workaround ADR-049 explicitly retires:
|
||||
|
||||
> alktty's per-crate in-band error vocabulary is retired on the
|
||||
@@ -43,7 +43,8 @@ a per-crate workaround ADR-049 explicitly retires:
|
||||
> for free.
|
||||
|
||||
alkcall 0.5.0 implements the resolution: the wrapper awaits a bounded
|
||||
establisher (`OpenEstablisher` — `Fn(Value, AuthContext) -> BoxFuture<
|
||||
establisher (`OpenEstablisher`:
|
||||
`Fn(Value, AuthContext) -> BoxFuture<
|
||||
Result<Establishment, EstablishmentError>>`) between channel
|
||||
allocation and the open reply; on rejection it tears the channel down
|
||||
and replies `channel:open_failed` with `details: { reason, message }`
|
||||
@@ -60,19 +61,19 @@ branchable end-to-end.
|
||||
|
||||
`register_openable` (alktty's helper, signature unchanged) now calls
|
||||
`ChannelCore::register_openable_with_establisher` with
|
||||
`make_tty_establisher` — the establishment phase runs the semantic
|
||||
`make_tty_establisher`; the establishment phase runs the semantic
|
||||
validation that used to be post-open error frames:
|
||||
|
||||
- Full `NegotiateRequest` parse of the registry-schema-validated
|
||||
`input` (the schema stays deliberately partial — the opaque ADR-053
|
||||
`input` (the schema stays deliberately partial: the opaque ADR-053
|
||||
backend params pass through; the establisher is the typed gate).
|
||||
- `carriage == "raw"`, non-empty `cmd`.
|
||||
- Backend lookup against the adapter's backend map.
|
||||
- The ADR-050 ownership check (moved from the pump handler's
|
||||
`validate_and_allocate`; the scope gate stays the registry's —
|
||||
pre-allocation, unchanged).
|
||||
`validate_and_allocate`; the scope gate stays the registry's,
|
||||
pre-allocation and unchanged).
|
||||
|
||||
Rejections map into ADR-049's fixed reason vocabulary (no new codes —
|
||||
Rejections map into ADR-049's fixed reason vocabulary (no new codes;
|
||||
the vocabulary is alkcall's one-way wire surface):
|
||||
|
||||
| TTY failure class | `EstablishmentError` variant | `details.reason` |
|
||||
@@ -81,24 +82,24 @@ the vocabulary is alkcall's one-way wire surface):
|
||||
| `carriage != "raw"`, empty `cmd` | `HandlerError` | `handler_error` |
|
||||
| unknown backend | `UnknownResource` | `unknown_resource` |
|
||||
| ownership denial | `HandlerError` | `handler_error` |
|
||||
| **allocation failure** (amended — §2A) | `DialFailed` | `dial_failed` |
|
||||
| **allocation failure** (amended, §2A) | `DialFailed` | `dial_failed` |
|
||||
| establishment deadline | (wrapper) | `timeout` |
|
||||
|
||||
`resource_shortage` has no TTY producer path (allocation-capacity
|
||||
failures map to `dial_failed` — the target refused or the backend
|
||||
failures map to `dial_failed`: the target refused or the backend
|
||||
lacked capacity; §2A). `HandlerError` covers both the
|
||||
malformed-negotiation semantics (the request shape is wrong) and the
|
||||
ACL-outcome ownership denial (an authorization failure, not a dial
|
||||
failure).
|
||||
|
||||
### 2. Backend allocation stays in the pump handler — `allocate_failed` stays in-band
|
||||
### 2. Backend allocation stays in the pump handler: `allocate_failed` stays in-band
|
||||
|
||||
> **Amended away (2026-09-07) — see §2A.** This section is the
|
||||
> **Amended away (2026-09-07; see §2A).** This section is the
|
||||
> historical record of the pre-0.3.0 shape; its blocker 1
|
||||
> (payloadless `Establishment`) was resolved by alkcall 0.6.0 (ADR-049
|
||||
> amendment 2, review 007 R-01). Blocker 2 (double-`allocate`
|
||||
> unsoundness) is avoided by construction in the amended shape: the
|
||||
> establisher's handle *is* the handle the handler pumps — there is no
|
||||
> establisher's handle *is* the handle the handler pumps; there is no
|
||||
> second allocate to be unsound about.
|
||||
|
||||
The one failure class that cannot migrate: `backend.allocate`. Two
|
||||
@@ -113,12 +114,12 @@ reasons, both structural:
|
||||
2. **Double-`allocate` is unsound under ADR-005's kill-on-`Drop`**
|
||||
contract: re-allocating in the handler after an establisher-side
|
||||
allocate would spawn the session target twice (the first handle's
|
||||
kill-guard would fire on its drop — a session killed before it
|
||||
kill-guard would fire on its drop: a session killed before it
|
||||
started), and leaking the establisher-side handle would leak the
|
||||
live target.
|
||||
|
||||
So `backend.allocate` runs where it always did — inside
|
||||
`drive_session_pre_negotiated`'s `validate_and_allocate` — and
|
||||
So `backend.allocate` runs where it always did (inside
|
||||
`drive_session_pre_negotiated`'s `validate_and_allocate`), and
|
||||
`allocate_failed` remains a post-open `0x00`-prefixed negotiation
|
||||
error frame, surfaced consumer-side as
|
||||
[`TtySessionError::NegotiationRejected`] via the same peek the direct
|
||||
@@ -139,18 +140,18 @@ frame.
|
||||
|
||||
alkcall 0.6.0 (ADR-049 amendment 2, review 007 R-01) filled the
|
||||
reserved field: `Establishment { plan: Option<ChannelPlan> }` with
|
||||
`ChannelPlan = Arc<dyn Any + Send + Sync>` — typed-opaque, wrapper
|
||||
threaded to the `OpenHandler`'s new second parameter. §2's blocker 1
|
||||
`ChannelPlan = Arc<dyn Any + Send + Sync>` (typed-opaque, wrapper
|
||||
threaded to the `OpenHandler`'s new second parameter). §2's blocker 1
|
||||
is gone; the double-`allocate` hazard (blocker 2) is structurally
|
||||
absent — one allocate per open, whose result the handler consumes.
|
||||
absent: one allocate per open, whose result the handler consumes.
|
||||
|
||||
The amended shape:
|
||||
|
||||
1. **The establisher allocates.** After the validation sequence (§1),
|
||||
it runs `backend.allocate(¶ms)`. Failure maps to
|
||||
`EstablishmentError::DialFailed` (`details.reason == "dial_failed"`
|
||||
— the target refused or the backend lacked capacity; one string
|
||||
message, no nuance — the same granularity the direct path's
|
||||
`EstablishmentError::DialFailed` (`details.reason == "dial_failed"`:
|
||||
the target refused or the backend lacked capacity; one string
|
||||
message, no nuance, the same granularity the direct path's
|
||||
`allocate_failed` frame always had). The
|
||||
`channel:open_failed` `ErrorDefinition` on `tty_open_spec()`
|
||||
therefore declares four reachable reasons (`dial_failed`,
|
||||
@@ -159,7 +160,7 @@ The amended shape:
|
||||
(boxed dyn streams), so it cannot be the `ChannelPlan` directly.
|
||||
The establisher wraps it in a private `AllocatedHandle` one-shot
|
||||
slot (`Mutex<Option<TtyHandle>>`), created **per establisher
|
||||
invocation** — per open. The wrapper moves this open's plan to this
|
||||
invocation**, one per open. The wrapper moves this open's plan to this
|
||||
open's handler (alkcall's concurrent-same-resource test pins that
|
||||
plans are never shared), the handler `take()`s it exactly once, and
|
||||
establisher-before-handler ordering is alkcall's wrapper guarantee
|
||||
@@ -167,8 +168,8 @@ The amended shape:
|
||||
construction: there is no shared slot.
|
||||
3. **The handler pumps the pre-allocated handle.** With a plan it
|
||||
calls `drive_session_pre_allocated` (new `pub(crate)` driver: the
|
||||
pumps directly, no validation pass). With `plan: None` — a
|
||||
no-establisher registration — it falls back to
|
||||
pumps directly, no validation pass). With `plan: None` (a
|
||||
no-establisher registration), it falls back to
|
||||
`drive_session_pre_negotiated`, whose inline validate-and-allocate
|
||||
keeps the full in-band error-frame vocabulary. That fallback is
|
||||
defense-in-depth for the assembly-layer freedom to register without
|
||||
@@ -176,7 +177,7 @@ The amended shape:
|
||||
4. **Consumer surface.** `TtySession::open_via_channels` sees
|
||||
allocation failure as
|
||||
`ChannelsOpen(CallFailed{ channel:open_failed, reason:
|
||||
dial_failed })` — a typed call error, branchable and retryable
|
||||
dial_failed })`, a typed call error, branchable and retryable
|
||||
like every other establishment class. The `0x00` peek on the
|
||||
channel stream becomes a formality that no registered producer
|
||||
ever exercises; the pinned test flipped with the contract
|
||||
@@ -185,8 +186,8 @@ The amended shape:
|
||||
failure_to_dial_failed` in `channels.rs`).
|
||||
|
||||
The R-02 lifetime contract (the handler's `JoinHandle` tracks the
|
||||
data-plane lifetime) was already alktty's shape — the open handler
|
||||
awaits the driver inline in its spawned task — so the amendment adds
|
||||
data-plane lifetime) was already alktty's shape (the open handler
|
||||
awaits the driver inline in its spawned task), so the amendment adds
|
||||
no lifetime changes.
|
||||
|
||||
### 3. The direct-ALPN path is unchanged
|
||||
@@ -194,21 +195,21 @@ no lifetime changes.
|
||||
`TtyAdapter::handle` / `drive_session` keep the wire-frame
|
||||
negotiation (ADR-001) and the full in-band error-frame vocabulary
|
||||
(`unknown_backend`, `malformed_negotiation`, `allocate_failed`,
|
||||
`forbidden`). Two transports, two contracts — the direct path has no
|
||||
`forbidden`). Two transports, two contracts: the direct path has no
|
||||
open op to fail, and its `0x00`-peek disambiguation is the ADR-001
|
||||
wire-stable contract. The `enforce_scope` split from ADR-009 is
|
||||
unchanged (`true` direct, `false` channels — the registry's
|
||||
unchanged (`true` direct, `false` channels; the registry's
|
||||
`AccessControl` is the channels scope gate).
|
||||
|
||||
### 4. Spec enrichment (review 006 E-02 + ADR-016)
|
||||
|
||||
`tty_open_spec()` gains:
|
||||
|
||||
- `description` — disclosed by `services/list` (the open op describes
|
||||
- `description`, disclosed by `services/list` (the open op describes
|
||||
itself; the produced resource set stays OQ-40's deferred
|
||||
`channel/resources/subscribe` shape).
|
||||
- An `ErrorDefinition` for `channel:open_failed` with the
|
||||
`details.reason` enum — so `services/schema` discloses the
|
||||
`details.reason` enum, so `services/schema` discloses the
|
||||
establishment-failure contract per ADR-016 (ADR-049 §3: "ALPN
|
||||
crates' open-op specs gain matching `ErrorDefinition` entries so
|
||||
`services/schema` discloses the failure contract").
|
||||
@@ -224,14 +225,14 @@ unchanged (`true` direct, `false` channels — the registry's
|
||||
| malformed negotiation (schema-valid) | establisher | `ChannelsOpen(CallFailed{ channel:open_failed, reason: handler_error })` |
|
||||
| unknown backend | establisher | `ChannelsOpen(CallFailed{ channel:open_failed, reason: unknown_resource })` |
|
||||
| ownership denial | establisher | `ChannelsOpen(CallFailed{ channel:open_failed, reason: handler_error })` |
|
||||
| **allocation failure** (amended — §2A) | establisher | **`ChannelsOpen(CallFailed{ channel:open_failed, reason: dial_failed })`** |
|
||||
| **allocation failure** (amended, §2A) | establisher | **`ChannelsOpen(CallFailed{ channel:open_failed, reason: dial_failed })`** |
|
||||
| establishment timeout | wrapper bound | `ChannelsOpen(CallFailed{ channel:open_failed, reason: timeout })` |
|
||||
| ACL denial (scope) | registry gate | `ChannelsOpen(CallFailed{ FORBIDDEN })` |
|
||||
| channel cap | wrapper `check_open` | `ChannelsOpen(CallFailed{ channel:too_many_channels })` |
|
||||
|
||||
`TtySessionError::ChannelsOpen` now carries alkcall's typed
|
||||
`ChannelOpenError` verbatim (`#[from]`) instead of a flattened
|
||||
`String` — the N-1 fix applied at alktty's layer, so consumers branch
|
||||
`String`, the N-1 fix applied at alktty's layer, so consumers branch
|
||||
on `establishment_reason()` without unwrapping strings.
|
||||
|
||||
## Consequences
|
||||
@@ -241,11 +242,11 @@ on `establishment_reason()` without unwrapping strings.
|
||||
- The phantom-channel workaround is retired: a semantically invalid
|
||||
open no longer allocates → succeeds → in-band-fails. Ledger, policy
|
||||
count, and manager state balance on every rejection.
|
||||
- Retry policy / UX can branch: `unknown_resource` (bad config — don't
|
||||
- Retry policy / UX can branch: `unknown_resource` (bad config, don't
|
||||
retry), `timeout` (maybe retry), `dial_failed` (capacity/target
|
||||
refused — maybe retry; amended §2A, was the in-band
|
||||
refused, maybe retry; amended §2A, was the in-band
|
||||
`allocate_failed`).
|
||||
- The consumer's `open_via_channels` failure is a typed call error —
|
||||
- The consumer's `open_via_channels` failure is a typed call error;
|
||||
no peeking at the data stream for anything but the (now
|
||||
unreachable-from-registered-producers) error-frame formality.
|
||||
|
||||
@@ -257,7 +258,7 @@ on `establishment_reason()` without unwrapping strings.
|
||||
direct path's failure surface is unchanged.
|
||||
- The `channel:open_failed` reason codes the establisher emits join
|
||||
the wire-stable error set (ADR-049's one-way door).
|
||||
- **Amendment (0.3.0):** allocation failure changed shape again —
|
||||
- **Amendment (0.3.0):** allocation failure changed shape again:
|
||||
`NegotiationRejected{ "allocate_failed" }` (in-band) →
|
||||
`ChannelsOpen(CallFailed{ channel:open_failed, reason:
|
||||
dial_failed })`. The direct path's `allocate_failed` frame is
|
||||
@@ -266,34 +267,34 @@ on `establishment_reason()` without unwrapping strings.
|
||||
## Door type
|
||||
|
||||
**One-way (wire-visible).** The establisher mapping fixes
|
||||
`details.reason` values for TTY's channels path — consumers branch on
|
||||
`details.reason` values for TTY's channels path: consumers branch on
|
||||
`unknown_resource` / `handler_error` / `timeout`, so re-mapping a
|
||||
failure class between reasons is a peer-breaking change. The
|
||||
establisher's internal validation order is a two-way door
|
||||
(implementation detail behind the fixed surface). ADR-009's
|
||||
"two-way for now" negotiation-location decision is unaffected — this
|
||||
ADR moves failure *reporting*, not the negotiation *location*.
|
||||
"two-way for now" negotiation-location decision is unaffected (this
|
||||
ADR moves failure *reporting*, not the negotiation *location*).
|
||||
|
||||
## References
|
||||
|
||||
- alkcall ADR-049 (the establisher, `channel:open_failed`, the typed
|
||||
client error — the upstream capability this ADR adopts)
|
||||
client error; the upstream capability this ADR adopts)
|
||||
- alkcall review 006 (E-01 the establishment gap; N-1 the typed
|
||||
client error; E-02 the discovery enrichment this ADR's spec
|
||||
enrichment adopts)
|
||||
- ADR-009 (the open op's `input` is the negotiation; the R4
|
||||
amendment's in-band error frames — shrunk to `allocate_failed` by
|
||||
amendment's in-band error frames, shrunk to `allocate_failed` by
|
||||
this ADR)
|
||||
- ADR-005 (kill-on-`Drop` — why a *second* establisher-side allocate
|
||||
would be unsound; avoided by construction in the §2A amendment —
|
||||
- ADR-005 (kill-on-`Drop`: why a *second* establisher-side allocate
|
||||
would be unsound; avoided by construction in the §2A amendment,
|
||||
one allocate per open, consumed by the handler)
|
||||
- ADR-001 (the §5 `0x00` disambiguation — retained for the direct
|
||||
- ADR-001 (the §5 `0x00` disambiguation, retained for the direct
|
||||
path; the channels-path peek is now a formality)
|
||||
- ADR-002 (backend selection/allocation ownership)
|
||||
- alkcall ADR-049 amendment 2 / review 007 R-01 (the `Establishment`
|
||||
plan payload — the upstream capability this ADR's §2A adopts)
|
||||
- `src/channels.rs` — `make_tty_establisher`, `AllocatedHandle`,
|
||||
plan payload; the upstream capability this ADR's §2A adopts)
|
||||
- `src/channels.rs`: `make_tty_establisher`, `AllocatedHandle`,
|
||||
`make_tty_open_handler`, `tty_open_spec`
|
||||
- `src/session.rs` — `TtySessionError::ChannelsOpen`, `open_via_channels`
|
||||
- `src/testing.rs` — the pinned `allocate_failure_fails_open_as_dial_failed`
|
||||
- `src/session.rs`: `TtySessionError::ChannelsOpen`, `open_via_channels`
|
||||
- `src/testing.rs`: the pinned `allocate_failure_fails_open_as_dial_failed`
|
||||
test (superseded `allocate_failure_still_arrives_in_band_on_channels_path`)
|
||||
@@ -6,7 +6,7 @@ status: draft (ported from alknet 2026-08-17; alknet-tty → alktty,
|
||||
last_updated: 2026-09-05
|
||||
---
|
||||
|
||||
# alktty — Overview
|
||||
# alktty: Overview
|
||||
|
||||
The terminal session protocol crate: a `ProtocolHandler` on `alk/tty`
|
||||
that pumps a bidirectional byte stream (stdin/stdout/stderr) with a JSON
|
||||
@@ -24,18 +24,18 @@ ALPN on the shared endpoint and implements the `ProtocolHandler` trait
|
||||
(alknet ADR-002, alknet ADR-007). The `TtyAdapter` receives a
|
||||
`Connection`, accepts one bidi stream per terminal session, reads a
|
||||
single JSON negotiation frame, switches to a raw chunk format, and
|
||||
pumps bytes bidirectionally for the life of the session —
|
||||
pumps bytes bidirectionally for the life of the session,
|
||||
backend-agnostic.
|
||||
|
||||
The guiding insight that shapes the crate:
|
||||
|
||||
> A terminal session is not an SSH concern, or a Docker concern — it is
|
||||
> A terminal session is not an SSH concern, or a Docker concern; it is
|
||||
> a terminal concern. SSH and Docker are just two backends that can
|
||||
> allocate a PTY.
|
||||
|
||||
The alknet-docker POC proved that the hard part of interactive attach —
|
||||
bidirectional byte pumping over a framed stream with a 1-byte
|
||||
stream-type multiplexer — is the same problem regardless of whether the
|
||||
The alknet-docker POC proved that the hard part of interactive attach
|
||||
(bidirectional byte pumping over a framed stream with a 1-byte
|
||||
stream-type multiplexer) is the same problem regardless of whether the
|
||||
backend is `bollard::attach_container()` or russh's `pty_request`. The
|
||||
POC's raw chunk format is the seed of alktty's wire format. alktty
|
||||
extracts that pattern into its own crate and ALPN; the backends (Docker,
|
||||
@@ -53,7 +53,7 @@ consumers. A hub that runs agent workspaces in containers wires
|
||||
sessions over `alk/tty`. A coordinator that runs `cargo test` remotely
|
||||
wires `LocalTtyBackend` (pipe mode) and gets 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, just over
|
||||
connection), the same shape as GitHub/Gitea Actions runners, just over
|
||||
alk's transport instead of HTTP polling. A browser terminal (xterm.js
|
||||
over WebTransport, when WebTransport revives) connects to `alk/tty`
|
||||
directly and gets raw bytes without implementing SSH or the call
|
||||
@@ -77,9 +77,9 @@ A `alk/tty` bidi stream has two phases (full detail in
|
||||
length-prefixed JSON frame carrying the terminal parameters, backend
|
||||
selector, command, and environment. The framing is a 4-byte
|
||||
big-endian length prefix + UTF-8 JSON body, self-contained in
|
||||
alktty (the format coincides with alkcall's framing by convention;
|
||||
alktty does not depend on alkcall's internal wire types — see
|
||||
Dependencies below).
|
||||
alktty (the format coincides with alkcall's framing by convention;
|
||||
alktty does not depend on alkcall's internal wire types; see
|
||||
Dependencies below).
|
||||
|
||||
2. **Raw carriage.** After the negotiation frame, the stream switches to
|
||||
the chunk format (`[stream_type: u8][length: u32 be][payload]`) for
|
||||
@@ -87,7 +87,7 @@ A `alk/tty` bidi stream has two phases (full detail in
|
||||
1=stdout (server→client), 2=stderr (server→client), 3=ctrl-in
|
||||
(client→server, JSON control messages: resize, signal, eof),
|
||||
4=ctrl-out (server→client, JSON control messages: exit). There is no
|
||||
`call.responded`/`call.completed` — this is not the call protocol;
|
||||
`call.responded`/`call.completed`; this is not the call protocol;
|
||||
the raw-carriage byte pump is its own wire format after the single
|
||||
JSON negotiation frame.
|
||||
|
||||
@@ -101,12 +101,12 @@ control-channel split) is in
|
||||
## Dependencies
|
||||
|
||||
```
|
||||
alktty (default — wasm-clean)
|
||||
alktty (default; wasm-clean)
|
||||
├── alkcall::core (ProtocolHandler, Connection, AuthContext, Identity, AccessControl,
|
||||
│ OwnershipProvider — alknet ADR-050 for terminal sessions as resources)
|
||||
└── (no backend deps — portable_pty, bollard, russh are in the backend crates / `local` feature)
|
||||
│ OwnershipProvider; alknet ADR-050 for terminal sessions as resources)
|
||||
└── (no backend deps; portable_pty, bollard, russh are in the backend crates / `local` feature)
|
||||
|
||||
alktty (local feature) — non-wasm by design
|
||||
alktty (local feature) is non-wasm by design
|
||||
└── adds: portable-pty, tokio-util, tokio/process, tokio/rt-multi-thread
|
||||
```
|
||||
|
||||
@@ -115,7 +115,7 @@ only. The negotiation framing is a self-contained ~30-line module in
|
||||
alktty (4-byte BE length prefix + UTF-8 JSON body on tokio's
|
||||
`AsyncRead`/`AsyncWrite`). The heavy backend dependencies
|
||||
(`portable_pty`, `bollard`, `russh`) live in the backend crates, not
|
||||
here. alktty does **not** depend on alkcall's internal wire types — see
|
||||
here. alktty does **not** depend on alkcall's internal wire types; see
|
||||
[ADR-006](decisions/006-negotiation-framing-self-contained.md).
|
||||
|
||||
### Why no alkcall-internal-wire-types dependency
|
||||
@@ -123,9 +123,9 @@ here. alktty does **not** depend on alkcall's internal wire types — see
|
||||
An earlier draft had alktty depending on alkcall for the
|
||||
`FrameFramedReader`/`FrameFramedWriter` "framing utility." A
|
||||
pre-implementation check found this was unsound: `FrameFramedReader`'s
|
||||
`read_frame()` is hardcoded to deserialize `EventEnvelope` — the
|
||||
`read_frame()` is hardcoded to deserialize `EventEnvelope` (the
|
||||
length-prefix read and the type-specific deserialize are one entangled
|
||||
call, not a separable utility. alktty's negotiation payload is a
|
||||
call, not a separable utility). alktty's negotiation payload is a
|
||||
`NegotiateRequest`, not an `EventEnvelope`, so the claimed reuse did not
|
||||
exist in a usable form. alktty implements its own framing (the format
|
||||
coincides with alkcall's by convention; the implementations are
|
||||
@@ -135,7 +135,7 @@ domain abstraction worth a cross-crate dependency. See
|
||||
full decision and alknet ADR-003 Amendment 2 for the dependency-edge
|
||||
clarification.
|
||||
|
||||
alktty stays lean — it has no `portable_pty` (default), no `bollard`, no
|
||||
alktty stays lean: it has no `portable_pty` (default), no `bollard`, no
|
||||
`russh`, no alkcall-internal-wire-types. The `TtyBackend` implementations
|
||||
are opaque `Arc<dyn TtyBackend>` from the adapter's perspective:
|
||||
constructed by the assembly layer at startup, stored in the adapter's
|
||||
@@ -145,18 +145,18 @@ backend map, dispatched by the `backend` field of the negotiation frame.
|
||||
|
||||
| ALPN | Handler | Transport | Browser? |
|
||||
|------|---------|-----------|----------|
|
||||
| `alk/tty` | `TtyAdapter` | QUIC bidi stream (direct) or `alk/channels` (multiplexed) | Yes (when WebTransport revives — alknet ADR-040 parked) |
|
||||
| `alk/tty` | `TtyAdapter` | QUIC bidi stream (direct) or `alk/channels` (multiplexed) | Yes (when WebTransport revives; alknet ADR-040 parked) |
|
||||
|
||||
`alk/tty` is a custom ALPN per the alknet ADR-006 `alk/<name>` convention
|
||||
(renamed from `alknet/<name>` in alkcall 0.1.1). The `TtyAdapter`
|
||||
registers for it; the endpoint's `HandlerRegistry` maps `alk/tty` to the
|
||||
adapter instance. One ALPN per connection (alknet ADR-006); within a
|
||||
connection, multiple bidi streams carry independent sessions (one
|
||||
session per stream — see [tty-adapter.md](tty-adapter.md)).
|
||||
session per stream; see [tty-adapter.md](tty-adapter.md)).
|
||||
|
||||
The browser terminal case: a browser (xterm.js) connects via WebTransport
|
||||
to `alk/tty` and gets raw bytes. The browser doesn't need to implement
|
||||
SSH or the call protocol for the terminal use case — only if it wants
|
||||
SSH or the call protocol for the terminal use case, only if it wants
|
||||
SSH-specific features (port forwarding, SFTP). This is a cleaner browser
|
||||
story than "run a WASM SSH client." WebTransport is deferred per
|
||||
alknet ADR-044; when it revives, the `alk/tty` ALPN is reachable over
|
||||
@@ -178,26 +178,26 @@ The decomposition principle: the trait lives where the types live
|
||||
live.
|
||||
|
||||
```
|
||||
alktty (default — lean, no portable_pty, no bollard, no russh)
|
||||
├── TtyBackend trait (the contract — ADR-002)
|
||||
alktty (default; lean, no portable_pty, no bollard, no russh)
|
||||
├── TtyBackend trait (the contract, ADR-002)
|
||||
├── TtyHandle, TtyControl (the handle shape backends produce)
|
||||
├── TtyParams, TerminalParams (the allocation request)
|
||||
├── TtyAdapter (ProtocolHandler on alk/tty — session lifecycle)
|
||||
├── wire format (ChunkReader/ChunkWriter, ControlMessage — ADR-001)
|
||||
├── TtyAdapter (ProtocolHandler on alk/tty; session lifecycle)
|
||||
├── wire format (ChunkReader/ChunkWriter, ControlMessage; ADR-001)
|
||||
└── negotiation framing (self-contained ~30-line module; format coincides
|
||||
with alkcall's by convention — ADR-006)
|
||||
with alkcall's by convention, ADR-006)
|
||||
|
||||
alktty (local feature module — ADR-003; folded in from the old
|
||||
alknet-tty-local sibling crate)
|
||||
├── LocalTtyBackend (impl TtyBackend — portable_pty for PTY, std::process for pipe)
|
||||
├── portable_pty dependency (PTY allocation — the heavy dep, here not in alktty default)
|
||||
└── libc (signal forwarding — REQ-TTY-02, Unix only)
|
||||
alktty (local feature module, ADR-003; folded in from the old
|
||||
alknet-tty-local sibling crate)
|
||||
├── LocalTtyBackend (impl TtyBackend: portable_pty for PTY, std::process for pipe)
|
||||
├── portable_pty dependency (PTY allocation, the heavy dep, here not in alktty default)
|
||||
└── libc (signal forwarding, REQ-TTY-02, Unix only)
|
||||
|
||||
alknet-docker (or alktty-docker adapter — future crate, out of scope here)
|
||||
└── DockerTtyBackend (impl TtyBackend — wraps bollard::attach_container / exec with tty:true)
|
||||
alknet-docker (or alktty-docker adapter, future crate, out of scope here)
|
||||
└── DockerTtyBackend (impl TtyBackend; wraps bollard::attach_container / exec with tty:true)
|
||||
|
||||
alknet-ssh (future crate — out of scope here)
|
||||
└── SshTtyBackend (impl TtyBackend — wraps russh pty_request + shell_request/exec_request)
|
||||
alknet-ssh (future crate, out of scope here)
|
||||
└── SshTtyBackend (impl TtyBackend; wraps russh pty_request + shell_request/exec_request)
|
||||
```
|
||||
|
||||
alktty never sees `portable_pty` (default), `bollard`, or `russh`. The
|
||||
@@ -207,7 +207,7 @@ transport dependencies. The local backend's module placement (folded
|
||||
into alktty behind a `local` feature, resolving the alknet cyclic-dep
|
||||
workaround) is decided in [ADR-003](decisions/003-local-backend-placement.md);
|
||||
the docker and SSH backends are future crates (out of scope for this
|
||||
spec set — see [tty-backend.md](tty-backend.md) §"Backend implementations"
|
||||
spec set; see [tty-backend.md](tty-backend.md) §"Backend implementations"
|
||||
for where they live).
|
||||
|
||||
## Feature Gates
|
||||
@@ -219,54 +219,54 @@ default = []
|
||||
local = ["dep:portable-pty", "dep:tokio-util", "tokio/process", "tokio/rt-multi-thread"]
|
||||
```
|
||||
|
||||
- `default` — the wire format, `TtyAdapter`, and the `TtyBackend` trait.
|
||||
- `default`: the wire format, `TtyAdapter`, and the `TtyBackend` trait.
|
||||
No backend implementations; the assembly layer registers backends
|
||||
from their own crates. A docker-only or ssh-only deployment uses the
|
||||
default features and depends on `alknet-docker` / `alknet-ssh` (or
|
||||
their own backend crate) directly. **The default crate compiles to
|
||||
`wasm32-unknown-unknown`** — this is what makes the downstream
|
||||
`wasm32-unknown-unknown`**; this is what makes the downstream
|
||||
TS/Python adapter story work (a wasm-compiled alktty is the protocol
|
||||
layer for a sandboxed adapter).
|
||||
- `local` — enables `alktty::local::LocalTtyBackend`, pulling in
|
||||
- `local` enables `alktty::local::LocalTtyBackend`, pulling in
|
||||
`portable_pty` (PTY mode) and `tokio::process` (pipe mode). A consumer
|
||||
that wants the local backend (terminal or runner) enables this
|
||||
feature. Inherently non-wasm — `portable-pty` + `tokio::process` need
|
||||
a real OS.
|
||||
feature. Inherently non-wasm (`portable-pty` + `tokio::process` need
|
||||
a real OS).
|
||||
|
||||
The local backend's `portable_pty` dependency is the heavy dep that
|
||||
motivates the feature gate — a docker-only deployment should not pull in
|
||||
motivates the feature gate: a docker-only deployment should not pull in
|
||||
PTY allocation code. See [ADR-003](decisions/003-local-backend-placement.md).
|
||||
|
||||
## Architecture (component pointers)
|
||||
|
||||
- **[tty-wire.md](tty-wire.md)** — the wire format: the negotiation
|
||||
- **[tty-wire.md](tty-wire.md)**: the wire format; the negotiation
|
||||
frame (JSON carriage, self-contained length-prefixed framing), the
|
||||
raw chunk codec (`[stream_type: u8][length: u32 be][payload]`), the
|
||||
five stream types, the control channel (split into `STREAM_CTRL_IN`
|
||||
/ `STREAM_CTRL_OUT` halves, JSON control messages), sentinels, and
|
||||
the fixed-channel-set rationale.
|
||||
- **[tty-bast.md](tty-bast.md)** — the BAST (Binary Abstract Syntax
|
||||
- **[tty-bast.md](tty-bast.md)**: the BAST (Binary Abstract Syntax
|
||||
Tree) document for the binary framing layer of the `alk/tty` wire
|
||||
format (the 5-byte chunk header and the negotiation frame's 4-byte
|
||||
length prefix); a normative JSON spec conforming to the BAST
|
||||
meta-schema at `https://alk.dev/bast/v1/schema`, validatable by any
|
||||
JSON Schema Draft 2020-12 validator. The JSON payloads
|
||||
(`NegotiateRequest`, `ControlMessage`, `TerminalParams`) are
|
||||
specified in `tty-wire.md` and the Rust source, not in the BAST —
|
||||
specified in `tty-wire.md` and the Rust source, not in the BAST;
|
||||
BAST describes binary layouts, not JSON shapes.
|
||||
- **[tty-backend.md](tty-backend.md)** — the `TtyBackend` trait,
|
||||
- **[tty-backend.md](tty-backend.md)**: the `TtyBackend` trait,
|
||||
`TtyParams`, `TtyHandle`, `TtyControl`. The inversion point between
|
||||
the wire-format adapter and the backends. Carries REQ-TTY-01
|
||||
(backends need not be natively async; the bridging pattern is a
|
||||
documented strategy). Notes where the docker/SSH backend crates live
|
||||
(future, out of scope here).
|
||||
- **[tty-adapter.md](tty-adapter.md)** — the `TtyAdapter`
|
||||
- **[tty-adapter.md](tty-adapter.md)**: the `TtyAdapter`
|
||||
(`ProtocolHandler` on `alk/tty`): the session lifecycle, the
|
||||
three-pump bidirectional driver (stdout→client, client→backend,
|
||||
exit→exit-chunk), negotiation errors, the exit-chunk ordering
|
||||
(ADR-004), access control (terminal sessions as runtime-spawned
|
||||
resources per alknet ADR-050), session-cancel cleanup (ADR-005).
|
||||
- **[tty-local.md](tty-local.md)** — the `local` feature module:
|
||||
- **[tty-local.md](tty-local.md)**: the `local` feature module:
|
||||
`LocalTtyBackend` via `portable_pty` (PTY mode) and
|
||||
`tokio::process::Command` (pipe/runner mode). Carries REQ-TTY-02
|
||||
(signal forwarding to the foreground process group). The
|
||||
@@ -291,27 +291,27 @@ PTY allocation code. See [ADR-003](decisions/003-local-backend-placement.md).
|
||||
|
||||
- **OQ-43** (resolved): `TtyControl` as a `Clone` trait object.
|
||||
- **OQ-44** (deferred(scope)): Terminal modes (TTY modes).
|
||||
- **OQ-45** (resolved): Flow control for high-throughput stdout — no
|
||||
- **OQ-45** (resolved): Flow control for high-throughput stdout (no
|
||||
application-level windowing; QUIC per-stream flow control is the
|
||||
backpressure mechanism.
|
||||
backpressure mechanism).
|
||||
- **OQ-46** (deferred(scope)): Runner API surface.
|
||||
- **OQ-47** (resolved): Stdin closure canonical signal.
|
||||
|
||||
## References
|
||||
|
||||
- alknet ADR-001 — ALPN-based dispatch
|
||||
- alknet ADR-002 — ProtocolHandler trait
|
||||
- alknet ADR-003 + Amendments 1 & 2 — crate decomposition
|
||||
- alknet ADR-001: ALPN-based dispatch
|
||||
- alknet ADR-002: ProtocolHandler trait
|
||||
- alknet ADR-003 + Amendments 1 & 2: crate decomposition
|
||||
(no-handler-depends-on-another-handler; alktty depends on alkcall
|
||||
only; backends depend on alktty for the trait)
|
||||
- alknet ADR-006 — `alk/<name>` ALPN convention; one ALPN per
|
||||
- alknet ADR-006: `alk/<name>` ALPN convention; one ALPN per
|
||||
connection; new ALPN for incompatible versions
|
||||
- alknet ADR-007 — `Connection`, `accept_bi`, the handler-receives-
|
||||
- alknet ADR-007: `Connection`, `accept_bi`, the handler-receives-
|
||||
Connection pattern
|
||||
- alknet ADR-050 — dynamic resource ownership (terminal sessions as
|
||||
- alknet ADR-050: dynamic resource ownership (terminal sessions as
|
||||
runtime-spawned resources; the adapter's access-control shape
|
||||
declares against this model)
|
||||
- `/workspace/@alkdev/alknet/docs/architecture/decisions/` — the alknet
|
||||
- `/workspace/@alkdev/alknet/docs/architecture/decisions/`: the alknet
|
||||
originals of the ADRs ported here as 001..008, plus the alknet ADRs
|
||||
referenced by alknet number above (which are not ported into alktty's
|
||||
ADR range because they are not tty-specific)
|
||||
@@ -5,7 +5,7 @@ status: draft (ported from alknet 2026-08-17; alknet/tty → alk/tty,
|
||||
last_updated: 2026-09-05
|
||||
---
|
||||
|
||||
# alktty — TtyAdapter and Session Lifecycle
|
||||
# alktty: TtyAdapter and Session Lifecycle
|
||||
|
||||
The `TtyAdapter` is the `ProtocolHandler` for `alk/tty`: it receives a
|
||||
`Connection`, accepts bidi streams, reads the negotiation frame, selects
|
||||
@@ -22,7 +22,7 @@ alknet ADR-007 to receive a `Connection`) on ALPN `alk/tty` (alknet
|
||||
ADR-006). It holds a `HashMap<String, Arc<dyn TtyBackend>>` populated at
|
||||
construction (ADR-002 §5). Its `handle()` method accepts the connection
|
||||
and loops `connection.accept_bi()`, dispatching each bidi stream to a
|
||||
session. One `alk/tty` connection hosts multiple terminal sessions — one
|
||||
session. One `alk/tty` connection hosts multiple terminal sessions, one
|
||||
session per bidi stream (DP-6, decided in the alknet research; matches
|
||||
the call protocol's one-operation-per-stream model).
|
||||
|
||||
@@ -58,10 +58,10 @@ impl ProtocolHandler for TtyAdapter {
|
||||
}
|
||||
```
|
||||
|
||||
The `drive_session` function is the per-stream session driver — the
|
||||
The `drive_session` function is the per-stream session driver: the
|
||||
counterpart to the POC's `session::drive_session`, generalized from the
|
||||
local PTY backend to the `TtyBackend` trait. It is also the function the
|
||||
channels path reuses — per [ADR-008](decisions/008-channels-pure-channel-multiplexing.md),
|
||||
channels path reuses; per [ADR-008](decisions/008-channels-pure-channel-multiplexing.md),
|
||||
TTY always uses its 5-byte format, so the same driver runs in
|
||||
both direct `alk/tty` and channels `alk/channels` modes; only the
|
||||
`BiStream` source differs. The two paths differ in where the
|
||||
@@ -69,7 +69,7 @@ both direct `alk/tty` and channels `alk/channels` modes; only the
|
||||
direct path reads the wire-frame negotiation (ADR-001 §"Negotiation
|
||||
Frame") and answers failures with in-band error frames; the channels
|
||||
path parses the open op's registry-validated `input` and runs
|
||||
`drive_session_pre_negotiated` (ADR-009 — no second negotiation
|
||||
`drive_session_pre_negotiated` (ADR-009; no second negotiation
|
||||
frame), with the establisher rejecting the semantic-failure classes
|
||||
as `channel:open_failed` before the handler spawns (ADR-010).
|
||||
|
||||
@@ -77,7 +77,7 @@ as `channel:open_failed` before the handler spawns (ADR-010).
|
||||
|
||||
The adapter is the place where the wire format (ADR-001), the backend
|
||||
trait (ADR-002), and the exit-chunk ordering (ADR-004) come together.
|
||||
Keeping these in one place — the adapter — is what makes the invariants
|
||||
Keeping these in one place (the adapter) is what makes the invariants
|
||||
enforceable: the wire format's "exit chunk is last" invariant is enforced
|
||||
here, not in the backends (which produce handles, not wire bytes); the
|
||||
backend's `exit_code` future is awaited here, not in the backend; the
|
||||
@@ -104,7 +104,7 @@ A `alk/tty` session on one bidi stream proceeds in three phases:
|
||||
allocated, docker exec failed, SSH channel request rejected), the
|
||||
adapter sends a JSON error response and closes the stream.
|
||||
|
||||
3. **Raw carriage — the bidirectional pump.** The adapter switches to
|
||||
3. **Raw carriage: the bidirectional pump.** The adapter switches to
|
||||
the raw chunk format and pumps three concurrent tasks:
|
||||
|
||||
- **A. stdout → client**: backend stdout (`TtyHandle.stdout`) → stdout
|
||||
@@ -127,7 +127,7 @@ A `alk/tty` session on one bidi stream proceeds in three phases:
|
||||
|
||||
A drainer task writes chunks to the client in arrival order. After the
|
||||
exit chunk is written (task C resolves and the exit chunk drains),
|
||||
the adapter closes the write half — the session ends.
|
||||
the adapter closes the write half; the session ends.
|
||||
|
||||
This is the POC's `session::drive_session` pattern, generalized: the POC
|
||||
hardcoded the local PTY backend; the adapter dispatches to any
|
||||
@@ -138,24 +138,24 @@ hardcoded the local PTY backend; the adapter dispatches to any
|
||||
The control channel is split into two halves so it is genuinely
|
||||
bidirectional on the wire:
|
||||
|
||||
- **`STREAM_CTRL_IN = 3`** — client→server control (`Resize`, `Signal`,
|
||||
- **`STREAM_CTRL_IN = 3`**: client→server control (`Resize`, `Signal`,
|
||||
`Eof`).
|
||||
- **`STREAM_CTRL_OUT = 4`** — server→client control (`Exit`).
|
||||
- **`STREAM_CTRL_OUT = 4`**: server→client control (`Exit`).
|
||||
|
||||
The adapter enforces the direction:
|
||||
|
||||
- An `Exit` arriving on `STREAM_CTRL_IN` is a protocol violation
|
||||
(server→client message on the client→server half) — the adapter
|
||||
(server→client message on the client→server half); the adapter
|
||||
ignores it (the previous single `STREAM_CONTROL = 3` could not
|
||||
distinguish the two directions, so `Exit` from the client was always
|
||||
ignored; the split makes the rejection explicit).
|
||||
- A `Resize`/`Signal`/`Eof` arriving on `STREAM_CTRL_OUT` is a protocol
|
||||
violation (client→server message on the server→client half) — the
|
||||
violation (client→server message on the server→client half), and the
|
||||
adapter ignores it (the server never dispatches control messages it
|
||||
receives on the server→client half).
|
||||
- `STREAM_CTRL_OUT` (stream_type 4) chunks written by the client are a
|
||||
protocol violation (the client should not write on the server→client
|
||||
half) — the adapter ignores them.
|
||||
half); the adapter ignores them.
|
||||
|
||||
The exit chunk (`Exit`) is emitted on `STREAM_CTRL_OUT` (stream_type
|
||||
4), not on the previous `STREAM_CONTROL = 3`. A client distinguishing
|
||||
@@ -180,29 +180,29 @@ the semantic classes (unknown backend, malformed negotiation,
|
||||
ownership denial) **and allocation** are rejected by the establisher
|
||||
before the open reply and surface as `channel:open_failed` call
|
||||
errors (alkcall ADR-049 / alktty ADR-010, as amended for alkcall 0.6's
|
||||
`Establishment` plan payload — ADR-010 §2A) — the in-band frames on
|
||||
`Establishment` plan payload; ADR-010 §2A), so the in-band frames on
|
||||
that path are defense-in-depth arms only (reachable from
|
||||
no-establisher registrations).
|
||||
|
||||
| Error | When | Shape |
|
||||
|-------|------|------|
|
||||
| `unknown_backend` | the `backend` string is not in the adapter's backend map — direct path, or the channels path's defense-in-depth arm (the establisher rejects it as `channel:open_failed` / `unknown_resource` first) | `{"error":"unknown_backend","backend":"..."}` |
|
||||
| `malformed_negotiation` | the negotiation frame failed to parse as JSON or failed `NegotiateRequest` validation — on the direct path the wire frame; on the channels path the open op's `input` (schema-valid values can still fail the typed parse, e.g. `cwd` typed as a number, because the schema is deliberately partial — the establisher rejects it as `channel:open_failed` / `handler_error` first; the handler-side frame is defense-in-depth, ADR-010 §2) | `{"error":"malformed_negotiation","message":"..."}` |
|
||||
| `allocate_failed` | `backend.allocate()` returned a `TtyError` — the failure surface of the **direct path**. On the channels path the establisher runs `allocate` (the handle crosses to the pump handler via alkcall 0.6's `Establishment` plan payload — ADR-010 §2A) and its failure is a `channel:open_failed` call error (`details.reason == "dial_failed"`) | `{"error":"allocate_failed","message":"..."}` |
|
||||
| `unknown_backend` | the `backend` string is not in the adapter's backend map; direct path, or the channels path's defense-in-depth arm (the establisher rejects it as `channel:open_failed` / `unknown_resource` first) | `{"error":"unknown_backend","backend":"..."}` |
|
||||
| `malformed_negotiation` | the negotiation frame failed to parse as JSON or failed `NegotiateRequest` validation. On the direct path the wire frame; on the channels path the open op's `input` (schema-valid values can still fail the typed parse, e.g. `cwd` typed as a number, because the schema is deliberately partial; the establisher rejects it as `channel:open_failed` / `handler_error` first, and the handler-side frame is defense-in-depth per ADR-010 §2) | `{"error":"malformed_negotiation","message":"..."}` |
|
||||
| `allocate_failed` | `backend.allocate()` returned a `TtyError` (the failure surface of the **direct path**). On the channels path the establisher runs `allocate` (the handle crosses to the pump handler via alkcall 0.6's `Establishment` plan payload; ADR-010 §2A) and its failure is a `channel:open_failed` call error (`details.reason == "dial_failed"`) | `{"error":"allocate_failed","message":"..."}` |
|
||||
|
||||
After sending the error response, the adapter closes the write half of
|
||||
the bidi stream. The client reads the error frame and treats stream close
|
||||
as the failure signal. There is no `call.error` — this is not the call
|
||||
as the failure signal. There is no `call.error`, as this is not the call
|
||||
protocol; the error is a JSON response in the negotiation framing.
|
||||
**Framing disambiguation (success vs error).** Both a successful
|
||||
allocation (raw chunks) and a failed allocation (JSON error frame) begin
|
||||
with bytes the client must read before knowing which framing applies.
|
||||
The disambiguation is by the first byte: a JSON error frame's 4-byte
|
||||
big-endian length prefix always starts with `0x00` (error frames MUST
|
||||
be under 16 MiB — `MAX_CHUNK_LEN` — so the high byte is zero; this is
|
||||
be under 16 MiB, the `MAX_CHUNK_LEN` limit, so the high byte is zero; this is
|
||||
a wire-format invariant, not an assumption), while a raw chunk's first
|
||||
byte is a `stream_type`. The server never sends `0` (stdin —
|
||||
client→server only) or `3` (`STREAM_CTRL_IN` — client→server only), so
|
||||
byte is a `stream_type`. The server never sends `0` (stdin,
|
||||
client→server only) or `3` (`STREAM_CTRL_IN`, client→server only), so
|
||||
the server-sent set is `{1, 2, 4}` (stdout, stderr, `STREAM_CTRL_OUT`);
|
||||
`0x00` is unambiguous. The client distinguishes: read the first byte; if
|
||||
it is `0x00`, interpret the next 4 bytes as a big-endian length prefix
|
||||
@@ -223,7 +223,7 @@ adapter's session driver, not in the backend. The ordering:
|
||||
backend's stdout ends when the process exits and the PTY/pipe buffer
|
||||
drains (the waiter thread's blocking wait returns when the child is
|
||||
reaped, which
|
||||
happens after the child exits and its stdout drains — ADR-004
|
||||
happens after the child exits and its stdout drains; ADR-004
|
||||
assumption 1).
|
||||
2. The exit task (task C) awaits `TtyHandle.exit_code`. The exit resolves
|
||||
after the child is reaped (the local backend's waiter thread blocks on
|
||||
@@ -239,12 +239,12 @@ adapter's session driver, not in the backend. The ordering:
|
||||
4. After both resolve, the exit chunk (`{"type":"exit","code":N}`) is
|
||||
enqueued on the writer channel.
|
||||
5. The drainer writes the exit chunk to the client.
|
||||
6. The adapter closes the write half — the session ends.
|
||||
6. The adapter closes the write half, and the session ends.
|
||||
|
||||
A client reads stdout/stderr/control chunks until it sees the exit
|
||||
chunk, records the exit code, and treats subsequent stream close as the
|
||||
session end. The exit chunk is the deterministic completion signal —
|
||||
the same stopgap property the docker POC validated for logs subscriptions,
|
||||
session end. The exit chunk is the deterministic completion signal, the
|
||||
same stopgap property the docker POC validated for logs subscriptions,
|
||||
now for any backend.
|
||||
|
||||
If `exit_code` resolves with a `TtyError` (the backend couldn't determine
|
||||
@@ -269,11 +269,11 @@ revokes. The adapter's access control declares against the ADR-050 model:
|
||||
process, an SSH backend's channel). The adapter delegates the
|
||||
resource-id extraction to the backend via
|
||||
`TtyBackend::resource_id(¶ms)` (ADR-002), which returns
|
||||
`None` (no pre-existing resource — the session creates its own) or
|
||||
`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
|
||||
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
|
||||
@@ -283,7 +283,7 @@ revokes. The adapter's access control declares against the ADR-050 model:
|
||||
layer.
|
||||
|
||||
The tty adapter is a `ProtocolHandler`, not an `OperationSpec`-registered
|
||||
operation — it doesn't go through the call protocol's
|
||||
operation; it doesn't go through the call protocol's
|
||||
`OperationRegistry::invoke()`. The access-control shape is the adapter's
|
||||
own (scope-gate + backend-driven ownership check at negotiation),
|
||||
declaring against the ADR-050 model but not consuming
|
||||
@@ -293,12 +293,12 @@ extraction is delegated to the backend via `resource_id()` rather than
|
||||
a path expression). See alknet ADR-050 §"Specifics" for the model this
|
||||
declares against.
|
||||
|
||||
The concrete choice — the scope name (`tty:open`) and the
|
||||
check-at-negotiation timing — is a **two-way-door** choice within the
|
||||
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
|
||||
backends with new resource shapes require no adapter change; the
|
||||
generalization is already in place (ADR-002). No ADR is warranted for
|
||||
the scope name; it is a reversible implementation choice, not an
|
||||
architectural commitment.
|
||||
@@ -309,7 +309,7 @@ architectural commitment.
|
||||
sessions on that connection are cancelled. Each session's pump tasks
|
||||
are dropped (Rust `Drop`); the `TtyHandle` is dropped; the
|
||||
`exit_code` future is dropped without being driven to completion,
|
||||
which triggers the backend's cancel-cleanup — the session target is
|
||||
which triggers the backend's cancel-cleanup: the session target is
|
||||
killed (ADR-005). For the local backend, the `exit_code` future's
|
||||
`Drop` calls `ChildKiller::kill(SIGHUP)`, the child exits, the
|
||||
waiter thread's blocking wait reaps it and exits, and the
|
||||
@@ -320,13 +320,13 @@ architectural commitment.
|
||||
- **Stream reset**: when a bidi stream is reset mid-session, the
|
||||
`ChunkReader` returns a `RawError` (ConnectionClosed or Io). The pump
|
||||
tasks exit; the `TtyHandle` is dropped; the cancel-cleanup runs
|
||||
(ADR-005). No exit chunk is sent — the stream is gone, the client
|
||||
(ADR-005). No exit chunk is sent: the stream is gone, the client
|
||||
that reset it already knows.
|
||||
- **Client cancel**: when the client closes the write half (or sends a
|
||||
zero-length stdin chunk / `eof` control chunk), the adapter signals
|
||||
EOF to the backend's stdin and keeps pumping stdout until the backend's
|
||||
stdout ends and the exit resolves. The session completes normally —
|
||||
the exit chunk is sent — the client just stopped sending input. This
|
||||
stdout ends and the exit resolves. The session completes normally
|
||||
(the exit chunk is sent); the client just stopped sending input. This
|
||||
is NOT a cancel from the adapter's perspective (the session runs to
|
||||
completion); the cancel-cleanup (ADR-005) is not triggered. The
|
||||
cancel-cleanup is triggered only when the *adapter* drops the handle
|
||||
@@ -342,14 +342,14 @@ architectural commitment.
|
||||
- **One session per bidi stream, multiple streams per connection.** A
|
||||
connection hosts multiple sessions (one stream each); the adapter
|
||||
spawns a `drive_session` task per accepted stream. Sessions are
|
||||
independent — one session's exit doesn't affect another.
|
||||
independent; one session's exit doesn't affect another.
|
||||
- **Negotiation errors are JSON, not raw chunks.** The error response
|
||||
uses the negotiation framing (length-prefixed JSON), not the raw chunk
|
||||
format. The stream enters raw mode only after a successful allocation.
|
||||
- **The exit chunk is the deterministic completion signal.** A client
|
||||
reading to completion sees the exit chunk and knows the process exited
|
||||
with code N. A client that cancels mid-stream (closes the write half)
|
||||
won't see the exit chunk — that's correct; a cancelled stream doesn't
|
||||
won't see the exit chunk. That's correct; a cancelled stream doesn't
|
||||
have a deterministic exit.
|
||||
- **The adapter triggers backend cleanup by dropping the `TtyHandle`
|
||||
(ADR-005).** On connection drop, stream reset, or pump-task panic, the
|
||||
@@ -377,33 +377,33 @@ architectural commitment.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- **OQ-45** (resolved): Flow control for high-throughput stdout — no application-level windowing; QUIC per-stream flow control is the backpressure mechanism.
|
||||
- **OQ-45** (resolved): Flow control for high-throughput stdout (no application-level windowing; QUIC per-stream flow control is the backpressure mechanism).
|
||||
|
||||
## References
|
||||
|
||||
- [ADR-001](decisions/001-wire-format-and-two-carriage.md) — the wire
|
||||
- [ADR-001](decisions/001-wire-format-and-two-carriage.md): the wire
|
||||
format the adapter pumps
|
||||
- [ADR-002](decisions/002-ttybackend-trait-and-ttyhandle.md) — the
|
||||
- [ADR-002](decisions/002-ttybackend-trait-and-ttyhandle.md): the
|
||||
backend trait the adapter dispatches to
|
||||
- [ADR-004](decisions/004-exit-code-on-control-chunk.md) — the
|
||||
- [ADR-004](decisions/004-exit-code-on-control-chunk.md): the
|
||||
exit-chunk ordering the adapter enforces
|
||||
- [ADR-005](decisions/005-backend-cleanup-on-session-cancel.md) — the
|
||||
- [ADR-005](decisions/005-backend-cleanup-on-session-cancel.md): the
|
||||
cancel-cleanup contract the adapter triggers by dropping the
|
||||
`TtyHandle` on session cancel
|
||||
- [ADR-008](decisions/008-channels-pure-channel-multiplexing.md) — why
|
||||
- [ADR-008](decisions/008-channels-pure-channel-multiplexing.md): why
|
||||
the same session driver runs in channels mode
|
||||
- [ADR-009](decisions/009-channels-open-op-is-the-negotiation.md) — the
|
||||
- [ADR-009](decisions/009-channels-open-op-is-the-negotiation.md): the
|
||||
channels path carries no second negotiation frame (the open op's
|
||||
registry-validated `input` is the negotiation)
|
||||
- [ADR-010](decisions/010-channels-establisher-migration.md) — the
|
||||
- [ADR-010](decisions/010-channels-establisher-migration.md): the
|
||||
channels path's semantic failures are establisher call errors
|
||||
(`channel:open_failed`); since alkcall 0.6 the establisher also
|
||||
allocates (the `TtyHandle` crosses via the plan payload), so no
|
||||
failure class arrives in-band from a registered producer
|
||||
- alknet ADR-050 — the ownership model the adapter's access control
|
||||
- alknet ADR-050: the ownership model the adapter's access control
|
||||
declares against
|
||||
- alknet ADR-007 — `Connection`, `accept_bi`, the handler-receives-
|
||||
- alknet ADR-007: `Connection`, `accept_bi`, the handler-receives-
|
||||
Connection pattern
|
||||
- `src/adapter.rs` — the Rust source this spec documents
|
||||
- [tty-wire.md](tty-wire.md) — the wire format details
|
||||
- [tty-backend.md](tty-backend.md) — the backend trait details
|
||||
- `src/adapter.rs`: the Rust source this spec documents
|
||||
- [tty-wire.md](tty-wire.md): the wire format details
|
||||
- [tty-backend.md](tty-backend.md): the backend trait details
|
||||
@@ -5,7 +5,7 @@ status: draft (ported from alknet 2026-08-17; alknet-tty → alktty,
|
||||
last_updated: 2026-08-17
|
||||
---
|
||||
|
||||
# alktty — TtyBackend Trait and TtyHandle
|
||||
# alktty: TtyBackend Trait and TtyHandle
|
||||
|
||||
The `TtyBackend` trait is the inversion point that keeps alktty
|
||||
decoupled from its backends. alktty defines the trait, the `TtyParams`
|
||||
@@ -24,30 +24,30 @@ terminal/process session. The adapter holds a
|
||||
adapter reads the negotiation frame, selects the backend by the
|
||||
`backend` field, calls `allocate()`, and pumps the resulting
|
||||
`TtyHandle`'s fields bidirectionally using the chunk format
|
||||
(ADR-001). The backend does not write to the wire — it produces handles;
|
||||
the adapter pumps.
|
||||
(ADR-001). The backend does not write to the wire; it produces handles,
|
||||
and the adapter pumps.
|
||||
|
||||
Three implementations are contemplated, each in its own crate (the
|
||||
no-handler-depends-on-another-handler rule from alknet ADR-003 is
|
||||
preserved — backends depend on alktty for the trait, alktty doesn't
|
||||
preserved: backends depend on alktty for the trait, alktty doesn't
|
||||
depend on them):
|
||||
|
||||
- **`LocalTtyBackend`** (in alktty's `local` feature module,
|
||||
[ADR-003](decisions/003-local-backend-placement.md)) — wraps
|
||||
[ADR-003](decisions/003-local-backend-placement.md)), which wraps
|
||||
`portable_pty` for the PTY case and `std::process::Command` with
|
||||
`Stdio::piped()` for the pipe/runner case. See
|
||||
[tty-local.md](tty-local.md).
|
||||
- **`DockerTtyBackend`** (in `alknet-docker` or a sibling adapter crate
|
||||
— future, out of scope here) — wraps `bollard::attach_container()` for
|
||||
- **`DockerTtyBackend`** (in `alknet-docker` or a sibling adapter crate,
|
||||
future and out of scope here) wraps `bollard::attach_container()` for
|
||||
interactive attach or `bollard::exec::start_exec` with `tty: true` for
|
||||
exec-with-PTY. `control.resize()` calls `bollard::exec::resize_exec`
|
||||
or `bollard::container::resize_container`. stdout/stderr are merged
|
||||
when `tty: true` (bollard's `LogOutput` on a TTY exec returns
|
||||
`StdOut` only), so `TtyHandle.stderr` is `None` for the PTY case.
|
||||
- **`SshTtyBackend`** (in `alknet-ssh` — future, out of scope here) —
|
||||
- **`SshTtyBackend`** (in `alknet-ssh`, future and out of scope here)
|
||||
wraps russh's `pty_request` + `shell_request` (or `exec_request` with
|
||||
a PTY) on a session channel. `channel.into_stream()` gives
|
||||
`(AsyncRead, AsyncWrite)` — the stream *is* the PTY; russh handles
|
||||
`(AsyncRead, AsyncWrite)`; the stream *is* the PTY, and russh handles
|
||||
kernel PTY allocation on the server side. `control.resize()` sends a
|
||||
`window_change` channel request; `control.signal()` sends a `signal`
|
||||
channel request. stdout and stderr are merged (PTY property), so
|
||||
@@ -60,9 +60,9 @@ without re-spec'ing the seam.
|
||||
## Why
|
||||
|
||||
The guiding insight: **a terminal session is not an SSH concern, or a
|
||||
Docker concern — it is a terminal concern. SSH and Docker are just two
|
||||
Docker concern; it is a terminal concern. SSH and Docker are just two
|
||||
backends that can allocate a PTY.** The `TtyBackend` trait is what makes
|
||||
that insight load-bearing — alktty owns the wire format and session
|
||||
that insight load-bearing: alktty owns the wire format and session
|
||||
lifecycle; the backends own PTY allocation. The full rationale (the
|
||||
inversion point, why the trait is the seam) is in
|
||||
[ADR-002](decisions/002-ttybackend-trait-and-ttyhandle.md) §Context.
|
||||
@@ -88,7 +88,7 @@ pub trait TtyBackend: Send + Sync {
|
||||
|
||||
/// The pre-existing resource this session targets, for ownership
|
||||
/// checks (alknet ADR-050). `None` = no pre-existing resource (the session
|
||||
/// creates its own — local process, SSH channel). `Some((kind, id))`
|
||||
/// creates its own; local process, SSH channel). `Some((kind, id))`
|
||||
/// = the session targets an existing resource the caller must own
|
||||
/// (e.g., DockerTtyBackend returns `Some(("container", id))`). The
|
||||
/// adapter calls this at negotiation to gate access; the backend
|
||||
@@ -106,7 +106,7 @@ that allocates a session*; the wire-format pump is backend-agnostic.
|
||||
|
||||
The error type for `allocate()` and `exit_code`. `#[non_exhaustive]` so
|
||||
new variants are additive (two-way-door extension within the one-way
|
||||
trait shape — ADR-002).
|
||||
trait shape; see ADR-002).
|
||||
|
||||
```rust
|
||||
#[non_exhaustive]
|
||||
@@ -123,24 +123,24 @@ pub enum TtyError {
|
||||
}
|
||||
```
|
||||
|
||||
- `AllocFailed` — the PTY couldn't be allocated, the docker exec failed
|
||||
- `AllocFailed`: the PTY couldn't be allocated, the docker exec failed
|
||||
to start, the SSH channel request was rejected. Returned by
|
||||
`allocate()`; on the direct path the adapter sends
|
||||
`{"error":"allocate_failed",...}` and closes (tty-adapter.md
|
||||
§"Negotiation errors"); on the channels path the establisher maps it
|
||||
to `channel:open_failed` / `dial_failed` (ADR-010 §2A).
|
||||
- `WaitFailed` — the backend couldn't reap the child / determine the
|
||||
- `WaitFailed`: the backend couldn't reap the child / determine the
|
||||
exit code. Returned by the `exit_code` future; the adapter sends
|
||||
`{"type":"exit","code":-1}` (ADR-004 §4).
|
||||
- `Io` — an I/O error from a backend's stream/handle.
|
||||
- `Backend` — backend-specific error not covered by the above (e.g., a
|
||||
- `Io`: an I/O error from a backend's stream/handle.
|
||||
- `Backend`: backend-specific error not covered by the above (e.g., a
|
||||
bollard API error, a russh protocol error).
|
||||
|
||||
### `TtyParams` — the allocation request
|
||||
### `TtyParams`: the allocation request
|
||||
|
||||
```rust
|
||||
pub struct TtyParams {
|
||||
/// Terminal parameters. `None` = pipe mode (no PTY — the runner case,
|
||||
/// Terminal parameters. `None` = pipe mode (no PTY, the runner case,
|
||||
/// ADR-003). `Some` = allocate a PTY with these dimensions.
|
||||
pub terminal: Option<TerminalParams>,
|
||||
/// Command vector (argv[0] + args). Non-empty.
|
||||
@@ -163,16 +163,16 @@ pub struct TerminalParams {
|
||||
pub rows: u16,
|
||||
pub pixel_width: u16,
|
||||
pub pixel_height: u16,
|
||||
pub modes: serde_json::Value, // reserved — OQ-44; backends MUST ignore content in v1
|
||||
pub modes: serde_json::Value, // reserved (OQ-44); backends MUST ignore content in v1
|
||||
}
|
||||
```
|
||||
|
||||
`terminal: None` is the pipe/runner case — no PTY, separate
|
||||
stdout/stderr. `terminal: Some` is the PTY case — stdout/stderr merged
|
||||
`terminal: None` is the pipe/runner case (no PTY, separate
|
||||
stdout/stderr). `terminal: Some` is the PTY case (stdout/stderr merged
|
||||
into the single stdout stream (`TtyHandle.stderr` is `None`), real
|
||||
terminal semantics (resize, signal delivery to process group). The
|
||||
per-session choice is the backend's branch in `allocate()`, not a
|
||||
per-deployment choice — see ADR-003.
|
||||
per-deployment choice (see ADR-003).
|
||||
|
||||
### Backend params are opaque
|
||||
|
||||
@@ -180,7 +180,7 @@ per-deployment choice — see ADR-003.
|
||||
a typed enum. The adapter passes the negotiation frame's
|
||||
backend-specific fields through verbatim; the backend deserializes its
|
||||
own strongly-typed params struct. alktty has zero knowledge of any
|
||||
backend's params shape — not docker's `container`, not an SSH host
|
||||
backend's params shape: not docker's `container`, not an SSH host
|
||||
selector, not anything. Each backend defines its own params struct and
|
||||
deserializes from `params.backend_params` inside `allocate()`:
|
||||
|
||||
@@ -197,7 +197,7 @@ impl TtyBackend for DockerTtyBackend {
|
||||
// use p.container ...
|
||||
}
|
||||
fn resource_id(&self, params: &TtyParams) -> Option<(&'static str, String)> {
|
||||
// extract for the ownership check — backend-driven, not adapter-hardcoded
|
||||
// extract for the ownership check: backend-driven, not adapter-hardcoded
|
||||
let p: DockerBackendParams = serde_json::from_value(
|
||||
serde_json::Value::Object(params.backend_params.clone())
|
||||
).ok()?;
|
||||
@@ -209,36 +209,36 @@ impl TtyBackend for DockerTtyBackend {
|
||||
This is a complete inversion: the *trait* is inverted (backends
|
||||
implement, alktty doesn't depend on them) and the *params* are
|
||||
inverted (backends define their own typed shape, alktty doesn't carry
|
||||
it). A new backend crate requires zero changes to alktty — no
|
||||
it). A new backend crate requires zero changes to alktty: no
|
||||
enum variant to add, no forward-reference type to place, no dependency
|
||||
edge. See ADR-002 §"Backend params are opaque" for the full rationale
|
||||
and why the typed-enum alternative (with `SshChannelRef`) was rejected.
|
||||
|
||||
### `TtyHandle` — what a backend produces
|
||||
### `TtyHandle`: what a backend produces
|
||||
|
||||
```rust
|
||||
pub struct TtyHandle {
|
||||
/// Stdin writer — bytes the adapter pumps from client stdin chunks.
|
||||
/// Stdin writer: bytes the adapter pumps from client stdin chunks.
|
||||
/// `tokio::io::AsyncWrite` (the tokio flavor, not the `futures::io`
|
||||
/// one — they are incompatible traits; the tokio stack is the
|
||||
/// one; they are incompatible traits, and the tokio stack is the
|
||||
/// adapter's runtime).
|
||||
pub stdin: Box<dyn tokio::io::AsyncWrite + Send + Unpin>,
|
||||
/// Stdout stream — bytes the adapter pumps to client stdout chunks.
|
||||
/// Stdout stream: bytes the adapter pumps to client stdout chunks.
|
||||
/// Ends when the backend's stdout reaches EOF.
|
||||
/// `futures_core::Stream<Item = bytes::Bytes>` (re-exported by
|
||||
/// `tokio_stream::StreamExt` for extension methods).
|
||||
pub stdout: Pin<Box<dyn futures_core::Stream<Item = bytes::Bytes> + Send>>,
|
||||
/// Stderr stream — `None` for PTY backends (stdout/stderr merged
|
||||
/// Stderr stream: `None` for PTY backends (stdout/stderr merged
|
||||
/// into `stdout`). `Some` for pipe backends (separate streams).
|
||||
pub stderr: Option<Pin<Box<dyn futures_core::Stream<Item = bytes::Bytes> + Send>>>,
|
||||
/// Exit code — a `Future` the adapter awaits. Resolves when the
|
||||
/// Exit code: a `Future` the adapter awaits. Resolves when the
|
||||
/// process/container/SSH exec exits. The adapter sends the result
|
||||
/// as the `{"type":"exit","code":N}` control chunk (ADR-004) and
|
||||
/// closes the stream. This is `BoxFuture`, not a method on
|
||||
/// `TtyHandle`, so the adapter can `select` between exit and
|
||||
/// stream-close without coupling to the other fields. (REQ-TTY-01.)
|
||||
pub exit_code: BoxFuture<'static, Result<i32, TtyError>>,
|
||||
/// Control handle (resize, signal) — `Clone` so the adapter can
|
||||
/// Control handle (resize, signal), `Clone` so the adapter can
|
||||
/// hand it to the spawned control-chunk dispatcher. `None` only
|
||||
/// when the backend genuinely has no control path. See OQ-43.
|
||||
pub control: Option<TtyControlHandle>,
|
||||
@@ -261,7 +261,7 @@ pub trait TtyControl: Send + Sync {
|
||||
}
|
||||
|
||||
/// The `Clone`-able handle to a backend's control path. The `TtyControl`
|
||||
/// trait is NOT `Clone` (`Clone` is not object-safe — `fn clone(&self) ->
|
||||
/// trait is NOT `Clone` (`Clone` is not object-safe: `fn clone(&self) ->
|
||||
/// Self` returns `Self`, which forbids `dyn` dispatch); the `Clone`-ability
|
||||
/// lives on this concrete newtype, which holds the trait object behind an
|
||||
/// `Arc`. The adapter clones the `Arc` to hand a handle to the spawned
|
||||
@@ -278,7 +278,7 @@ impl TtyControlHandle {
|
||||
|
||||
The trait is kept object-safe by NOT putting `Clone` on it; the `Clone`
|
||||
newtype (`TtyControlHandle`) holds the trait object behind an `Arc`. The
|
||||
POC used a concrete `PtyControl` struct (inherently `Clone` — it held
|
||||
POC used a concrete `PtyControl` struct (inherently `Clone`, since it held
|
||||
`Arc<Mutex<...>>` fields); this newtype generalizes the POC's shape so a
|
||||
backend produces its own control type via `TtyControlHandle::new(Arc::new(MyControl))`
|
||||
without the adapter knowing the concrete shape. See OQ-43 for the
|
||||
@@ -288,7 +288,7 @@ itself.
|
||||
### REQ-TTY-01: backends are not required to be natively async
|
||||
|
||||
`portable_pty`'s API is blocking `std::io::{Read, Write}` and a blocking
|
||||
`Child::wait()` — there is no async variant. The local-PTY POC bridges
|
||||
`Child::wait()`; there is no async variant. The local-PTY POC bridges
|
||||
this with three dedicated std threads (reader, writer, waiter) feeding
|
||||
tokio mpsc/oneshot channels; the async-facing `LocalPty` then exposes
|
||||
`mpsc::Receiver<Bytes>` for stdout, `mpsc::Sender<StdinCmd>` for stdin,
|
||||
@@ -298,8 +298,8 @@ and `oneshot::Receiver<i32>` for exit. This is the same pattern wezterm
|
||||
The trait's adapter-facing types (`AsyncWrite`, `Stream<Item = Bytes>`,
|
||||
`BoxFuture`, `TtyControl`) are the **adapter's contract**. A backend may
|
||||
expose blocking handles internally and bridge them to these async-facing
|
||||
types. The bridging pattern — blocking `std::io` on dedicated std threads
|
||||
or `tokio::task::spawn_blocking`, feeding tokio mpsc/oneshot channels —
|
||||
types. The bridging pattern (blocking `std::io` on dedicated std threads
|
||||
or `tokio::task::spawn_blocking`, feeding tokio mpsc/oneshot channels)
|
||||
is a **documented, supported implementation strategy**, not a workaround.
|
||||
|
||||
This resolves the first half of OQ-TTY-01 (the research's open question
|
||||
@@ -339,8 +339,8 @@ layer chooses what's available.
|
||||
The SSH backend crate is future work; this spec commits the
|
||||
trait shape it implements so it can be built against it without
|
||||
re-spec'ing the seam. The `DockerTtyBackend` is future work in
|
||||
`alknet-docker` — the natural extension of the alknet-docker POC's
|
||||
`drive_attach_raw` — with the trait, it becomes `impl TtyBackend for
|
||||
`alknet-docker`, the natural extension of the alknet-docker POC's
|
||||
`drive_attach_raw`; with the trait, it becomes `impl TtyBackend for
|
||||
DockerTtyBackend`. The `SshTtyBackend` dissolves the alknet-ssh
|
||||
research's PTY hedge (DP-5): alknet-ssh's session channel still does
|
||||
`exec` (structured, JSON carriage, exit code on completion) but
|
||||
@@ -367,10 +367,10 @@ inside alknet-ssh.
|
||||
produce handles; the adapter pumps. A backend that wrote to the wire
|
||||
directly would break the wire-format invariants (the exit-chunk
|
||||
ordering, ADR-004). The backend's `exit_code` future resolves and the
|
||||
adapter sends the exit chunk — the backend does not serialize
|
||||
adapter sends the exit chunk; the backend does not serialize
|
||||
`ControlMessage::Exit`.
|
||||
- **PTY backends merge stdout/stderr.** `TtyHandle.stderr` is `None` for
|
||||
the PTY case (kernel PTY property — one output stream from the slave).
|
||||
the PTY case (kernel PTY property: one output stream from the slave).
|
||||
The adapter pumps only stdout chunks (stream_type 1). Pipe backends
|
||||
set `stderr: Some` and the adapter pumps both stdout (stream_type 1)
|
||||
and stderr (stream_type 2) chunks.
|
||||
@@ -383,7 +383,7 @@ inside alknet-ssh.
|
||||
`exit_code` field is a `BoxFuture<'static, Result<i32, TtyError>>`
|
||||
whose `Drop`-on-cancel (i.e., dropped without being driven to
|
||||
completion) MUST kill the child/container/SSH process. This is a
|
||||
behavioral contract on the `TtyBackend` trait — the adapter triggers
|
||||
behavioral contract on the `TtyBackend` trait: the adapter triggers
|
||||
it by dropping the `TtyHandle` on session cancel (connection drop,
|
||||
stream reset); the backend wires the kill into the `exit_code`
|
||||
future's `Drop`. A backend that returns a bare `oneshot::Receiver<i32>`
|
||||
@@ -410,19 +410,19 @@ inside alknet-ssh.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR-002](decisions/002-ttybackend-trait-and-ttyhandle.md) — the
|
||||
- [ADR-002](decisions/002-ttybackend-trait-and-ttyhandle.md): the
|
||||
trait shape decision (this spec is its elaboration)
|
||||
- [ADR-001](decisions/001-wire-format-and-two-carriage.md) — the wire
|
||||
- [ADR-001](decisions/001-wire-format-and-two-carriage.md): the wire
|
||||
format the adapter pumps to/from these handles
|
||||
- [ADR-004](decisions/004-exit-code-on-control-chunk.md) — the
|
||||
- [ADR-004](decisions/004-exit-code-on-control-chunk.md): the
|
||||
exit-chunk ordering the `exit_code` field feeds into
|
||||
- [ADR-005](decisions/005-backend-cleanup-on-session-cancel.md) — the
|
||||
- [ADR-005](decisions/005-backend-cleanup-on-session-cancel.md): the
|
||||
cancel-cleanup contract on this trait (`exit_code` future's
|
||||
`Drop`-on-cancel kills the session target)
|
||||
- [ADR-003](decisions/003-local-backend-placement.md) — the local
|
||||
- [ADR-003](decisions/003-local-backend-placement.md): the local
|
||||
backend's placement (folded into alktty behind a `local` feature)
|
||||
- `src/backend.rs` — the Rust source this spec documents
|
||||
- [tty-local.md](tty-local.md) — the `LocalTtyBackend` spec (carries
|
||||
- `src/backend.rs`: the Rust source this spec documents
|
||||
- [tty-local.md](tty-local.md): the `LocalTtyBackend` spec (carries
|
||||
REQ-TTY-02: signal forwarding to the process group)
|
||||
- [tty-adapter.md](tty-adapter.md) — the session driver that consumes
|
||||
- [tty-adapter.md](tty-adapter.md): the session driver that consumes
|
||||
these handles
|
||||
@@ -3,7 +3,7 @@ status: draft
|
||||
last_updated: 2026-08-17
|
||||
---
|
||||
|
||||
# alktty — BAST Document for the `alk/tty` Wire Format
|
||||
# alktty: BAST Document for the `alk/tty` Wire Format
|
||||
|
||||
This document is the **BAST (Binary Abstract Syntax Tree)** specification
|
||||
for the binary portions of the `alk/tty` wire format. It is a normative
|
||||
@@ -24,7 +24,7 @@ and [ADR-006](decisions/006-negotiation-framing-self-contained.md).
|
||||
|
||||
The `alk/tty` wire format mixes a small binary framing layer with
|
||||
UTF-8 JSON payloads for the structured parts. Per the BAST format
|
||||
spec, a BAST document describes **binary data layouts** — it is not a
|
||||
spec, a BAST document describes **binary data layouts**; it is not a
|
||||
format for validating JSON payloads ([`validate_bytes` vs
|
||||
`validate_json`](https://alk.dev/bast/v1/spec)). This document
|
||||
therefore specifies only the binary framing; the JSON shapes are
|
||||
@@ -36,25 +36,25 @@ The binary portions this BAST covers (both one-way doors per
|
||||
[ADR-001](decisions/001-wire-format-and-two-carriage.md)):
|
||||
|
||||
1. **The 5-byte chunk header** (`[stream_type: u8][length: u32 BE]
|
||||
[payload]`) — the raw chunk codec in `src/wire.rs`. Five stream
|
||||
[payload]`), the raw chunk codec in `src/wire.rs`. Five stream
|
||||
types: `STREAM_STDIN=0`, `STREAM_STDOUT=1`, `STREAM_STDERR=2`,
|
||||
`STREAM_CTRL_IN=3`, `STREAM_CTRL_OUT=4`.
|
||||
2. **The negotiation frame length prefix** (4-byte BE `u32` followed
|
||||
by `length` bytes of UTF-8 JSON `NegotiateRequest` body) —
|
||||
by `length` bytes of UTF-8 JSON `NegotiateRequest` body),
|
||||
self-contained per
|
||||
[ADR-006](decisions/006-negotiation-framing-self-contained.md).
|
||||
|
||||
What this BAST deliberately does **not** cover:
|
||||
|
||||
- **`NegotiateRequest` JSON shape** — a UTF-8 JSON object, not a
|
||||
- **`NegotiateRequest` JSON shape**: a UTF-8 JSON object, not a
|
||||
binary layout. Specified in [tty-wire.md](tty-wire.md) §"Phase 1"
|
||||
and implemented by `NegotiateRequest` in `src/negotiation.rs`.
|
||||
- **`ControlMessage` JSON shape** — the UTF-8 JSON payloads of
|
||||
- **`ControlMessage` JSON shape**: the UTF-8 JSON payloads of
|
||||
`STREAM_CTRL_IN`/`STREAM_CTRL_OUT` chunks (the `resize`/`signal`/
|
||||
`eof`/`exit` variants). Specified in [tty-wire.md](tty-wire.md)
|
||||
§"Control Channel" and implemented by `ControlMessage` in
|
||||
`src/control.rs`.
|
||||
- **`TerminalParams` JSON shape** — the `tty` field of
|
||||
- **`TerminalParams` JSON shape**: the `tty` field of
|
||||
`NegotiateRequest`. Specified in [tty-wire.md](tty-wire.md) §"Phase
|
||||
1" and implemented by `TerminalParamsWire` in
|
||||
`src/negotiation.rs`.
|
||||
@@ -67,8 +67,8 @@ struct-encoded binary. The JSON field types (`u16`, `i32`, `String`)
|
||||
are JSON value types produced and consumed by `serde_json`, not binary
|
||||
field widths. Describing them as BAST structs implied a binary
|
||||
encoding that does not exist on the wire and would have misled any
|
||||
generated validator. The simpler model — BAST for the binary framing,
|
||||
prose + Rust source for the JSON payloads — matches what the wire
|
||||
generated validator. The simpler model, BAST for the binary framing
|
||||
and prose + Rust source for the JSON payloads, matches what the wire
|
||||
format actually is.
|
||||
|
||||
## The BAST document
|
||||
@@ -114,7 +114,7 @@ format actually is.
|
||||
|
||||
## Annotations
|
||||
|
||||
### `ChunkHeader` — binary, exact
|
||||
### `ChunkHeader`: binary, exact
|
||||
|
||||
Maps 1:1 to the on-wire bytes. 5 bytes: `stream_type` as `uint8` (1
|
||||
byte), `length` as `uint32` big-endian (4 bytes). This is the 5-byte
|
||||
@@ -123,12 +123,12 @@ The Rust types are `STREAM_STDIN`/`STREAM_STDOUT`/`STREAM_STDERR`/
|
||||
`STREAM_CTRL_IN`/`STREAM_CTRL_OUT` and `CHUNK_HEADER_LEN = 5` in
|
||||
`src/wire.rs`.
|
||||
|
||||
### `StreamType` — enum, documented deviation: on-wire encoding is `uint8`, not BAST's standard `u32` enum index
|
||||
### `StreamType`: enum, documented deviation: on-wire encoding is `uint8`, not BAST's standard `u32` enum index
|
||||
|
||||
The BAST meta-schema specifies that an `enum`'s binary representation
|
||||
is a `u32` index into `values` (0-based). The `alk/tty` wire format
|
||||
encodes `stream_type` as a **`uint8`** (1 byte), not a `u32` (4
|
||||
bytes) — the chunk header is 5 bytes, not 8. This is a deliberate
|
||||
bytes); the chunk header is 5 bytes, not 8. This is a deliberate
|
||||
deviation: the chunk header needs a 1-byte discriminator, and 4 bytes
|
||||
of padding would double the per-chunk overhead.
|
||||
|
||||
@@ -149,9 +149,10 @@ integer→name table for validation and diagnostics.
|
||||
|
||||
`stream_type > 4` is a protocol error (`InvalidStreamType`); there is
|
||||
no extension escape hatch in the byte (a 6th channel is a wire-format
|
||||
change requiring a new ALPN — [ADR-001](decisions/001-wire-format-and-two-carriage.md)).
|
||||
change requiring a new ALPN; see
|
||||
[ADR-001](decisions/001-wire-format-and-two-carriage.md)).
|
||||
|
||||
### `Chunk` — binary, exact
|
||||
### `Chunk`: binary, exact
|
||||
|
||||
A chunk is the 5-byte `ChunkHeader` followed by `length` bytes of
|
||||
payload. The `payload` field is `bytes` with `encoding:
|
||||
@@ -166,16 +167,16 @@ in `src/wire.rs`). The `payload`'s interpretation depends on
|
||||
- `CtrlIn`/`CtrlOut` (3/4): UTF-8 JSON text (a serialized
|
||||
`ControlMessage`). Control chunks are never zero-length (the JSON
|
||||
payload is at least `{}`). The JSON shape is **not** specified by
|
||||
this BAST — see [tty-wire.md](tty-wire.md) §"Control Channel" and
|
||||
this BAST; see [tty-wire.md](tty-wire.md) §"Control Channel" and
|
||||
`src/control.rs`.
|
||||
|
||||
### `NegotiationFrame` — binary, exact (out-of-band for the chunk codec)
|
||||
### `NegotiationFrame`: binary, exact (out-of-band for the chunk codec)
|
||||
|
||||
The negotiation frame is a 4-byte big-endian length prefix + a UTF-8
|
||||
JSON body. This maps 1:1 to the on-wire bytes: `length` as `uint32`
|
||||
big-endian (4 bytes), `body` as a length-prefixed byte string
|
||||
(`length` bytes of UTF-8 JSON). The `maxLength: 16777216` (16 MiB)
|
||||
constraint is the same as `MAX_CHUNK_LEN` — error frames MUST be under
|
||||
constraint is the same as `MAX_CHUNK_LEN`: error frames MUST be under
|
||||
16 MiB so the 4-byte length prefix's high byte is `0x00`, which is
|
||||
what makes the framing-disambiguation trick sound (first byte `0x00`
|
||||
= error/negotiation frame; first byte `1`/`2`/`4` = raw chunk; see
|
||||
@@ -200,22 +201,22 @@ See [ADR-006](decisions/006-negotiation-framing-self-contained.md).
|
||||
|
||||
The BAST document above is validatable in two ways:
|
||||
|
||||
1. **Structural validation** — run any JSON Schema Draft 2020-12
|
||||
1. **Structural validation**: run any JSON Schema Draft 2020-12
|
||||
validator against the BAST meta-schema at
|
||||
`https://alk.dev/bast/v1/schema`. This checks whether the BAST is
|
||||
well-formed (correct `kind` strings, required fields present,
|
||||
`$ref` targets exist). It does NOT check whether a binary payload
|
||||
conforms to the layout — that is the BAST-native validator's job
|
||||
conforms to the layout; that is the BAST-native validator's job
|
||||
(see the alktype BAST format spec,
|
||||
`/workspace/@alkdev/alktype/docs/architecture/bast-format.md`).
|
||||
2. **Drift detection** — a cheap test that parses the BAST document
|
||||
2. **Drift detection**: a cheap test that parses the BAST document
|
||||
with `serde_json` and asserts the `StreamType` enum values match
|
||||
`wire.rs`'s `STREAM_STDIN`/`STREAM_STDOUT`/`STREAM_STDERR`/
|
||||
`STREAM_CTRL_IN`/`STREAM_CTRL_OUT` constants (the index of each
|
||||
name in `values` is the integer constant). This catches the common
|
||||
drift case (a new stream_type added to `wire.rs` but not the BAST,
|
||||
or vice versa) without requiring alktype as a dep — just
|
||||
`serde_json`, which alktty already has. See the project setup plan's
|
||||
or vice versa) without requiring alktype as a dep (just
|
||||
`serde_json`, which alktty already has). See the project setup plan's
|
||||
"Risk: BAST schema drift" mitigation.
|
||||
|
||||
## Design Decisions
|
||||
@@ -228,22 +229,22 @@ The BAST document above is validatable in two ways:
|
||||
|
||||
## References
|
||||
|
||||
- [ADR-001](decisions/001-wire-format-and-two-carriage.md) — the wire
|
||||
- [ADR-001](decisions/001-wire-format-and-two-carriage.md), the wire
|
||||
format decision this BAST specifies
|
||||
- [ADR-006](decisions/006-negotiation-framing-self-contained.md) — the
|
||||
- [ADR-006](decisions/006-negotiation-framing-self-contained.md): the
|
||||
negotiation framing is self-contained (the `NegotiationFrame` is not
|
||||
reused from alkcall)
|
||||
- [tty-wire.md](tty-wire.md) — the prose wire format spec this BAST
|
||||
- [tty-wire.md](tty-wire.md), the prose wire format spec this BAST
|
||||
formalizes (covers both the binary framing and the JSON payloads)
|
||||
- `src/wire.rs` — the runtime chunk codec (`ChunkReader`/`ChunkWriter`,
|
||||
- `src/wire.rs`: the runtime chunk codec (`ChunkReader`/`ChunkWriter`,
|
||||
`STREAM_*` constants, `MAX_CHUNK_LEN`) this BAST describes
|
||||
- `src/negotiation.rs` — the `NegotiationReader`/`NegotiationWriter`
|
||||
- `src/negotiation.rs`: the `NegotiationReader`/`NegotiationWriter`
|
||||
for the `NegotiationFrame`, and `NegotiateRequest` (JSON shape, out
|
||||
of BAST scope)
|
||||
- `src/control.rs` — `ControlMessage` (JSON shape, out of BAST scope)
|
||||
- [alktype BAST format spec](https://alk.dev/bast/v1/schema) — the
|
||||
- `src/control.rs`: `ControlMessage` (JSON shape, out of BAST scope)
|
||||
- [alktype BAST format spec](https://alk.dev/bast/v1/schema), the
|
||||
normative format spec for BAST documents (the meta-schema this
|
||||
document conforms to); see also
|
||||
`/workspace/@alkdev/alktype/docs/architecture/bast-format.md`
|
||||
- Project setup plan, "Risk: BAST schema drift" — the drift-detection
|
||||
- Project setup plan, "Risk: BAST schema drift": the drift-detection
|
||||
test mitigation (`docs/plans/project-setup.md`)
|
||||
@@ -5,11 +5,11 @@ status: draft (ported from alknet 2026-08-17; alknet-tty → alktty,
|
||||
last_updated: 2026-08-17
|
||||
---
|
||||
|
||||
# alktty — Wire Format
|
||||
# alktty: Wire Format
|
||||
|
||||
The wire protocol for `alk/tty`: the negotiation frame (JSON carriage),
|
||||
the raw chunk codec, the control channel (split into `STREAM_CTRL_IN` /
|
||||
`STREAM_CTRL_OUT` halves — Phase 7), and the sentinels. The
|
||||
`STREAM_CTRL_OUT` halves in Phase 7), and the sentinels. The
|
||||
two-carriage model is decided in [ADR-001](decisions/001-wire-format-and-two-carriage.md);
|
||||
this document specifies what an implementer builds.
|
||||
|
||||
@@ -39,8 +39,8 @@ channel set (five stream types, no negotiation) is an impoverishment of
|
||||
SSH's channel multiplexer that is the feature: alktty multiplexes *one*
|
||||
service (a terminal session) with a fixed channel structure, not
|
||||
*arbitrary* services, so the demux is a `match`, not a hash lookup. The
|
||||
full rationale — why not JSON for everything, why fixed channel set
|
||||
rather than extensible — is in
|
||||
full rationale (why not JSON for everything, why a fixed channel set
|
||||
rather than an extensible one) is in
|
||||
[ADR-001](decisions/001-wire-format-and-two-carriage.md) §Context.
|
||||
|
||||
## Architecture
|
||||
@@ -49,11 +49,11 @@ rather than extensible — is in
|
||||
|
||||
The client opens a bidi stream (or the server accepts one) and writes a
|
||||
single length-prefixed JSON frame. The framing is a 4-byte big-endian
|
||||
length prefix + UTF-8 JSON body — a self-contained ~30-line module in
|
||||
length prefix + UTF-8 JSON body, a self-contained ~30-line module in
|
||||
alktty (read 4-byte length, bounds-check, read N bytes; write the
|
||||
inverse) on tokio's `AsyncRead`/`AsyncWrite`. The format coincides with
|
||||
alkcall's `EventEnvelope` framing by convention (both are
|
||||
length-prefixed JSON), not by code reuse — alktty does not depend on
|
||||
length-prefixed JSON), not by code reuse; alktty does not depend on
|
||||
alkcall's internal wire types. The negotiation payload is a tty-specific
|
||||
struct (`NegotiateRequest`), not a `call.requested` event. See ADR-001
|
||||
§6 and [ADR-006](decisions/006-negotiation-framing-self-contained.md).
|
||||
@@ -80,7 +80,7 @@ The payload shape:
|
||||
|
||||
Fields:
|
||||
|
||||
- `carriage` — `"raw"` for terminal sessions (the only carriage in v1).
|
||||
- `carriage`: `"raw"` for terminal sessions (the only carriage in v1).
|
||||
Selects the post-negotiation byte format. MUST be `"raw"` in v1; any
|
||||
other value (e.g., `"json"`, an unknown carriage, or the field
|
||||
absent) is a `malformed_negotiation` error and the adapter closes the
|
||||
@@ -88,19 +88,19 @@ Fields:
|
||||
structured JSON-only mode for a non-terminal use case) is a v2
|
||||
addition; in v1 the field is required and must be the literal
|
||||
`"raw"`.
|
||||
- `backend` — the backend selector string (`"local"`, `"docker"`,
|
||||
- `backend`: the backend selector string (`"local"`, `"docker"`,
|
||||
`"ssh"`). The adapter dispatches to the registered `TtyBackend` by this
|
||||
key (ADR-002 §5).
|
||||
- `tty` — terminal parameters. `null` for the pipe/runner case (no PTY —
|
||||
- `tty`: terminal parameters. `null` for the pipe/runner case (no PTY;
|
||||
[ADR-003](decisions/003-local-backend-placement.md)). `Some` for the PTY case. The `tty` block maps directly to
|
||||
SSH's `pty_request` parameters (term, cols, rows, pixel_width,
|
||||
pixel_height, modes) and to docker's `CreateExecOptions { tty: true }`;
|
||||
a local backend passes it to `portable_pty::PtySystem::openpty`. The
|
||||
`modes` field is reserved (OQ-44 — default terminal modes suffice for
|
||||
`modes` field is reserved (OQ-44: default terminal modes suffice for
|
||||
the current scope).
|
||||
- `cmd` — command vector (argv[0] + args). Non-empty.
|
||||
- `cwd` — working directory (`null` = inherit/default).
|
||||
- `env` — environment variables (empty = inherit).
|
||||
- `cmd`: command vector (argv[0] + args). Non-empty.
|
||||
- `cwd`: working directory (`null` = inherit/default).
|
||||
- `env`: environment variables (empty = inherit).
|
||||
|
||||
The Rust struct the adapter parses the frame into:
|
||||
|
||||
@@ -130,7 +130,7 @@ pub struct TerminalParamsWire {
|
||||
#[serde(default)]
|
||||
pub pixel_height: u16,
|
||||
#[serde(default)]
|
||||
pub modes: serde_json::Value, // reserved — OQ-44; backends MUST ignore content in v1
|
||||
pub modes: serde_json::Value, // reserved (OQ-44); backends MUST ignore content in v1
|
||||
}
|
||||
```
|
||||
|
||||
@@ -149,11 +149,11 @@ 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 remaining backend-specific
|
||||
fields to the selected backend's `allocate()` as an opaque
|
||||
`serde_json::Map` (ADR-002) — the adapter does not interpret them; the
|
||||
`serde_json::Map` (ADR-002). 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.
|
||||
no `call.responded`/`call.completed`: this is not the call protocol.
|
||||
|
||||
### Phase 2: Raw Chunk Format
|
||||
|
||||
@@ -161,7 +161,7 @@ no `call.responded`/`call.completed` — this is not the call protocol.
|
||||
[stream_type: u8][length: u32 be][payload bytes]
|
||||
```
|
||||
|
||||
- **`stream_type`** (1 byte) — the channel:
|
||||
- **`stream_type`** (1 byte): the channel:
|
||||
|
||||
| stream_type | channel | direction | payload |
|
||||
|-------------|-------------|----------------|---------------------|
|
||||
@@ -172,7 +172,7 @@ no `call.responded`/`call.completed` — this is not the call protocol.
|
||||
| 4 | ctrl-out | server→client | JSON control message (`Exit`) |
|
||||
|
||||
`stream_type > 4` is a protocol error (`InvalidStreamType`). There is
|
||||
no extension escape hatch in the byte — a 6th channel is a wire-format
|
||||
no extension escape hatch in the byte: a 6th channel is a wire-format
|
||||
change requiring a new ALPN (`alk/tty/v2` per alknet ADR-006), not a
|
||||
negotiated addition to this format. See ADR-001 §"Fixed channel set,
|
||||
not extensible."
|
||||
@@ -190,11 +190,11 @@ no `call.responded`/`call.completed` — this is not the call protocol.
|
||||
violation and is ignored; a `Resize` arriving on `STREAM_CTRL_OUT` is
|
||||
likewise a protocol violation and is ignored).
|
||||
|
||||
- **`length`** (4 bytes, big-endian) — payload length in bytes. Max
|
||||
- **`length`** (4 bytes, big-endian): payload length in bytes. Max
|
||||
16 MiB (`MAX_CHUNK_LEN = 16 * 1024 * 1024`). A chunk larger than 16 MiB
|
||||
is a protocol error (`ChunkTooLarge`).
|
||||
|
||||
- **`payload`** (`length` bytes) — the raw bytes (for data channels) or
|
||||
- **`payload`** (`length` bytes): the raw bytes (for data channels) or
|
||||
UTF-8 JSON (for the control channel).
|
||||
|
||||
The codec is `ChunkReader`/`ChunkWriter` in `src/wire.rs`:
|
||||
@@ -206,15 +206,15 @@ writes the header and payload. See ADR-001.
|
||||
|
||||
Zero-length data chunks are sentinels:
|
||||
|
||||
- **Zero-length stdin chunk (stream_type 0, length 0)** — EOF from the
|
||||
- **Zero-length stdin chunk (stream_type 0, length 0)**: EOF from the
|
||||
client. The server closes the backend's stdin (`ChildStdin::drop` /
|
||||
PTY writer close). This is one of two canonical "stdin done" signals;
|
||||
the other is a `{"type":"eof"}` control chunk — see OQ-47.
|
||||
- **Zero-length stdout chunk (stream_type 1, length 0)** — "drained"
|
||||
the other is a `{"type":"eof"}` control chunk (see OQ-47).
|
||||
- **Zero-length stdout chunk (stream_type 1, length 0)**: "drained"
|
||||
from the server. The backend's stdout stream ended (process exited,
|
||||
container output stream ended, SSH channel closed). This is an
|
||||
implementation sentinel; the deterministic completion signal is the
|
||||
exit control chunk ([ADR-004](decisions/004-exit-code-on-control-chunk.md)), not this sentinel — but the drained
|
||||
exit control chunk ([ADR-004](decisions/004-exit-code-on-control-chunk.md)), not this sentinel, but the drained
|
||||
sentinel is emitted for symmetry with the docker POC's pattern.
|
||||
|
||||
Control chunks are never zero-length (the JSON payload is at least
|
||||
@@ -224,8 +224,8 @@ Control chunks are never zero-length (the JSON payload is at least
|
||||
|
||||
The control channel is split into two halves (Phase 7):
|
||||
|
||||
- **`STREAM_CTRL_IN` (stream_type 3)** — client→server control.
|
||||
- **`STREAM_CTRL_OUT` (stream_type 4)** — server→client control.
|
||||
- **`STREAM_CTRL_IN` (stream_type 3)**: client→server control.
|
||||
- **`STREAM_CTRL_OUT` (stream_type 4)**: server→client control.
|
||||
|
||||
Each half carries JSON payloads tagged by `type`. The schema is the
|
||||
`ControlMessage` enum (`src/control.rs`):
|
||||
@@ -259,7 +259,7 @@ The adapter enforces the direction: an `Exit` arriving on
|
||||
`STREAM_CTRL_IN` is a protocol violation (the adapter ignores it); a
|
||||
`Resize`/`Signal`/`Eof` arriving on `STREAM_CTRL_OUT` is likewise a
|
||||
protocol violation (the adapter ignores it). The split makes the
|
||||
control channel genuinely bidirectional on the wire — the previous
|
||||
control channel genuinely bidirectional on the wire: the previous
|
||||
single `STREAM_CONTROL = 3` was documented as "bidirectional" but the
|
||||
adapter had to ignore `Exit` from the client because the two directions
|
||||
were indistinguishable on the same stream_type.
|
||||
@@ -267,14 +267,14 @@ were indistinguishable on the same stream_type.
|
||||
**Signal names.** `name` is an uppercase string. The supported set (per
|
||||
`signal_from_name` in `src/control.rs`): `HUP`, `INT`, `QUIT`, `TERM`,
|
||||
`KILL`, `USR1`, `USR2`, `TSTP`, `CONT`. Unknown names fall back to the
|
||||
backend's default kill (see [tty-local.md](tty-local.md) REQ-TTY-02 —
|
||||
backend's default kill (see [tty-local.md](tty-local.md) REQ-TTY-02:
|
||||
`portable_pty`'s `ChildKiller::kill` sends SIGHUP).
|
||||
|
||||
**Exit code.** `code` is `i32`: `ExitStatus::code()` on normal exit; on
|
||||
Unix, the negative signal number on signal-terminated exit — the crate's
|
||||
own convention, not `ExitStatus::code()`'s, which returns `None` for
|
||||
signal termination. `-1` is the adapter's "could not determine" sentinel
|
||||
and (via the `-N` encoding) the SIGHUP value — the SIGHUP/sentinel
|
||||
Unix, the negative signal number on signal-terminated exit; this is the
|
||||
crate's own convention, not `ExitStatus::code()`'s, which returns `None`
|
||||
for signal termination. `-1` is the adapter's "could not determine" sentinel
|
||||
and (via the `-N` encoding) the SIGHUP value; the SIGHUP/sentinel
|
||||
collision is a documented edge case (ADR-004). The exit chunk is the
|
||||
last control chunk before stream close (ADR-004).
|
||||
|
||||
@@ -283,7 +283,7 @@ message types are added by extending the tagged enum. Unknown `type`
|
||||
values are **ignored** (not a protocol error) so that a newer client
|
||||
sending a control message an older server doesn't recognize degrades
|
||||
gracefully rather than tearing down the session. This is a two-way-door
|
||||
extension point within the one-way-door wire format (ADR-001) — adding a
|
||||
extension point within the one-way-door wire format (ADR-001): adding a
|
||||
control message type is additive; changing the meaning of an existing
|
||||
type is not.
|
||||
|
||||
@@ -292,12 +292,12 @@ type is not.
|
||||
Two signals both close the client's stdin:
|
||||
|
||||
1. **`{"type":"eof"}` control chunk** (stream_type 3, `STREAM_CTRL_IN`)
|
||||
— explicit, recommended. Tells the server to close the backend's
|
||||
is explicit and recommended. Tells the server to close the backend's
|
||||
stdin (`ChildStdin::drop` / PTY writer close). The client may still
|
||||
want to receive remaining stdout + the exit code, so the server does
|
||||
not tear down the session on eof — it just closes stdin and keeps
|
||||
not tear down the session on eof; it just closes stdin and keeps
|
||||
pumping output.
|
||||
2. **Zero-length stdin chunk** (stream_type 0, length 0) — the docker
|
||||
2. **Zero-length stdin chunk** (stream_type 0, length 0), the docker
|
||||
POC's sentinel. Accepted for compatibility with that pattern.
|
||||
|
||||
The spec recommends `eof` for explicitness (it's a control message, not
|
||||
@@ -306,14 +306,14 @@ a data-length hack), but both are accepted. See OQ-47.
|
||||
### Connection vs Stream
|
||||
|
||||
A `Connection` (alknet ADR-007) can open/accept multiple bidi streams. One
|
||||
`alk/tty` connection hosts multiple terminal sessions — one session per
|
||||
`alk/tty` connection hosts multiple terminal sessions, one session per
|
||||
bidi stream (DP-6, decided in the alknet research). This matches the call
|
||||
protocol's model (one operation per stream, multiple operations per
|
||||
connection) and is the natural fit for QUIC's stream multiplexing. A
|
||||
coordinator opens one connection to an endpoint and launches multiple
|
||||
sessions (one stream each) for parallel tasks. The `TtyAdapter::handle`
|
||||
accepts the connection and loops `accept_bi`, dispatching each stream to
|
||||
a session — see [tty-adapter.md](tty-adapter.md).
|
||||
a session (see [tty-adapter.md](tty-adapter.md)).
|
||||
|
||||
## Constraints
|
||||
|
||||
@@ -326,13 +326,13 @@ a session — see [tty-adapter.md](tty-adapter.md).
|
||||
`STREAM_CTRL_OUT = 4` is server→client (`Exit`). The adapter enforces
|
||||
the direction: an `Exit` on `STREAM_CTRL_IN` is ignored; a `Resize` on
|
||||
`STREAM_CTRL_OUT` is ignored. The split is what makes the control
|
||||
channel genuinely bidirectional on the wire — the previous single
|
||||
channel genuinely bidirectional on the wire: the previous single
|
||||
`STREAM_CONTROL = 3` was documented as "bidirectional" but the adapter
|
||||
had to ignore `Exit` from the client because the two directions were
|
||||
indistinguishable on the same stream_type.
|
||||
- **No windowing.** The chunk format has no flow-control window; QUIC's
|
||||
per-stream flow control is the backpressure mechanism (OQ-45 resolved:
|
||||
the backpressure chain is complete by construction — QUIC flow control
|
||||
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
|
||||
@@ -341,16 +341,16 @@ a session — see [tty-adapter.md](tty-adapter.md).
|
||||
- **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
|
||||
the client sends data" step — QUIC's stream reliability handles
|
||||
the client sends data" step, as QUIC's stream reliability handles
|
||||
in-order delivery, and the negotiation frame is small (fits in the
|
||||
initial flow-control window — ADR-001 assumption 2).
|
||||
initial flow-control window; ADR-001 assumption 2).
|
||||
- **Negotiation errors are JSON, not chunks.** If the server cannot
|
||||
allocate the session (unknown backend, PTY allocation failed, the
|
||||
command is invalid), it sends a JSON error response in the same
|
||||
length-prefixed framing as the negotiation frame and closes the stream
|
||||
without entering raw mode. The error response MUST be under 16 MiB
|
||||
(`MAX_CHUNK_LEN`) so the 4-byte big-endian length prefix's high byte
|
||||
is `0x00` — this is what makes the framing-disambiguation trick
|
||||
is `0x00`; this is what makes the framing-disambiguation trick
|
||||
(first byte `0x00` = error frame, first byte `1`/`2`/`4` = raw chunk;
|
||||
the server never sends `0` (stdin, client→server) or `3`
|
||||
(`STREAM_CTRL_IN`, client→server), so `0x00` is unambiguous) sound; it
|
||||
@@ -370,34 +370,34 @@ a session — see [tty-adapter.md](tty-adapter.md).
|
||||
## Open Questions
|
||||
|
||||
- **OQ-44** (deferred(scope)): Terminal modes.
|
||||
- **OQ-45** (resolved): Flow control for high-throughput stdout — no
|
||||
- **OQ-45** (resolved): Flow control for high-throughput stdout (no
|
||||
application-level windowing; QUIC per-stream flow control is the
|
||||
backpressure mechanism.
|
||||
backpressure mechanism).
|
||||
- **OQ-47** (resolved): Stdin closure canonical signal.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR-001](decisions/001-wire-format-and-two-carriage.md) — the wire
|
||||
- [ADR-001](decisions/001-wire-format-and-two-carriage.md): the wire
|
||||
format decision
|
||||
- [ADR-004](decisions/004-exit-code-on-control-chunk.md) — the
|
||||
- [ADR-004](decisions/004-exit-code-on-control-chunk.md): the
|
||||
exit-chunk ordering the control channel carries
|
||||
- [ADR-006](decisions/006-negotiation-framing-self-contained.md) — the
|
||||
- [ADR-006](decisions/006-negotiation-framing-self-contained.md): the
|
||||
dependency-edge decision (negotiation framing is self-contained in
|
||||
alktty)
|
||||
- alknet ADR-003 Amendment 2 — alktty does not depend on alknet-call
|
||||
- alknet ADR-003 Amendment 2: alktty does not depend on alknet-call
|
||||
(self-contained framing); see the alknet originals at
|
||||
`/workspace/@alkdev/alknet/docs/architecture/decisions/`
|
||||
- `src/wire.rs` — the chunk codec (`ChunkReader`/`ChunkWriter`,
|
||||
- `src/wire.rs`: the chunk codec (`ChunkReader`/`ChunkWriter`,
|
||||
stream_type 0-4) this spec documents
|
||||
- `src/control.rs` — the JSON control schema (`ControlMessage` tagged
|
||||
- `src/control.rs`: the JSON control schema (`ControlMessage` tagged
|
||||
enum) this spec documents
|
||||
- [tty-bast.md](tty-bast.md) — the BAST (Binary Abstract Syntax Tree)
|
||||
- [tty-bast.md](tty-bast.md): the BAST (Binary Abstract Syntax Tree)
|
||||
document for the binary framing layer of this wire format (the
|
||||
5-byte chunk header and the negotiation frame's 4-byte length
|
||||
prefix); a normative JSON spec downstream consumers can validate
|
||||
against. The JSON payloads (`NegotiateRequest`, `ControlMessage`,
|
||||
`TerminalParams`) are specified in this document and the Rust
|
||||
source, not in the BAST — BAST describes binary layouts, not JSON
|
||||
source, not in the BAST; BAST describes binary layouts, not JSON
|
||||
shapes
|
||||
- [tty-adapter.md](tty-adapter.md) — the session lifecycle that consumes
|
||||
- [tty-adapter.md](tty-adapter.md): the session lifecycle that consumes
|
||||
this wire format
|
||||
+41
-41
@@ -1,6 +1,6 @@
|
||||
# Fuzzing alktty: Plan
|
||||
|
||||
**Status:** adopted — implementation in progress (see §8 progress log)
|
||||
**Status:** adopted; implementation in progress (see §8 progress log)
|
||||
**Date:** 2026-09-28
|
||||
**Research input:** `/workspace/@alkdev/alkcall/docs/research/fuzzing.md` (the
|
||||
alkcall fuzzing evaluation; the toolchain decision, corpus policy, detached
|
||||
@@ -13,7 +13,7 @@ re-derived).
|
||||
|
||||
**Yes.** Alktty is the *named follow-up* in the alkcall research: §5
|
||||
"Non-surfaces" explicitly defers the downstream parsers to "downstream
|
||||
crates' fuzz targets" — the channels layer carries opaque `Bytes` and the
|
||||
crates' fuzz targets": the channels layer carries opaque `Bytes`, and the
|
||||
handler owns sub-stream framing (ADR-035). Alktty is that handler, and it
|
||||
owns four parse surfaces fed by arbitrary peers on both transports (direct
|
||||
ALPN and channels):
|
||||
@@ -21,7 +21,7 @@ ALPN and channels):
|
||||
| # | Surface | Location | Shape |
|
||||
|---|---|---|---|
|
||||
| 1 | 5-byte chunk codec | `src/wire.rs` (`ChunkReader`/`ChunkWriter`, `validate_header`) | async, `Cursor`-drivable; validate-then-alloc at `wire.rs:244` |
|
||||
| 2 | Negotiation framing | `src/negotiation.rs:185` (`NegotiationReader::read_frame`) | 4-byte BE prefix, bounds-check then `vec![0u8; length]` at `:199` — the same bounded-but-allocation-rate pattern alkcall flagged (§6.2-3) |
|
||||
| 2 | Negotiation framing | `src/negotiation.rs:185` (`NegotiationReader::read_frame`) | 4-byte BE prefix, bounds-check then `vec![0u8; length]` at `:199`; the same bounded-but-allocation-rate pattern alkcall flagged (§6.2-3) |
|
||||
| 3 | `NegotiateRequest` JSON | `src/negotiation.rs` (serde parse of up to 16 MiB bodies) | attacker-shaped JSON, typed struct |
|
||||
| 4 | `ControlMessage` JSON | `src/control.rs:105` (`from_slice`), `signal_from_name` | sync pure |
|
||||
|
||||
@@ -29,7 +29,7 @@ Reasons this pays off here, beyond the generic alkcall argument:
|
||||
|
||||
1. **Semantic invariants no example test explores adversarially.** The
|
||||
peek/disambiguation machinery (`peek_stream_type` vs
|
||||
`read_chunk_after_peek` — both call orders must yield exactly one
|
||||
`read_chunk_after_peek`; both call orders must yield exactly one
|
||||
chunk) and the cross-codec invariant that makes it sound: an
|
||||
error-response frame's length-prefix high byte must be `0x00` so a
|
||||
`ChunkReader` peek distinguishes it from a chunk whose first byte is a
|
||||
@@ -40,14 +40,14 @@ Reasons this pays off here, beyond the generic alkcall argument:
|
||||
2. **The alkcall bug class has a plausible analog here.** The duplicate
|
||||
adopt/open finding (alkcall §7.8) was *stateful*: an interleaving no
|
||||
unit test replays destroyed the live channel. Alktty's adapter has the
|
||||
same shape — take-once `Mutex<Option<tx>>` slots (`adapter.rs:777-792`),
|
||||
same shape: take-once `Mutex<Option<tx>>` slots (`adapter.rs:777-792`),
|
||||
session re-registration, handle replacement. A stateful op-sequence
|
||||
target against the session pump + mock backend is where a real finding
|
||||
would live.
|
||||
3. **Cost is low and the playbook is proven.** Mirroring alkcall's
|
||||
as-built layout is a port, not a design exercise (§5 below).
|
||||
|
||||
Zero parser-bug yield is a successful negative result — it converts "we
|
||||
Zero parser-bug yield is a successful negative result: it converts "we
|
||||
think the codecs are robust" into a demonstrated property (the quinn
|
||||
lesson cuts both ways).
|
||||
|
||||
@@ -64,8 +64,8 @@ compiled for `wasm32-unknown-unknown`.
|
||||
|
||||
- `cargo-fuzz` + `arbitrary`, standard `fuzz/` workspace with its own
|
||||
`[workspace]` table (nightly-only bins).
|
||||
- **The invariant logic lives in `fuzz/shared/` — a stable-toolchain
|
||||
library crate** — not inline in the target binaries. The `fuzz_target!`
|
||||
- **The invariant logic lives in `fuzz/shared/`, a stable-toolchain
|
||||
library crate**, not inline in the target binaries. The `fuzz_target!`
|
||||
binaries are thin wrappers. This makes corpus replay a plain
|
||||
`cargo test --manifest-path fuzz/shared/Cargo.toml` on stable: it
|
||||
replays every committed seed through the identical invariant functions
|
||||
@@ -74,8 +74,8 @@ compiled for `wasm32-unknown-unknown`.
|
||||
crate stays stable at MSRV 1.88.
|
||||
- Committed hand-made seeds, gitignored grown corpora (quinn/h2 pattern);
|
||||
deterministic seed generator script committed next to them.
|
||||
- **No hosted CI** — this repo has the same no-CI policy as alkcall
|
||||
(§7.9 there): the gitea host serves git only; verification and
|
||||
- **No hosted CI**: this repo has the same no-CI policy as alkcall
|
||||
(§7.9 there), where the gitea host serves git only; verification and
|
||||
campaigns are manual. Corpus replay is the standing fuzz gate;
|
||||
campaigns run via the detached runner before releases and after
|
||||
touching `src/wire.rs`, `src/negotiation.rs`, `src/control.rs`, the
|
||||
@@ -91,7 +91,7 @@ session's own process tree and cgroup. A fuzz campaign is exactly the
|
||||
workload shape that can kill its own host:
|
||||
|
||||
- **OOM-killer shared fate.** The target's worst case *is* unbounded
|
||||
allocation. libFuzzer's RSS guard is a *soft* poll — a fast single huge
|
||||
allocation. libFuzzer's RSS guard is a *soft* poll; a fast single huge
|
||||
allocation can beat it, and then the kernel OOM killer fires. It picks
|
||||
the largest-RSS process in the cgroup, which can be the opencode server
|
||||
hosting the session. Losing that server is recoverable (sessions are
|
||||
@@ -108,7 +108,7 @@ Three layers, all standard:
|
||||
`-malloc_limit_mb=2048` make libFuzzer exit cleanly (OOM-class
|
||||
artifact) before the kernel gets involved. Do **not** use `ulimit -v`
|
||||
with ASAN (the sanitizer reserves terabytes of virtual address space;
|
||||
the classic failure is instant `MmapAlloc` death) — the ASAN-safe
|
||||
the classic failure is instant `MmapAlloc` death). The ASAN-safe
|
||||
equivalent is `malloc_limit_mb`.
|
||||
2. **Fork mode as blast-radius containment (default):** `-fork=1` runs
|
||||
each input in a short-lived child; a crash/timeout/OOM/leak kills only
|
||||
@@ -144,7 +144,7 @@ alkcall §7.4).
|
||||
| 1 | `chunk_frame` | raw `&[u8]` | `ChunkReader`/`ChunkWriter` over `Cursor` + `block_on` | no-panic; error-shape partition (`ConnectionClosed` only on truncation; `InvalidStreamType` iff `stream_type > 4`; `ChunkTooLarge` iff `length > MAX_CHUNK_LEN`; `Io` impossible on a Cursor); Ok iff header complete + payload complete; round-trip (`write_chunk` then `read_chunk` reproduces stream_type + bytes); 5-byte consumption accounting; peek interleave (`peek_stream_type` → `read_chunk_after_peek` == `read_chunk`, idempotent peek, peek state resets per chunk); never allocates for out-of-range headers |
|
||||
| 2 | `negotiation_frame` | raw `&[u8]` | `NegotiationReader`/`NegotiationWriter` + `NegotiateRequest` serde parse + `error_response_bytes` | no-panic; error-shape partition (`ConnectionClosed` only on truncation; `FrameTooLarge` iff `length > MAX_CHUNK_LEN`; `Json` only when the full body was present; `Io` impossible); consumption accounting (4 + length); negotiation JSON round-trip (`NegotiateRequest` → `to_json` → frame → `from_slice` → equal struct); **cross-codec disambiguation** (see §4.1) |
|
||||
| 3 | `control_json` | raw `&[u8]` | `ControlMessage::from_slice` / `to_json`, `signal_from_name` | no-panic on any bytes; clean serde errors (never silent); `to_json` → `from_slice` structural round-trip; `signal_from_name` is total (returns `Option`, never panics) |
|
||||
| 4 | `session_opseq` | `#[derive(Arbitrary)]` op sequence | the producer session pump (`drive_session_pre_negotiated`) + a fuzz-local mock backend (`fuzz/shared/src/session_opseq.rs`; the in-crate `MockBackend` is `#[cfg(test)]`-only) | no-panic (driver, pump tasks via the session `JoinError`, harness model); **kill-on-Drop (ADR-005)** — after teardown `kill_fired == !exit_resolved`, and with no exit op issued the kill MUST have fired; **exit chunk is last (ADR-004)** — at most one ctrl_out chunk, `type == "exit"`, `code` matches the first-issued exit op (`-1` on wait-failure or dropped exit sender), nothing observed after it, server never writes stream types 0/3; lossless per-stream FIFO of produced `(len, byte)` patterns with exactly one stdout sentinel (only after the stdout-EOF op) and no stderr sentinel; stdin prefix-losslessness (post-shutdown chunks never delivered); control dispatch never over-counts and dispatches promptly pre-exit; take-once allocation; teardown terminates within the bound |
|
||||
| 4 | `session_opseq` | `#[derive(Arbitrary)]` op sequence | the producer session pump (`drive_session_pre_negotiated`) + a fuzz-local mock backend (`fuzz/shared/src/session_opseq.rs`; the in-crate `MockBackend` is `#[cfg(test)]`-only) | no-panic (driver, pump tasks via the session `JoinError`, harness model); **kill-on-Drop (ADR-005)**: after teardown `kill_fired == !exit_resolved`, and with no exit op issued the kill MUST have fired; **exit chunk is last (ADR-004)**: at most one ctrl_out chunk, `type == "exit"`, `code` matches the first-issued exit op (`-1` on wait-failure or dropped exit sender), nothing observed after it, server never writes stream types 0/3; lossless per-stream FIFO of produced `(len, byte)` patterns with exactly one stdout sentinel (only after the stdout-EOF op) and no stderr sentinel; stdin prefix-losslessness (post-shutdown chunks never delivered); control dispatch never over-counts and dispatches promptly pre-exit; take-once allocation; teardown terminates within the bound |
|
||||
|
||||
### 4.1 Cross-codec disambiguation invariant (target 2, the subtle one)
|
||||
|
||||
@@ -154,14 +154,14 @@ a 4-byte BE length prefix ≤ 16 MiB − 1 starts with `0x00`, which is
|
||||
neither. Encoded as fuzz assertions:
|
||||
|
||||
- For every `error_response_bytes` output and every negotiation frame of
|
||||
length < `MAX_CHUNK_LEN`, the first byte is `0x00` — a `ChunkReader`
|
||||
length < `MAX_CHUNK_LEN`, the first byte is `0x00`, so a `ChunkReader`
|
||||
peek on `[frame bytes…]` never mistakes it for a chunk.
|
||||
- **Known boundary collision, encoded deliberately:** a frame of length
|
||||
exactly `MAX_CHUNK_LEN` (= `0x01000000`) has prefix `[0x01, …]` —
|
||||
exactly `MAX_CHUNK_LEN` (= `0x01000000`) has prefix `[0x01, …]`:
|
||||
first byte `0x01`, colliding with `STREAM_STDOUT`. The read paths
|
||||
admit it (`length > MAX_CHUNK_LEN` rejects, `==` passes). This is an
|
||||
inherent edge of the one-byte-peek design; producers only ever send
|
||||
tiny error frames, so it is accepted — but the assertion pins the
|
||||
tiny error frames, so it is accepted, but the assertion pins the
|
||||
boundary so any future `MAX_CHUNK_LEN` change that widens the
|
||||
collision gets caught. (`validate_header`'s doc comment at
|
||||
`wire.rs:262-270` claims the ≤ cap high byte is `0x00`; strictly the
|
||||
@@ -184,7 +184,7 @@ fuzz/
|
||||
│ ├── negotiation_frame.rs thin wrapper
|
||||
│ ├── control_json.rs thin wrapper
|
||||
│ └── session_opseq.rs thin wrapper (typed SessionSequence input)
|
||||
├── shared/ alktty-fuzz-shared — STABLE-toolchain library
|
||||
├── shared/ alktty-fuzz-shared, STABLE-toolchain library
|
||||
│ └── src/{chunk_frame,negotiation_frame,control_json,session_opseq}.rs
|
||||
├── corpus/<target>/ committed seeds
|
||||
├── gen_fuzz_seeds.py deterministic seed generator
|
||||
@@ -194,12 +194,12 @@ fuzz/
|
||||
|
||||
Root `Cargo.toml` changes (the alkcall §7.7 footguns, both required):
|
||||
|
||||
- `[workspace]` table: `members = ["."]`, `exclude = ["fuzz"]` — without
|
||||
it cargo auto-discovers `fuzz/shared/` into the main workspace and the
|
||||
- `[workspace]` table: `members = ["."]`, `exclude = ["fuzz"]`, since
|
||||
without it cargo auto-discovers `fuzz/shared/` into the main workspace and the
|
||||
MSRV toolchain tries to build nightly-consumed dev-deps.
|
||||
- `"fuzz/"` added to the publish `exclude` list.
|
||||
|
||||
No `#[cfg(fuzzing)]` exposure expected — `ChunkReader`/`ChunkWriter`,
|
||||
No `#[cfg(fuzzing)]` exposure expected: `ChunkReader`/`ChunkWriter`,
|
||||
`NegotiationReader`/`NegotiationWriter`, `NegotiateRequest`,
|
||||
`ControlMessage`, `error_response_bytes`, `drive_session_pre_negotiated`,
|
||||
and the `TtyBackend`/`TtyHandle`/`TtyParams` shapes are all already `pub`
|
||||
@@ -226,8 +226,8 @@ deterministic (no randomness) so seeds are reproducible.
|
||||
## 7. Sequencing
|
||||
|
||||
1. **Bump alkcall 0.8.0 → 0.8.1** (the duplicate adopt/open fix lands on
|
||||
alktty's own path — `TtySession::open_via_channels` and the channels
|
||||
establisher run on the fixed `ChannelManager`). Full verification set
|
||||
alktty's own path, where `TtySession::open_via_channels` and the
|
||||
channels establisher run on the fixed `ChannelManager`). Full verification set
|
||||
including wasm checks. Also fix AGENTS.md's stale "pin alkcall 0.6.0"
|
||||
line while touching it. **Status: see §8.**
|
||||
2. Scaffold `fuzz/` + root workspace table + publish exclude.
|
||||
@@ -239,13 +239,13 @@ deterministic (no randomness) so seeds are reproducible.
|
||||
5. Detached campaigns (10 min per target via `run-detached.sh`), triage
|
||||
anything found, record results in §8.
|
||||
6. Decide on target 4 (`session_opseq`) after 1–3 are clean.
|
||||
**Status: landed — see §8. All four targets are in scope and live.**
|
||||
**Status: landed; see §8. All four targets are in scope and live.**
|
||||
|
||||
## 8. Progress log (append-only across sessions)
|
||||
|
||||
- **2026-09-28** — Proposal discussed and adopted; this plan written.
|
||||
- **2026-09-28**: Proposal discussed and adopted; this plan written.
|
||||
Alkcall dependency bump + fuzz scaffold started.
|
||||
- **2026-09-28 (later)** — **All of §7 steps 1–5 landed.**
|
||||
- **2026-09-28 (later)**: **All of §7 steps 1–5 landed.**
|
||||
- alkcall 0.8.0 → 0.8.1 verified across the full checklist (113 +
|
||||
all-features 156 tests, clippy stable/wasm, fmt, doc).
|
||||
- AGENTS.md: stale "pin alkcall 0.6.0" corrected to 0.8.1; corpus
|
||||
@@ -260,38 +260,38 @@ deterministic (no randomness) so seeds are reproducible.
|
||||
(`cargo test --manifest-path fuzz/shared/Cargo.toml`).
|
||||
- Harness-model corrections the corpus replay caught *before* any
|
||||
campaign (the alkcall §7.7 pattern repeating): (1) a valid first
|
||||
chunk with trailing stream bytes is correct reader behavior — the
|
||||
payload slice is `5..5+len`, not `5..`; (2) the negotiation framing
|
||||
layer is length-prefix-only — `read_frame` returns raw bytes and the
|
||||
chunk with trailing stream bytes is correct reader behavior (the
|
||||
payload slice is `5..5+len`, not `5..`); (2) the negotiation framing
|
||||
layer is length-prefix-only: `read_frame` returns raw bytes and the
|
||||
adapter does the serde parse (`adapter.rs:411`), so
|
||||
`NegotiationError::Json` is unreachable from this reader (unlike
|
||||
alkcall's serde-backed envelope reader; the `Json` arm now panics as
|
||||
"unreachable" and the trailing-byte probe asserts exact-body
|
||||
consumption instead of a Json error); (3) zero-length negotiation
|
||||
frames are admitted by this reader (the adapter's parse rejects
|
||||
them) — the shape assertion was relaxed accordingly.
|
||||
them), so the shape assertion was relaxed accordingly.
|
||||
- **10-min detached campaigns, all three targets: every run exited 0
|
||||
with an empty artifact directory — no crashes, hangs, OOMs, or
|
||||
leaks** (per-target `oom/timeout/crash: 0/0/0` on all ~28 fork
|
||||
with an empty artifact directory (no crashes, hangs, OOMs, or
|
||||
leaks)** (per-target `oom/timeout/crash: 0/0/0` on all ~28 fork
|
||||
jobs): `chunk_frame` 380k execs (cov saturated at 710 edges within
|
||||
seconds — the 5-byte-parser ceiling is fully enumerated);
|
||||
seconds; the 5-byte-parser ceiling is fully enumerated);
|
||||
`negotiation_frame` 3.69M execs (cov 3157, still finding coverage at
|
||||
budget end — JSON structure exploration); `control_json` 8.6M execs
|
||||
budget end, JSON structure exploration); `control_json` 8.6M execs
|
||||
(cov 2035, still growing). Grown corpus entries were deleted per the
|
||||
§6 policy; committed seeds unchanged.
|
||||
- §7 step 6 (target 4 `session_opseq`) remains open for a follow-up
|
||||
session.
|
||||
- **2026-09-28 (later still)** — **§7 step 6 landed: target 4
|
||||
- **2026-09-28 (later still)**: **§7 step 6 landed: target 4
|
||||
`session_opseq`. All four targets are now live.**
|
||||
- `fuzz/shared/src/session_opseq.rs`: `SessionOp`/`SessionSequence`
|
||||
(`#[derive(Arbitrary)]`, 12-op vocabulary — client writes incl.
|
||||
(`#[derive(Arbitrary)]`, 12-op vocabulary: client writes incl.
|
||||
barrier probes, backend production/EOF, exit resolve/fail/drop,
|
||||
control messages, bounded reads, yields) + a fuzz-local
|
||||
`FuzzBackend` (the in-crate `MockBackend` is `#[cfg(test)]`-only) +
|
||||
the harness driving `drive_session_pre_negotiated` over a
|
||||
`tokio::io::duplex` pair, with a delivery model mirroring §4's
|
||||
invariants. 12 committed seeds via the deterministic generator
|
||||
(hand-encoded against the `arbitrary` 1.4.x derive layout — variant
|
||||
(hand-encoded against the `arbitrary` 1.4.x derive layout: variant
|
||||
tag `(u32_le * 12) >> 32`, fields LE zero-filled, keep-going byte
|
||||
gating each `Vec` element; the encoding is pinned by a decode test
|
||||
asserting the exact ops each seed decodes to).
|
||||
@@ -303,22 +303,22 @@ deterministic (no randomness) so seeds are reproducible.
|
||||
unbounded backend stdin (control dispatch stays prompt).
|
||||
- **Harness-model correction before the campaign** (the alkcall §7.7
|
||||
pattern repeating, third time): a 60s smoke run crashed on
|
||||
`ClientWrite` after the session had completed — a failed client
|
||||
`ClientWrite` after the session had completed; a failed client
|
||||
write can mean the *server half of the duplex dropped* because the
|
||||
session finished (exit resolved → pumps join → exit chunk →
|
||||
drainer exits → duplex halves drop) while the write was in flight.
|
||||
The harness now treats any write/shutdown error as "the session
|
||||
closed": it drains the read side and requires the exit chunk (a
|
||||
close without it is a premature close — a real bug). No adapter
|
||||
close without it is a premature close, a real bug). No adapter
|
||||
code changed; the finding was a harness-model gap.
|
||||
- Corpus replay green on stable (9 tests, incl. the seed-decode
|
||||
pin). Full checklist green: 113 + 156 all-features tests, clippy
|
||||
stable/wasm + fuzz-shared clippy, fmt, doc, publish dry-run, wasm
|
||||
check.
|
||||
- Campaign: **10 min detached via `run-detached.sh`, exited 0 with an
|
||||
empty artifact directory — no crashes, hangs, OOMs, or leaks** (all
|
||||
empty artifact directory and no crashes, hangs, OOMs, or leaks** (all
|
||||
33 fork jobs `oom/timeout/crash: 0/0/0`). 42.3k execs at ~74/s per
|
||||
job (the op-seq target is stateful — each input runs a full tokio
|
||||
job (the op-seq target is stateful, with each input running a full tokio
|
||||
session with bounded reads/yields, so exec/s is inherently tens, not
|
||||
millions; the value is interleaving exploration). Cov grew
|
||||
3934 → 4075 edges over the run, still finding coverage at budget
|
||||
@@ -340,7 +340,7 @@ example-based tests structurally cannot enumerate.
|
||||
Verification additions once landed:
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path fuzz/shared/Cargo.toml # corpus replay — the standing fuzz gate
|
||||
cargo test --manifest-path fuzz/shared/Cargo.toml # corpus replay: the standing fuzz gate
|
||||
```
|
||||
|
||||
…added to the AGENTS.md checklist alongside the existing commands. The
|
||||
|
||||
+102
-100
@@ -1,14 +1,14 @@
|
||||
# Project Setup Plan
|
||||
|
||||
Status: draft (revised 2026-08-17 to reflect landed upstream changes;
|
||||
Phase 4 landed 2026-08-17 — architecture docs + BAST schema +
|
||||
renumbered ADRs; Phase 5 landed 2026-08-17 — tests)
|
||||
Phase 4 landed 2026-08-17 (architecture docs + BAST schema +
|
||||
renumbered ADRs); Phase 5 landed 2026-08-17 (tests)
|
||||
Last updated: 2026-08-17
|
||||
|
||||
## Overview
|
||||
|
||||
Extract `alknet-tty` + `alknet-tty-local` from the alknet mono-repo into a
|
||||
standalone `alktty` crate — a producer/consumer protocol crate on top of
|
||||
standalone `alktty` crate: a producer/consumer protocol crate on top of
|
||||
alkcall channels. This is the first protocol crate built on alkcall and
|
||||
establishes patterns that `alktunnels` and others will follow.
|
||||
|
||||
@@ -29,7 +29,7 @@ These were "open questions" or "upstream changes needed" in the
|
||||
2026-08-14 draft and are now resolved. The plan reflects the as-built
|
||||
shape, not the original intent.
|
||||
|
||||
### alkcall 0.1.1 — ALPN rename done
|
||||
### alkcall 0.1.1: ALPN rename done
|
||||
|
||||
- `CHANNELS_ALPN`: `b"alknet/channels"` → `b"alk/channels"` (in
|
||||
`alkcall/src/channels/adapter.rs`).
|
||||
@@ -39,40 +39,40 @@ shape, not the original intent.
|
||||
- `alkcall` `Cargo.toml` dependency needs updating from `0.1.0` to
|
||||
`0.1.1` (the scaffold still pins `0.1.0`).
|
||||
|
||||
### alkcall — `Identity.resources` shape
|
||||
### alkcall: `Identity.resources` shape
|
||||
|
||||
The alknet-core `Identity` had `resources: HashMap<String, String>` (a
|
||||
flat resource-type → resource-id map). alkcall's `Identity` (in
|
||||
`alkcall::core::auth`) has `resources: HashMap<String, Vec<String>>` —
|
||||
`alkcall::core::auth`) has `resources: HashMap<String, Vec<String>>`;
|
||||
one resource type can carry multiple resource ids. The ported adapter
|
||||
tests that build an `Identity` by hand need this shape; the production
|
||||
code only reads `identity.scopes` (the scope-gate at negotiation), so
|
||||
the change is test-side.
|
||||
|
||||
### alkcall — `OwnershipProvider` / `OwnershipStore` signature
|
||||
### alkcall: `OwnershipProvider` / `OwnershipStore` signature
|
||||
|
||||
- `OwnershipProvider::owns(&self, identity, resource_type,
|
||||
resource_id, action: &str)` — added an `action` parameter (was 3
|
||||
resource_id, action: &str)`, which added an `action` parameter (was 3
|
||||
args, now 4). The adapter's call site
|
||||
(`provider.owns(id_ref, kind, &id, "tty")`) already passes an
|
||||
`"tty"` action string, which is the new arity. No change to the
|
||||
production adapter; the mock and test fixtures need the 4-arg shape.
|
||||
- `OwnershipStore::record(&self, identity, resource_type,
|
||||
resource_id)` — **dropped** the old `action` parameter that
|
||||
resource_id)` **dropped** the old `action` parameter that
|
||||
alknet-core's variant took. The adapter test's
|
||||
`store.record(&owner, "container", "c1").await` matches the new
|
||||
arity. (The scaffold's plan previously listed an `OwnershipStore`
|
||||
import that no longer needs an action arg.)
|
||||
|
||||
### alkcall — `AuthContext::anonymous(alpn)`
|
||||
### alkcall: `AuthContext::anonymous(alpn)`
|
||||
|
||||
alkcall added a convenience constructor `AuthContext::anonymous(alpn)`
|
||||
that builds an `AuthContext` with no identity, no fingerprint, no
|
||||
remote address — only the ALPN. Useful in adapter tests; not used by
|
||||
remote address, just the ALPN. Useful in adapter tests; not used by
|
||||
the production adapter (which receives the `AuthContext` from the
|
||||
dispatch path).
|
||||
|
||||
### alktype 0.2.0 — BAST format pivot
|
||||
### alktype 0.2.0: BAST format pivot
|
||||
|
||||
alktype was reworked: the v0.1.0 `AlkType:*` custom-keyword JSON Schema
|
||||
backend was dropped in favor of a straightforward `kind`-based BAST
|
||||
@@ -81,7 +81,7 @@ document is a plain JSON file (conforming to a JSON Schema meta-schema)
|
||||
that describes binary layouts using `kind` strings (`"uint8"`,
|
||||
`"uint32"`, `"struct"`, etc.) and `$defs`/`$ref` for composition.
|
||||
|
||||
`alktty` will **not** depend on alktype — the hand-rolled
|
||||
`alktty` will **not** depend on alktype. The hand-rolled
|
||||
`ChunkReader`/`ChunkWriter` in `wire.rs` already works and is
|
||||
straightforward. The BAST format matters here only as a normative
|
||||
**schema document** for the `alk/tty` wire format: a JSON file under
|
||||
@@ -93,7 +93,7 @@ dependency.
|
||||
## Crate Structure
|
||||
|
||||
Single crate (`alktty`) with the local backend gated behind a `local`
|
||||
feature. This is a "tty-specific monorepo" — one crate, one concern,
|
||||
feature. This is a "tty-specific monorepo": one crate, one concern,
|
||||
both halves of the original alknet split (`alknet-tty` +
|
||||
`alknet-tty-local`) folded in.
|
||||
|
||||
@@ -106,9 +106,9 @@ alktty/
|
||||
│ ├── control.rs # ControlMessage enum, signal_from_name
|
||||
│ ├── backend.rs # TtyBackend trait, TtyHandle, TtyParams, TtyError
|
||||
│ ├── adapter.rs # TtyAdapter (ProtocolHandler), drive_session
|
||||
│ ├── session.rs # TtySession — typed consumer client
|
||||
│ ├── session.rs # TtySession (typed consumer client)
|
||||
│ ├── channels.rs # OpenHandler factory, register_openable helper
|
||||
│ └── local/ # (behind `local` feature — the folded alknet-tty-local)
|
||||
│ └── local/ # (behind `local` feature, the folded alknet-tty-local)
|
||||
│ ├── mod.rs # LocalTtyBackend re-export
|
||||
│ ├── backend.rs # LocalTtyBackend (dispatches terminal: Some→pty, None→pipe)
|
||||
│ ├── pty.rs # PTY mode (portable_pty, 3 std threads → tokio channels, #[cfg(unix)])
|
||||
@@ -136,7 +136,7 @@ alktty/
|
||||
The old split (`alknet-tty` + `alknet-tty-local`) had a cyclic dependency
|
||||
problem: `alknet-tty-local` depends on `alknet-tty` for the trait, and
|
||||
ADR-054 wanted `alknet-tty` to re-export `LocalTtyBackend` behind a
|
||||
`local` feature — which cargo rejects. The assembly-layer workaround
|
||||
`local` feature, which cargo rejects. The assembly-layer workaround
|
||||
(consumer depends on both crates directly) works but is awkward.
|
||||
|
||||
Single crate with `local` feature solves this cleanly. The
|
||||
@@ -149,31 +149,31 @@ original alknet split: the wire/adapter/backend-trait half (from
|
||||
local backend is small (~1.4k lines), tightly coupled to the trait,
|
||||
and the cyclic-dep workaround it required in the alknet mono-repo
|
||||
doesn't exist in a single crate. A docker or SSH backend would still
|
||||
be a separate crate — those have real external deps (`bollard`,
|
||||
be a separate crate; those have real external deps (`bollard`,
|
||||
`russh`) and their own resource models.
|
||||
|
||||
**WASM target.** The default crate (no `local` feature) MUST compile
|
||||
to `wasm32-unknown-unknown`. alkcall and alktype both target wasm;
|
||||
keeping alktty wasm-clean is what makes the downstream TS/Python
|
||||
adapter story work — a wasm-compiled alktty is the protocol layer for
|
||||
adapter story work. A wasm-compiled alktty is the protocol layer for
|
||||
a sandboxed adapter (browser terminal over WebTransport, a Python
|
||||
wheel that shells out to a wasm module, etc.). The `local` feature is
|
||||
inherently non-wasm (`portable-pty` needs a real OS, `tokio::process`
|
||||
needs `spawn`), so enabling `local` on wasm is a build error by
|
||||
design — the local-process backend runs on a real OS (Linux, macOS,
|
||||
design: the local-process backend runs on a real OS (Linux, macOS,
|
||||
Windows), never in a sandbox.
|
||||
|
||||
Concretely, `Cargo.toml` uses `tokio = { default-features = false,
|
||||
features = ["rt", "sync", "io-util", "macros"] }` (the wasm-clean
|
||||
subset alkcall uses) and `local` adds `tokio/process` +
|
||||
`tokio/rt-multi-thread`. Do NOT use `features = ["full"]` — it pulls
|
||||
`tokio/rt-multi-thread`. Do NOT use `features = ["full"]`; it pulls
|
||||
in `signal`/`fs`/`net` which break `wasm32-unknown-unknown`. The
|
||||
adapter's `tokio::spawn` for per-session pumps is fine on wasm (the
|
||||
wasm tokio runtime supports `spawn`); the `local` module's std
|
||||
threads and `tokio::process::Command` are the non-wasm parts, and
|
||||
they're feature-gated. `libc` stays under `cfg(unix)` (it's already
|
||||
there for `signal_from_name` and the pipe-mode `kill` path — neither
|
||||
runs on wasm because the only callers are in `local`).
|
||||
there for `signal_from_name` and the pipe-mode `kill` path, neither
|
||||
of which runs on wasm because the only callers are in `local`).
|
||||
|
||||
### 2. Dependency: `alknet-core` → `alkcall::core`
|
||||
|
||||
@@ -191,7 +191,8 @@ comes from. The trait shape is identical. The `Identity` struct's
|
||||
reads `identity.scopes`, so the change is test-side.
|
||||
|
||||
`Cargo.toml` pins `alkcall = "0.1.1"` (the scaffold's `0.1.0` pin is
|
||||
stale — 0.1.1 is the ALPN-rename release; same public API per semver).
|
||||
stale; 0.1.1 is the ALPN-rename release with the same public API per
|
||||
semver).
|
||||
|
||||
### 3. Channels integration (the new part)
|
||||
|
||||
@@ -199,11 +200,11 @@ This is where alktty becomes a proper producer/consumer on alkcall.
|
||||
|
||||
**ALPN strings (settled).** `alk/tty` (direct) and `alk/channels`
|
||||
(multiplexed). The `alknet/<name>` → `alk/<name>` rename landed in
|
||||
alkcall 0.1.1 — `CHANNELS_ALPN` is `b"alk/channels"`, the channels
|
||||
alkcall 0.1.1. `CHANNELS_ALPN` is `b"alk/channels"` and the channels
|
||||
client test uses `"alk/tty"`. No upstream work remains; we just use
|
||||
the new strings.
|
||||
|
||||
**Producer half** — two paths to expose TTY:
|
||||
**Producer half** (two paths to expose TTY):
|
||||
|
||||
- **Direct ALPN** (`alk/tty`): `TtyAdapter` implements `ProtocolHandler`.
|
||||
Existing code, just import migration. Used for direct-connect scenarios
|
||||
@@ -216,38 +217,38 @@ the new strings.
|
||||
|
||||
Per ADR-093 (reversal of ADR-077): TTY always uses its 5-byte format.
|
||||
The channels layer strips its 8-byte header and hands TTY the payload
|
||||
transparently. The same `wire.rs` code runs in both modes — only the
|
||||
transparently. The same `wire.rs` code runs in both modes; only the
|
||||
`BiStream` source differs.
|
||||
|
||||
The channels integration code is small: a `register_openable` helper
|
||||
function that takes a `ChannelCore`, `OperationRegistry`, and backend
|
||||
map, and registers the per-ALPN open op (`channels/tty/sub` —
|
||||
map, and registers the per-ALPN open op (`channels/tty/sub`,
|
||||
consumer-opens). This lives in `src/channels.rs`. The op spec carries
|
||||
the `channel_open` marker (`ChannelOpenSpec::new("alk/tty")`) and the
|
||||
`AccessControl` for the scope-gate + ownership check; the channels
|
||||
wrapper does `check_open` → `open_channel` → spawn the `OpenHandler`
|
||||
→ respond with `{ channel_id }`.
|
||||
|
||||
**Consumer half** — `TtySession`:
|
||||
**Consumer half** (the `TtySession`):
|
||||
|
||||
A typed client that wraps the wire protocol. Two constructors:
|
||||
- `TtySession::connect_direct(connection)` — for direct `alk/tty`
|
||||
- `TtySession::open_via_channels(client, params)` — opens a channel via
|
||||
- `TtySession::connect_direct(connection)`: for direct `alk/tty`
|
||||
- `TtySession::open_via_channels(client, params)`: opens a channel via
|
||||
`ChannelClient`, negotiates, returns a session handle
|
||||
|
||||
The session handle exposes:
|
||||
- `send_stdin(bytes)` / `close_stdin()`
|
||||
- `recv_stdout()` / `recv_stderr()` — streams
|
||||
- `recv_stdout()` / `recv_stderr()`: streams
|
||||
- `resize(cols, rows)`, `signal(name)`
|
||||
- `wait()` — awaits exit code
|
||||
- `wait()`: awaits exit code
|
||||
|
||||
### 4. Negotiation framing — keep self-contained
|
||||
### 4. Negotiation framing: keep it self-contained
|
||||
|
||||
ADR-057 says the negotiation framing is self-contained (~30 lines, format
|
||||
coincides with alkcall's by convention). Even though alkcall is now a
|
||||
dependency, we keep the self-contained framing because:
|
||||
|
||||
- The payload is `NegotiateRequest`, not `EventEnvelope` — alkcall's
|
||||
- The payload is `NegotiateRequest`, not `EventEnvelope`; alkcall's
|
||||
`FrameFramedReader` is hardcoded to deserialize `EventEnvelope`
|
||||
- The framing is trivial (4-byte BE length prefix + body)
|
||||
- Avoids coupling to alkcall's internal wire types for a TTY-specific payload
|
||||
@@ -257,11 +258,11 @@ dependency, we keep the self-contained framing because:
|
||||
The architecture README in alkcall describes protocol crates as having two
|
||||
halves:
|
||||
|
||||
1. **Producer half** — `register_*()` functions that take an
|
||||
1. **Producer half**: `register_*()` functions that take an
|
||||
`&mut OperationRegistry` and register ops with handlers. For channels,
|
||||
an `OpenHandler` factory.
|
||||
|
||||
2. **Consumer half** — a typed client wrapper around `CallConnection` or
|
||||
2. **Consumer half**: a typed client wrapper around `CallConnection` or
|
||||
`ChannelClient` that exposes the crate's ops as async methods.
|
||||
|
||||
We make this explicit in the module structure:
|
||||
@@ -273,13 +274,14 @@ We make this explicit in the module structure:
|
||||
The old code had minimal access control (a scope-gate at negotiation).
|
||||
With alkcall, we get:
|
||||
|
||||
- **Operation-level ACL**: `AccessControl` on `OperationSpec` — scopes,
|
||||
resource ownership checks (`required_scopes`, `required_scopes_any`,
|
||||
`resource_type` + `resource_id_path`, `resource_action`)
|
||||
- **Channel lifecycle policy**: `ChannelLifecyclePolicy` — per-identity
|
||||
channel caps, open/close hooks (consulted by the `ChannelCore`
|
||||
- **Operation-level ACL**: `AccessControl` on `OperationSpec`, covering
|
||||
scopes and resource ownership checks (`required_scopes`,
|
||||
`required_scopes_any`, `resource_type` + `resource_id_path`,
|
||||
`resource_action`)
|
||||
- **Channel lifecycle policy**: `ChannelLifecyclePolicy` for per-identity
|
||||
channel caps and open/close hooks (consulted by the `ChannelCore`
|
||||
wrapper, not by alktty directly)
|
||||
- **Ownership**: `OwnershipProvider` — runtime-spawned resource tracking
|
||||
- **Ownership**: `OwnershipProvider` for runtime-spawned resource tracking
|
||||
(terminal sessions are resources per ADR-050). alkcall's
|
||||
`OwnershipProvider::owns` takes an `action: &str` arg (the adapter
|
||||
passes `"tty"`); `OwnershipStore::record` takes no action arg.
|
||||
@@ -301,7 +303,7 @@ types round-trip. Keeping them as separate artifacts avoids pulling in
|
||||
alktype as a runtime dep for a codec that's already ~200 lines and
|
||||
works. If we later want runtime validation against the BAST (e.g., to
|
||||
reject malformed chunks at the framing boundary with a generated
|
||||
validator), alktype becomes an optional dep — the BAST document is
|
||||
validator), alktype becomes an optional dep; the BAST document is
|
||||
already there to feed it.
|
||||
|
||||
The `TtyAdapter`'s direct-ALPN path keeps the existing ad-hoc scope
|
||||
@@ -309,7 +311,7 @@ check (`has_scope(identity, TTY_OPEN_SCOPE)`) for the scope gate and
|
||||
optionally consults `OwnershipProvider` for the resource-ownership
|
||||
check. The channels path gets both for free via
|
||||
`ChannelCore::register_openable`, which wires `AccessControl` into the
|
||||
operation spec — the registry runs the ACL before the wrapper, so the
|
||||
operation spec; the registry runs the ACL before the wrapper, so the
|
||||
`OpenHandler` only needs to validate params and spawn the protocol.
|
||||
|
||||
## Migration Strategy
|
||||
@@ -333,26 +335,26 @@ operation spec — the registry runs the ACL before the wrapper, so the
|
||||
4. Confirm the `local` feature already wires `portable-pty` +
|
||||
`tokio-util` as optional deps (it does in the scaffold). The
|
||||
`local` module's `pty.rs` additionally needs `libc` under
|
||||
`cfg(unix)` — already in `[target.'cfg(unix)'.dependencies]`.
|
||||
`cfg(unix)`, which is already in `[target.'cfg(unix)'.dependencies]`.
|
||||
|
||||
### Phase 1: Port core types from `alknet-tty` (no new logic)
|
||||
|
||||
1. Port `wire.rs` — no `alknet-core` imports; just `std::io`, `tokio::io`,
|
||||
1. Port `wire.rs`: no `alknet-core` imports; just `std::io`, `tokio::io`,
|
||||
`bytes`, `thiserror`. Rename crate references in doc comments
|
||||
(`alknet/tty` → `alk/tty`, `alknet-tty` → `alktty`).
|
||||
2. Port `control.rs` — no `alknet-core` imports; `serde`, `bytes`,
|
||||
2. Port `control.rs`: no `alknet-core` imports; `serde`, `bytes`,
|
||||
`libc` (under `cfg(unix)`). Rename `alknet/tty` → `alk/tty` in
|
||||
doc comments.
|
||||
3. Port `negotiation.rs` — no `alknet-core` imports; `std::io`,
|
||||
3. Port `negotiation.rs`: no `alknet-core` imports; `std::io`,
|
||||
`bytes`, `tokio::io`, `thiserror`, `serde_json`. Rename doc
|
||||
references.
|
||||
4. Port `backend.rs` — no `alknet-core` imports; `std`, `async_trait`,
|
||||
4. Port `backend.rs`: no `alknet-core` imports; `std`, `async_trait`,
|
||||
`bytes`, `futures_core`, `tokio::sync`, `tokio_stream`. Change the
|
||||
`BoxFuture` type alias from `Pin<Box<dyn Future + Send>>` to
|
||||
`futures::future::BoxFuture<'static, T>` (functionally identical;
|
||||
the alias matches the alkcall convention and lets us drop the
|
||||
`Pin`/`Future` imports).
|
||||
5. Port `adapter.rs` — change imports:
|
||||
5. Port `adapter.rs`; change the imports:
|
||||
- `alknet_core::auth::{AuthContext, Identity}` →
|
||||
`alkcall::core::{AuthContext, Identity}`
|
||||
- `alknet_core::ownership::OwnershipProvider` →
|
||||
@@ -368,48 +370,48 @@ operation spec — the registry runs the ACL before the wrapper, so the
|
||||
- `Identity.resources: HashMap<String, Vec<String>>` (was
|
||||
`HashMap<String, String>` in alknet-core).
|
||||
- `OwnershipStore::record(&self, identity, resource_type,
|
||||
resource_id)` — 3 args, no `action` (alkcall dropped it).
|
||||
- `OwnershipProvider::owns(identity, rt, rid, action)` — 4 args
|
||||
resource_id)`: 3 args, no `action` (alkcall dropped it).
|
||||
- `OwnershipProvider::owns(identity, rt, rid, action)`: 4 args
|
||||
(alkcall added `action`).
|
||||
|
||||
### Phase 2: Channels integration (new code)
|
||||
|
||||
1. Create `src/channels.rs`:
|
||||
- `register_openable` helper — takes `ChannelCore`, backend map,
|
||||
- `register_openable` helper: takes `ChannelCore`, backend map,
|
||||
`OperationRegistry`, `AuthContext`; builds the `OperationSpec`
|
||||
for `channels/tty/sub` with `ChannelOpenSpec::new("alk/tty")`,
|
||||
`AccessControl` carrying `TTY_OPEN_SCOPE` + resource-type/ownership
|
||||
if a backend declares a `resource_id`; calls
|
||||
`ChannelCore::register_openable(spec, open_handler, registry, auth)`.
|
||||
- `TtyOpenHandler` — the `OpenHandler` (an
|
||||
- `TtyOpenHandler`: the `OpenHandler` (an
|
||||
`Arc<dyn Fn(Value, Connection, AuthContext) -> JoinHandle<()>`)
|
||||
that receives a channel `Connection`, calls `accept_bi()`, runs
|
||||
`drive_session` on the resulting `BiStream`.
|
||||
2. Create `src/session.rs`:
|
||||
- `TtySession` struct — typed consumer client
|
||||
- `TtySession::connect_direct(connection)` — direct `alk/tty`
|
||||
- `TtySession::open_via_channels(client, params)` — opens a channel
|
||||
- `TtySession` struct: typed consumer client
|
||||
- `TtySession::connect_direct(connection)`: direct `alk/tty`
|
||||
- `TtySession::open_via_channels(client, params)`: opens a channel
|
||||
via `ChannelClient::open_channel("channels/tty/sub", params,
|
||||
"alk/tty")`, negotiates, returns a session handle
|
||||
- Methods: `send_stdin`, `recv_stdout`, `recv_stderr`, `resize`,
|
||||
`signal`, `wait`
|
||||
|
||||
### Phase 3: Local backend (behind `local` feature) — folded `alknet-tty-local`
|
||||
### Phase 3: Local backend (behind `local` feature), the folded `alknet-tty-local`
|
||||
|
||||
1. Create `src/local/mod.rs` (re-exports `LocalTtyBackend`),
|
||||
`src/local/backend.rs`, `src/local/pty.rs`, `src/local/pipe.rs`.
|
||||
2. Port from `alknet-tty-local/src/`:
|
||||
- `backend.rs` — change `alknet_tty::backend::{...}` →
|
||||
- `backend.rs`: change `alknet_tty::backend::{...}` →
|
||||
`crate::backend::{...}`. `LocalTtyBackend::allocate` dispatches
|
||||
on `params.terminal`: `Some` → `pty::allocate_pty`, `None` →
|
||||
`pipe::allocate_pipe`.
|
||||
- `pty.rs` — `portable_pty` + 3 std threads (reader/writer/waiter)
|
||||
- `pty.rs`: `portable_pty` + 3 std threads (reader/writer/waiter)
|
||||
feeding tokio mpsc/oneshot. `#[cfg(unix)]`-only: PTY mode uses
|
||||
`libc::kill(-pgid, sig)` for process-group signal forwarding.
|
||||
Update `alknet_tty::backend::{BoxFuture, TtyControl, ...}` →
|
||||
`crate::backend::{...}` and `alknet_tty::control::signal_from_name`
|
||||
→ `crate::control::signal_from_name`.
|
||||
- `pipe.rs` — `tokio::process::Command` + `tokio_util::io::ReaderStream`
|
||||
- `pipe.rs`: `tokio::process::Command` + `tokio_util::io::ReaderStream`
|
||||
for stdout/stderr. Cross-platform: signal forwarding uses
|
||||
`libc::kill(pid, sig)` under `cfg(unix)`, falls back to
|
||||
`Child::kill()` on non-Unix. Update `alknet_tty::...` → `crate::...`.
|
||||
@@ -417,11 +419,11 @@ operation spec — the registry runs the ACL before the wrapper, so the
|
||||
`local` feature in `src/lib.rs`:
|
||||
`#[cfg(feature = "local")] pub mod local;`
|
||||
|
||||
### Phase 4: Architecture docs + BAST schema — landed 2026-08-17
|
||||
### Phase 4: Architecture docs + BAST schema (landed 2026-08-17)
|
||||
|
||||
1. Ported the 5 spec docs from `alknet/docs/architecture/crates/tty/`
|
||||
into `docs/architecture/` (flat layout, not the `crates/tty/`
|
||||
subpath — this is a single-crate repo now):
|
||||
subpath; this is a single-crate repo now):
|
||||
`overview.md` (the crate overview; the alknet `README.md` index is
|
||||
ported as `docs/architecture/README.md`), `tty-wire.md`,
|
||||
`tty-backend.md`, `tty-adapter.md`, `tty-local.md`. Each is renamed
|
||||
@@ -432,30 +434,30 @@ operation spec — the registry runs the ACL before the wrapper, so the
|
||||
2. Ported the 8 alknet ADRs (052, 053, 054, 055, 056, 057, 077, 093)
|
||||
into `docs/architecture/decisions/` renumbered 001..008 in order:
|
||||
- [001](../architecture/decisions/001-wire-format-and-two-carriage.md)
|
||||
← alknet ADR-052 — wire format + two-carriage model (incl. the
|
||||
← alknet ADR-052: wire format + two-carriage model (incl. the
|
||||
Phase 7 control-channel split amendment)
|
||||
- [002](../architecture/decisions/002-ttybackend-trait-and-ttyhandle.md)
|
||||
← alknet ADR-053 — `TtyBackend` trait + `TtyHandle`
|
||||
← alknet ADR-053: `TtyBackend` trait + `TtyHandle`
|
||||
- [003](../architecture/decisions/003-local-backend-placement.md)
|
||||
← alknet ADR-054 — local backend placement (records both the
|
||||
← alknet ADR-054: local backend placement (records both the
|
||||
alknet sibling-crate decision and the alktty single-crate
|
||||
consolidation behind a `local` feature)
|
||||
- [004](../architecture/decisions/004-exit-code-on-control-chunk.md)
|
||||
← alknet ADR-055 — exit code on a control chunk
|
||||
← alknet ADR-055: exit code on a control chunk
|
||||
- [005](../architecture/decisions/005-backend-cleanup-on-session-cancel.md)
|
||||
← alknet ADR-056 — backend cleanup on session cancel
|
||||
← alknet ADR-056: backend cleanup on session cancel
|
||||
- [006](../architecture/decisions/006-negotiation-framing-self-contained.md)
|
||||
← alknet ADR-057 — self-contained negotiation framing
|
||||
← alknet ADR-057: self-contained negotiation framing
|
||||
- [007](../architecture/decisions/007-tty-inside-channels.md)
|
||||
← alknet ADR-077 — TTY inside channels (reversed by 008; kept for
|
||||
← alknet ADR-077: TTY inside channels (reversed by 008; kept for
|
||||
historical context with its reversal notice pointing to 008)
|
||||
- [008](../architecture/decisions/008-channels-pure-channel-multiplexing.md)
|
||||
← alknet ADR-093 — channels pure channel multiplexing (reverses
|
||||
← alknet ADR-093: channels pure channel multiplexing (reverses
|
||||
007; TTY always uses its 5-byte format)
|
||||
3. Wrote
|
||||
[`docs/architecture/tty-bast.md`](../architecture/tty-bast.md) —
|
||||
the BAST (Binary Abstract Syntax Tree) document for the `alk/tty`
|
||||
wire format. Conforms to the BAST meta-schema at
|
||||
3. Wrote the BAST (Binary Abstract Syntax Tree) document for the `alk/tty`
|
||||
wire format at
|
||||
[`docs/architecture/tty-bast.md`](../architecture/tty-bast.md). Conforms
|
||||
to the BAST meta-schema at
|
||||
`https://alk.dev/bast/v1/schema`; validatable by any JSON Schema
|
||||
Draft 2020-12 validator. Covers:
|
||||
- The 5-byte chunk header (`struct` with `endian: "big"`:
|
||||
@@ -467,12 +469,12 @@ operation spec — the registry runs the ACL before the wrapper, so the
|
||||
that on-wire control payloads are UTF-8 JSON, not BAST's binary
|
||||
union encoding.
|
||||
- The negotiation frame as a separate `struct` (4-byte BE length
|
||||
prefix + UTF-8 JSON `NegotiateRequest` body) — annotated as
|
||||
prefix + UTF-8 JSON `NegotiateRequest` body), annotated as
|
||||
out-of-band for the chunk codec but documented for completeness.
|
||||
- The `StreamType` enum's documented deviation: on-wire encoding is
|
||||
`uint8` (1 byte), not BAST's standard `u32` enum index (4 bytes)
|
||||
— the chunk header is 5 bytes, not 8.
|
||||
4. Wrote [`docs/architecture/README.md`](../architecture/README.md) —
|
||||
`uint8` (1 byte), not BAST's standard `u32` enum index (4 bytes);
|
||||
the chunk header is 5 bytes, not 8.
|
||||
4. Wrote [`docs/architecture/README.md`](../architecture/README.md),
|
||||
the architecture index: documents table, ADR table (with alknet
|
||||
origin numbers and port status), key design principles, open
|
||||
questions, and references.
|
||||
@@ -488,28 +490,28 @@ split (`STREAM_CTRL_IN` = 3, `STREAM_CTRL_OUT` = 4) is an amendment
|
||||
inside ADR-001, mirroring alknet (it was not a standalone ADR there
|
||||
either). `AGENTS.md` was updated to match this mapping.
|
||||
|
||||
### Phase 5: Tests — landed 2026-08-17
|
||||
### Phase 5: Tests (landed 2026-08-17)
|
||||
|
||||
1. Ported existing unit tests from `alknet-tty` (wire, negotiation,
|
||||
control, adapter) inline in each `src/*.rs` module's
|
||||
`#[cfg(test)] mod tests` — done in Phases 1–2 alongside the
|
||||
`#[cfg(test)] mod tests`, done in Phases 1–2 alongside the
|
||||
production code (80 lib tests, all passing).
|
||||
2. Ported integration tests from `alknet-tty-local/tests/` into
|
||||
`tests/` at the crate root:
|
||||
- [`tests/common/mod.rs`](../tests/common/mod.rs) — the
|
||||
- [`tests/common/mod.rs`](../tests/common/mod.rs): the
|
||||
`ClientSide` wire-protocol harness + `spawn_session` helper +
|
||||
`negotiate_pty_json` / `negotiate_pipe_json` builders. Imports
|
||||
renamed `alknet_core::auth::Identity` →
|
||||
`alkcall::core::auth::Identity`, `alknet_tty::...` →
|
||||
`alktty::...`.
|
||||
- [`tests/negotiation.rs`](../tests/negotiation.rs) — 4
|
||||
- [`tests/negotiation.rs`](../tests/negotiation.rs): 4
|
||||
negotiation-error scenarios (unknown_backend,
|
||||
malformed_negotiation ×3, allocate_failed).
|
||||
- [`tests/pipe.rs`](../tests/pipe.rs) — 6 pipe-mode scenarios
|
||||
- [`tests/pipe.rs`](../tests/pipe.rs): 6 pipe-mode scenarios
|
||||
(echo happy path, separate stderr, SIGTERM, cancel cleanup,
|
||||
resize no-op, stdout sentinel). The 2 cancel-cleanup /
|
||||
SIGTERM tests are `#[cfg(unix)]`.
|
||||
- [`tests/pty.rs`](../tests/pty.rs) — 8 PTY-mode scenarios
|
||||
- [`tests/pty.rs`](../tests/pty.rs): 8 PTY-mode scenarios
|
||||
(echo, interactive cat, resize, SIGINT, process-group
|
||||
signal, stdin-EOF sentinel, cancel cleanup,
|
||||
exit-chunk-is-last). The 4 signal / cancel-cleanup tests
|
||||
@@ -534,7 +536,7 @@ the 80/19 the plan originally recorded).
|
||||
|
||||
## Open Questions
|
||||
|
||||
### OQ-1: ALPN strings (RESOLVED — landed in alkcall 0.1.1)
|
||||
### OQ-1: ALPN strings (RESOLVED, landed in alkcall 0.1.1)
|
||||
|
||||
**Decision**: `alk/tty` (direct) and `alk/channels` (multiplexed). The
|
||||
`alknet/<name>` → `alk/<name>` rename landed in alkcall 0.1.1:
|
||||
@@ -552,12 +554,12 @@ correct.
|
||||
**Decision**: `channels/tty/sub` for consumer-opens, `channels/tty/pub`
|
||||
reserved for future producer-opens use case.
|
||||
|
||||
### OQ-3: `TtySession` API — stream vs callback (RESOLVED)
|
||||
### OQ-3: `TtySession` API, stream vs callback (RESOLVED)
|
||||
|
||||
**Decision**: Stream-based (`Stream<Item = Bytes>`). Consistent with the
|
||||
backend trait's `TtyHandle` shape.
|
||||
|
||||
### OQ-4: `drive_session` — keep the three-pump pattern? (RESOLVED)
|
||||
### OQ-4: `drive_session`, keep the three-pump pattern? (RESOLVED)
|
||||
|
||||
**Decision**: Keep it. It works, it's tested, and ADR-093 means the same
|
||||
code runs in both direct and channels modes.
|
||||
@@ -571,7 +573,7 @@ transitive dep.
|
||||
|
||||
### OQ-6: BAST schema scope (RESOLVED)
|
||||
|
||||
**Decision**: Documentation artifact only — `docs/architecture/tty-bast.md`.
|
||||
**Decision**: Documentation artifact only, at `docs/architecture/tty-bast.md`.
|
||||
alktty does not depend on alktype. The hand-rolled `wire.rs` codec is
|
||||
the runtime; the BAST document is the normative contract for downstream
|
||||
consumers. If runtime validation against the BAST becomes desirable
|
||||
@@ -598,7 +600,7 @@ of what was pushed.
|
||||
|
||||
## Risks and Concerns
|
||||
|
||||
### Risk: alkcall is v0.1.x — API stability
|
||||
### Risk: alkcall is v0.1.x (API stability)
|
||||
|
||||
alkcall is published at 0.1.1. Breaking changes are expected at this
|
||||
major-zero stage. The integration surface we use
|
||||
@@ -656,17 +658,17 @@ from the BAST will be wrong.
|
||||
`serde_json` and asserts the stream-type enum values match
|
||||
`wire.rs`'s `STREAM_STDIN`/`STREAM_STDOUT`/`STREAM_STDERR`/
|
||||
`STREAM_CTRL_IN`/`STREAM_CTRL_OUT` constants. Cheap; catches the
|
||||
common drift case. (Doesn't require alktype as a dep — just
|
||||
common drift case. (Doesn't require alktype as a dep, just
|
||||
`serde_json`, which we already have.)
|
||||
|
||||
## References
|
||||
|
||||
- [alkcall architecture README](/workspace/@alkdev/alkcall/docs/architecture/README.md)
|
||||
- [alkcall channels operations](/workspace/@alkdev/alkcall/src/channels/operations.rs) — `ChannelCore::register_openable`, `OpenHandler`
|
||||
- [alkcall channels client](/workspace/@alkdev/alkcall/src/channels/client.rs) — `ChannelClient::open_channel`, reference integration pattern
|
||||
- [alktype BAST format](/workspace/@alkdev/alktype/docs/architecture/bast-format.md) — normative format spec for the `tty-bast.md` document
|
||||
- [alknet-tty source](/workspace/@alkdev/alknet/crates/alknet-tty/src/) — port origin (wire, control, negotiation, backend, adapter)
|
||||
- [alknet-tty-local source](/workspace/@alkdev/alknet/crates/alknet-tty-local/src/) — port origin (local backend, pty, pipe)
|
||||
- [alknet-tty architecture docs](/workspace/@alkdev/alknet/docs/architecture/crates/tty/) — port origin for the spec docs
|
||||
- [ADR-093](/workspace/@alkdev/alknet/docs/architecture/decisions/093-channels-pure-channel-multiplexing.md) — TTY always uses 5-byte format
|
||||
- [ADR-077](/workspace/@alkdev/alknet/docs/architecture/decisions/077-tty-inside-channels.md) — reversed, historical context
|
||||
- [alkcall channels operations](/workspace/@alkdev/alkcall/src/channels/operations.rs): `ChannelCore::register_openable`, `OpenHandler`
|
||||
- [alkcall channels client](/workspace/@alkdev/alkcall/src/channels/client.rs): `ChannelClient::open_channel`, reference integration pattern
|
||||
- [alktype BAST format](/workspace/@alkdev/alktype/docs/architecture/bast-format.md): normative format spec for the `tty-bast.md` document
|
||||
- [alknet-tty source](/workspace/@alkdev/alknet/crates/alknet-tty/src/): port origin (wire, control, negotiation, backend, adapter)
|
||||
- [alknet-tty-local source](/workspace/@alkdev/alknet/crates/alknet-tty-local/src/): port origin (local backend, pty, pipe)
|
||||
- [alknet-tty architecture docs](/workspace/@alkdev/alknet/docs/architecture/crates/tty/): port origin for the spec docs
|
||||
- [ADR-093](/workspace/@alkdev/alknet/docs/architecture/decisions/093-channels-pure-channel-multiplexing.md): TTY always uses 5-byte format
|
||||
- [ADR-077](/workspace/@alkdev/alknet/docs/architecture/decisions/077-tty-inside-channels.md): reversed, historical context
|
||||
+31
-31
@@ -18,7 +18,7 @@ leverages:
|
||||
4. **Task-Driven**: Structured task graphs with dependency analysis
|
||||
5. **Safe Exit**: Always have a way to unblock progress when stuck
|
||||
6. **Categorical Estimates**: Use risk/scope/impact categories, not time
|
||||
estimates. These are structurally important — upstream failures multiply
|
||||
estimates. These are structurally important: upstream failures multiply
|
||||
downstream damage regardless of developer type (human or LLM). See the
|
||||
cost-benefit framework in taskgraph's framework docs.
|
||||
|
||||
@@ -47,11 +47,11 @@ approaches
|
||||
**Process**:
|
||||
|
||||
1. Architect creates the architecture documentation structure:
|
||||
- `docs/architecture/README.md` — index with doc table, ADR table, lifecycle
|
||||
- `docs/architecture/overview.md` — package purpose, exports, dependencies
|
||||
- `docs/architecture/<component>.md` — one focused doc per component/area
|
||||
- `docs/architecture/decisions/` — numbered ADR files (ADR-001, ADR-002, ...)
|
||||
- `docs/architecture/open-questions.md` — centralized tracker with OQ-IDs
|
||||
- `docs/architecture/README.md`: index with doc table, ADR table, lifecycle
|
||||
- `docs/architecture/overview.md`: package purpose, exports, dependencies
|
||||
- `docs/architecture/<component>.md`: one focused doc per component/area
|
||||
- `docs/architecture/decisions/`: numbered ADR files (ADR-001, ADR-002, ...)
|
||||
- `docs/architecture/open-questions.md`: centralized tracker with OQ-IDs
|
||||
2. All docs start in `Draft` status. Spec docs reference ADRs by number (not
|
||||
inline rationale) and OQs by number (not inline questions).
|
||||
3. Architecture Review validates for ambiguities, risks, and structural issues
|
||||
@@ -134,16 +134,16 @@ WHAT, ADRs explain WHY, open questions track what's unresolved.
|
||||
- Extract decisions into numbered ADRs in `decisions/` directory
|
||||
- Centralize open questions in `open-questions.md` with OQ-IDs
|
||||
- Maintain `README.md` as the architecture index
|
||||
- Keep spec documents focused (~500 lines) — reference ADRs and OQs, don't inline them
|
||||
- Keep spec documents focused (~500 lines): reference ADRs and OQs, don't inline them
|
||||
- Redirect exploration work to Research Specialist
|
||||
- Iterate based on review feedback
|
||||
|
||||
**Deliverables**:
|
||||
|
||||
- `docs/architecture/README.md` — index with doc table, ADR table, lifecycle
|
||||
- `docs/architecture/<component>.md` — focused spec documents
|
||||
- `docs/architecture/decisions/` — numbered ADR files
|
||||
- `docs/architecture/open-questions.md` — centralized OQ tracker
|
||||
- `docs/architecture/README.md`: index with doc table, ADR table, lifecycle
|
||||
- `docs/architecture/<component>.md`: focused spec documents
|
||||
- `docs/architecture/decisions/`: numbered ADR files
|
||||
- `docs/architecture/open-questions.md`: centralized OQ tracker
|
||||
|
||||
---
|
||||
|
||||
@@ -179,17 +179,17 @@ sessions.
|
||||
**Mode**: Primary (manages worktrees and agent sessions)
|
||||
|
||||
**Uses**: The `worktree` tool from the **open-coordinator** opencode plugin.
|
||||
Single tool with `{action, args}` dispatch. Role is auto-detected — coordinator
|
||||
Single tool with `{action, args}` dispatch. Role is auto-detected: coordinator
|
||||
sessions get the full operation set, spawned implementation sessions get a
|
||||
limited set (current, notify, status). No mode toggle required.
|
||||
|
||||
**Tools**:
|
||||
|
||||
- `worktree({action, args})` — spawn, sessions, dashboard, message, abort,
|
||||
- `worktree({action, args})`: spawn, sessions, dashboard, message, abort,
|
||||
cleanup
|
||||
- Bash (opencode CLI for session interaction)
|
||||
- Read (monitor task files)
|
||||
- `memory` / `memory_compact` — context management and session history (via
|
||||
- `memory` / `memory_compact`: context management and session history (via
|
||||
@alkdev/open-memory, when available)
|
||||
|
||||
**Key Behaviors**:
|
||||
@@ -218,10 +218,10 @@ limited set (current, notify, status). No mode toggle required.
|
||||
**Tools**:
|
||||
|
||||
- Read, Write, Edit, Glob, Grep, Bash
|
||||
- `worktree({action: "notify", ...})` — report progress/blockers to coordinator
|
||||
- `worktree({action: "current"})` — verify worktree assignment
|
||||
- `worktree({action: "notify", ...})`: report progress/blockers to coordinator
|
||||
- `worktree({action: "current"})`: verify worktree assignment
|
||||
- webSearch (documentation lookup)
|
||||
- `memory` / `memory_compact` — context management (via @alkdev/open-memory,
|
||||
- `memory` / `memory_compact`: context management (via @alkdev/open-memory,
|
||||
when available)
|
||||
|
||||
**Key Behaviors**:
|
||||
@@ -388,7 +388,7 @@ Implement OAuth2 authentication with provider abstraction.
|
||||
### Categorical Estimates
|
||||
|
||||
These fields are structurally important, not optional metadata. They power
|
||||
`taskgraph decompose`, `risk-path`, `critical`, and `bottleneck` — commands that
|
||||
`taskgraph decompose`, `risk-path`, `critical`, and `bottleneck`, commands that
|
||||
reveal structural problems in the task graph. A task missing `scope`, `risk`,
|
||||
`impact`, or `level` is a red flag indicating incomplete decomposition. See the
|
||||
cost-benefit framework in taskgraph's framework docs for the reasoning.
|
||||
@@ -422,15 +422,15 @@ cost-benefit framework in taskgraph's framework docs for the reasoning.
|
||||
|
||||
### Architecture work and the `level` field
|
||||
|
||||
Architecture work uses the same taskgraph format as implementation work —
|
||||
Architecture work uses the same taskgraph format as implementation work;
|
||||
there is no separate `architecture` level. The `level` enum
|
||||
(`planning, decomposition, implementation, review, research`) already covers
|
||||
architecture activities:
|
||||
|
||||
| Architecture activity | `level` | Rationale |
|
||||
|---|---|---|
|
||||
| Writing an ADR (the decision) | `implementation` | The ADR *is* the deliverable — the implementation of an architecture decision. Calling it `planning` would mislabel the artifact. |
|
||||
| Decomposing a spec into ADRs + OQs | `decomposition` | Direct fit — breaking a large architecture area into decideable units. |
|
||||
| Writing an ADR (the decision) | `implementation` | The ADR *is* the deliverable: the implementation of an architecture decision. Calling it `planning` would mislabel the artifact. |
|
||||
| Decomposing a spec into ADRs + OQs | `decomposition` | Direct fit: breaking a large architecture area into decideable units. |
|
||||
| Architecture review pass | `review` | Direct fit. |
|
||||
| Research / POC before a decision | `research` | Direct fit. |
|
||||
| Spec writing (overview, component spec) | `implementation` | The spec is the deliverable. |
|
||||
@@ -480,7 +480,7 @@ the deferral has two halves that must stay in sync:
|
||||
2. **The machine-readable half** (in `tasks/architecture/`): an
|
||||
external-trigger tracker task represents the unblocking condition. It is
|
||||
tagged `[external-trigger, deferred-oq]`, has `risk: trivial` and
|
||||
`level: research` (it is not actionable work — it tracks whether the
|
||||
`level: research` (it is not actionable work; it tracks whether the
|
||||
external condition has arrived), and its `id` is referenced from the OQ's
|
||||
`Blocked on:` text. This is the surface for the task graph: `taskgraph`
|
||||
tools can reason about it, and downstream work that depends on the
|
||||
@@ -490,7 +490,7 @@ When the unblocking condition arrives (a use case materializes, a crate is
|
||||
specced), the tracker task is marked `completed` and the OQ transitions from
|
||||
`deferred(scope)` to `open` (or directly to `resolved` if no architecture
|
||||
decision remains). The two halves serve different audiences and use one edge
|
||||
type (`depends_on`) — the reverse lookup uses `taskgraph dependents`. Do not
|
||||
type (`depends_on`); the reverse lookup uses `taskgraph dependents`. Do not
|
||||
add a `blocks:` field; it duplicates the edge and creates a sync hazard.
|
||||
|
||||
## Review Injection
|
||||
@@ -509,7 +509,7 @@ Use graph analysis to determine where reviews should happen:
|
||||
### Current (open-coordinator plugin)
|
||||
|
||||
The Coordinator uses the `worktree` tool from the open-coordinator opencode
|
||||
plugin. It's a single tool with `{action, args}` dispatch — no separate
|
||||
plugin. It's a single tool with `{action, args}` dispatch; no separate
|
||||
enable/toggle steps. Role is auto-detected from session state.
|
||||
|
||||
```
|
||||
@@ -563,13 +563,13 @@ a worktree.
|
||||
When the open-memory plugin is available alongside open-coordinator, the
|
||||
coordinator gains:
|
||||
|
||||
- `memory({tool: "children", args: {sessionId: "..."}})` — view sub-agent
|
||||
- `memory({tool: "children", args: {sessionId: "..."}})`: view sub-agent
|
||||
sessions spawned from the coordinator
|
||||
- `memory({tool: "messages", args: {sessionId: "..."}})` — read a spawned
|
||||
- `memory({tool: "messages", args: {sessionId: "..."}})`: read a spawned
|
||||
session's conversation for debugging
|
||||
- `memory({tool: "context"})` — check context window usage before long
|
||||
- `memory({tool: "context"})`: check context window usage before long
|
||||
monitoring sessions
|
||||
- `memory_compact()` — proactively compact at natural breakpoints
|
||||
- `memory_compact()`: proactively compact at natural breakpoints
|
||||
|
||||
Implementation agents can also use `memory({tool: "context"})` and
|
||||
`memory_compact()` to manage their context during long tasks.
|
||||
@@ -593,7 +593,7 @@ Once the hub is operational, coordination uses native operations:
|
||||
```
|
||||
|
||||
State moves from in-process tracking to Postgres `mappings` table. The
|
||||
open-coordinator plugin becomes unnecessary — the hub provides the same
|
||||
open-coordinator plugin becomes unnecessary; the hub provides the same
|
||||
capabilities as server-side operations accessible from any environment.
|
||||
|
||||
## Document Structure
|
||||
@@ -660,7 +660,7 @@ Accepted | Proposed | Deprecated | Superseded
|
||||
(Links to related specs and ADRs)
|
||||
```
|
||||
|
||||
Numbering starts at 001 within each project. ADRs are stable — once Accepted,
|
||||
Numbering starts at 001 within each project. ADRs are stable: once Accepted,
|
||||
they don't revert. If superseded, mark the old one and create a new one.
|
||||
|
||||
### Open Questions Format
|
||||
@@ -676,7 +676,7 @@ they don't revert. If superseded, mark the old one and create a new one.
|
||||
- **Status**: open | resolved | deferred(scope) | partially resolved
|
||||
- **Priority**: high | medium | low
|
||||
- **Resolution**: (when resolved)
|
||||
- **Blocked on**: (when deferred — concrete dependency that would unblock)
|
||||
- **Blocked on**: (when deferred: concrete dependency that would unblock)
|
||||
- **Cross-references**: OQ-NN, ADR-NNN
|
||||
```
|
||||
|
||||
|
||||
+9
-9
@@ -6,12 +6,12 @@ operating rules live in `docs/plans/fuzzing.md` (adopted from alkcall's
|
||||
|
||||
## Layout
|
||||
|
||||
- `fuzz_targets/` — nightly-only `fuzz_target!` binaries (thin wrappers).
|
||||
- `shared/` — stable-toolchain library holding the invariant logic; the
|
||||
- `fuzz_targets/`: nightly-only `fuzz_target!` binaries (thin wrappers).
|
||||
- `shared/` is the stable-toolchain library holding the invariant logic; the
|
||||
corpus replay tests run here on plain `cargo test`.
|
||||
- `corpus/<target>/` — committed seeds (regenerate with
|
||||
- `corpus/<target>/` holds the committed seeds (regenerate with
|
||||
`python3 fuzz/gen_fuzz_seeds.py`).
|
||||
- `artifacts/` — gitignored crash/oom/timeout artifacts + campaign logs.
|
||||
- `artifacts/`: gitignored crash/oom/timeout artifacts + campaign logs.
|
||||
|
||||
## Targets
|
||||
|
||||
@@ -20,9 +20,9 @@ operating rules live in `docs/plans/fuzzing.md` (adopted from alkcall's
|
||||
| `chunk_frame` | `ChunkReader`/`ChunkWriter` (5-byte chunk codec, ADR-001) |
|
||||
| `negotiation_frame` | `NegotiationReader`/`NegotiationWriter` + `NegotiateRequest` + `error_response_bytes` + the cross-codec peek-disambiguation seam |
|
||||
| `control_json` | `ControlMessage::from_slice`/`to_json` + `signal_from_name` |
|
||||
| `session_opseq` | the producer session pump (`drive_session_pre_negotiated`) + a fuzz-local mock backend — the stateful op-sequence target (ADR-004/ADR-005 invariants; the alkcall-bug-class hunt) |
|
||||
| `session_opseq` | the producer session pump (`drive_session_pre_negotiated`) + a fuzz-local mock backend: the stateful op-sequence target (ADR-004/ADR-005 invariants; the alkcall-bug-class hunt) |
|
||||
|
||||
## Running a campaign — always detached
|
||||
## Running a campaign (always detached)
|
||||
|
||||
Agent sessions must never run fuzzing in the foreground (an OOM in a
|
||||
target can take down the session host; see docs/plans/fuzzing.md §3).
|
||||
@@ -63,11 +63,11 @@ Run campaigns before releases and after touching `src/wire.rs`,
|
||||
## session_opseq notes
|
||||
|
||||
The op-sequence target is inherently slower than the byte-format targets
|
||||
(each input drives a full tokio session with bounded reads and yields —
|
||||
tens of exec/s per worker, not millions). That is expected: the value is
|
||||
(each input drives a full tokio session with bounded reads and yields,
|
||||
which means tens of exec/s per worker, not millions). That is expected: the value is
|
||||
state-space exploration, not throughput. It uses `tokio::time::timeout`
|
||||
internally, so the nightly target needs the `--features time` tokio
|
||||
surface already present via the shared crate's deps — no extra flags.
|
||||
surface already present via the shared crate's deps; no extra flags.
|
||||
Its invariants (kill-on-Drop, exit-chunk-is-last, sentinel/FIFO
|
||||
semantics) are the ones unit tests assert individually; the fuzzer
|
||||
explores interleavings no example test can enumerate.
|
||||
+25
-25
@@ -10,13 +10,13 @@
|
||||
//!
|
||||
//! A `alk/tty` session on one bidi stream proceeds in three phases:
|
||||
//!
|
||||
//! 1. **Negotiation** — read a length-prefixed JSON frame, parse
|
||||
//! 1. **Negotiation**: read a length-prefixed JSON frame, parse
|
||||
//! `NegotiateRequest`, validate (`carriage == "raw"`, `cmd` non-empty),
|
||||
//! look up the `TtyBackend`, run access control, construct `TtyParams`.
|
||||
//! Errors → JSON error response in negotiation framing, stream close.
|
||||
//! 2. **Allocation** — `backend.allocate(¶ms)`. Errors →
|
||||
//! 2. **Allocation**: `backend.allocate(¶ms)`. Errors →
|
||||
//! `allocate_failed` JSON error response, stream close.
|
||||
//! 3. **Raw carriage** — three concurrent pumps:
|
||||
//! 3. **Raw carriage**: three concurrent pumps:
|
||||
//! - **A. stdout → client**: `TtyHandle.stdout` → stdout chunks
|
||||
//! (stream_type 1); a concurrent stderr pump emits stderr chunks
|
||||
//! (stream_type 2) when `TtyHandle.stderr` is `Some`. On backend stdout
|
||||
@@ -48,17 +48,17 @@
|
||||
//! 3` was documented as "bidirectional" but the adapter ignored `Exit`
|
||||
//! from the client because the two directions were indistinguishable on
|
||||
//! the same stream_type. The split makes the bidirectionality explicit
|
||||
//! — see `docs/architecture/tty-wire.md` §"Control Channel".
|
||||
//! (see `docs/architecture/tty-wire.md` §"Control Channel").
|
||||
//!
|
||||
//! # Cancel cleanup (ADR-056)
|
||||
//!
|
||||
//! On connection drop or stream reset, the pump tasks are dropped, which
|
||||
//! drops the `TtyHandle`, which drops the `exit_code` future without
|
||||
//! driving it to completion — the backend's kill-on-`Drop` guard fires
|
||||
//! driving it to completion; the backend's kill-on-`Drop` guard fires
|
||||
//! and kills the session target. The adapter has no separate kill
|
||||
//! method; the cleanup is wired into the `exit_code` future's `Drop` by
|
||||
//! the backend. A client closing the write half (stdin EOF) does NOT
|
||||
//! trigger cancel-cleanup — the session runs to completion.
|
||||
//! trigger cancel-cleanup, and the session runs to completion.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
@@ -91,7 +91,7 @@ pub const TTY_OPEN_SCOPE: &str = "tty:open";
|
||||
/// resource-ownership check. `handle()` accepts the connection and loops
|
||||
/// `accept_bi`, dispatching each bidi stream to a [`drive_session`] task.
|
||||
///
|
||||
/// One `alk/tty` connection hosts multiple terminal sessions — one session
|
||||
/// One `alk/tty` connection hosts multiple terminal sessions: one session
|
||||
/// per bidi stream (DP-6). Sessions are independent: one session's exit
|
||||
/// doesn't affect another.
|
||||
pub struct TtyAdapter {
|
||||
@@ -101,7 +101,7 @@ pub struct TtyAdapter {
|
||||
|
||||
impl TtyAdapter {
|
||||
/// Construct with the given backend map and no ownership provider
|
||||
/// (scope-gate only — no resource-level ACL).
|
||||
/// (scope-gate only; no resource-level ACL).
|
||||
pub fn new(backends: HashMap<String, Arc<dyn TtyBackend>>) -> Self {
|
||||
Self {
|
||||
backends: Arc::new(backends),
|
||||
@@ -170,7 +170,7 @@ fn has_scope(identity: &Option<Identity>, scope: &str) -> bool {
|
||||
///
|
||||
/// Shared by the direct path's failure arms and the channels open
|
||||
/// handler (`make_tty_open_handler` writes the same `malformed_negotiation`
|
||||
/// frame on a `NegotiateRequest` parse failure of the open op's `input` —
|
||||
/// frame on a `NegotiateRequest` parse failure of the open op's `input`;
|
||||
/// the pre-negotiated driver has no writer to consume, so the handler
|
||||
/// builds its own over the channel's write half). `pub(crate)` so the
|
||||
/// channels handler reuses it; not public API.
|
||||
@@ -195,11 +195,11 @@ pub(crate) async fn send_negotiation_error<W: AsyncWrite + Unpin>(
|
||||
/// `client_send` / `client_recv` are the two halves of the bidi stream
|
||||
/// (split from the `BiStream` yielded by `accept_bi` via `tokio::io::split`).
|
||||
/// Returns when the session is complete (exit chunk sent, stream closed) or
|
||||
/// when the stream is reset (cancel-cleanup path — no exit chunk sent).
|
||||
/// when the stream is reset (cancel-cleanup path: no exit chunk sent).
|
||||
///
|
||||
/// This is the per-stream session driver — generalized from the POC's
|
||||
/// This is the per-stream session driver, generalized from the POC's
|
||||
/// `session::drive_session` to the [`TtyBackend`] trait. The negotiation
|
||||
/// frame is read from the wire (the direct-ALPN path — ADR-052); the
|
||||
/// frame is read from the wire (the direct-ALPN path, ADR-052); the
|
||||
/// channels path (open-op `input` is the negotiation) uses
|
||||
/// [`drive_session_pre_negotiated`] instead.
|
||||
pub async fn drive_session(
|
||||
@@ -217,18 +217,18 @@ pub async fn drive_session(
|
||||
}
|
||||
|
||||
/// Drive a `alk/tty` session whose negotiation already happened out of
|
||||
/// band — the channels path (ADR-009). The open op's registry-validated
|
||||
/// band: the channels path (ADR-009). The open op's registry-validated
|
||||
/// `input` was parsed into `req` by the channels wrapper; the raw-chunk
|
||||
/// data plane starts immediately. No negotiation frame is read from the
|
||||
/// wire, and none is written by the client.
|
||||
///
|
||||
/// Validation still runs here (`carriage`/`cmd`/backend lookup) plus the
|
||||
/// ADR-050 ownership check — failures go to the client as a negotiation
|
||||
/// ADR-050 ownership check; failures go to the client as a negotiation
|
||||
/// error frame on the channel stream (the same framing the direct path
|
||||
/// uses, so the consumer's M1 disambiguation read applies unchanged).
|
||||
/// The `tty:open` scope gate is NOT re-checked: on the channels path the
|
||||
/// registry's `AccessControl` enforced it at open time (the identity view
|
||||
/// the registry checked — resolved from the connection — is the
|
||||
/// the registry checked (resolved from the connection) is the
|
||||
/// authoritative one; the handler-side identity is only the
|
||||
/// ownership-check subject).
|
||||
pub async fn drive_session_pre_negotiated(
|
||||
@@ -254,7 +254,7 @@ pub async fn drive_session_pre_negotiated(
|
||||
}
|
||||
|
||||
/// Drive a `alk/tty` session whose negotiation AND allocation already
|
||||
/// happened out of band — the channels path with an establisher
|
||||
/// happened out of band, namely the channels path with an establisher
|
||||
/// (alkcall 0.6, ADR-010 as amended). The establisher validated the
|
||||
/// request and ran `backend.allocate`; its `TtyHandle` arrived via the
|
||||
/// establishment plan (`AllocatedHandle`, taken by the open handler).
|
||||
@@ -289,11 +289,11 @@ pub(crate) async fn drive_session_pre_allocated<W, R>(
|
||||
///
|
||||
/// `enforce_scope` gates the `tty:open` scope check: `true` on the direct
|
||||
/// path (the adapter is the only gate), `false` on the channels path (the
|
||||
/// registry's `AccessControl` enforced the scope at open time — see
|
||||
/// registry's `AccessControl` enforced the scope at open time; see
|
||||
/// [`drive_session_pre_negotiated`]). The scope gate runs *first* (review
|
||||
/// #003 P5): checking it after the backend lookup would let an
|
||||
/// authenticated-but-unscoped identity distinguish registered backend
|
||||
/// names (`unknown_backend`) from unregistered ones (`forbidden`) — a
|
||||
/// names (`unknown_backend`) from unregistered ones (`forbidden`): a
|
||||
/// name-enumeration differential. Gated first, every unscoped request
|
||||
/// gets the same `forbidden` regardless of the request body.
|
||||
#[allow(clippy::type_complexity)]
|
||||
@@ -458,8 +458,8 @@ where
|
||||
/// Enforces the exit-chunk-is-last invariant (ADR-055): the exit chunk
|
||||
/// is enqueued only after the stdout/stderr pumps have finished sending
|
||||
/// AND `exit_code` has resolved. The drainer runs concurrently with the
|
||||
/// pumps so the shared writer channel is always being consumed — see
|
||||
/// `drain_chunks`.
|
||||
/// pumps so the shared writer channel is always being consumed (see
|
||||
/// `drain_chunks`).
|
||||
async fn pump_session<W, R>(
|
||||
client_write: W,
|
||||
client_read: R,
|
||||
@@ -519,7 +519,7 @@ where
|
||||
|
||||
drop(writer_tx);
|
||||
// The client→backend pump outlives the session data plane only until
|
||||
// the client disconnects; aborting it at session end is tighter — no
|
||||
// the client disconnects; aborting it at session end is tighter: no
|
||||
// lingering task holding the backend stdin half after the exit chunk.
|
||||
input_pump.abort();
|
||||
|
||||
@@ -614,7 +614,7 @@ async fn pump_stderr(
|
||||
/// (`Exit` is server→client only, belongs on `STREAM_CTRL_OUT`); the
|
||||
/// adapter ignores it. (The previous single `STREAM_CONTROL = 3`
|
||||
/// couldn't distinguish the two directions, so `Exit` from the client
|
||||
/// was always ignored — the split makes the rejection explicit.)
|
||||
/// was always ignored; the split makes the rejection explicit.)
|
||||
/// - `STREAM_CTRL_OUT` (stream_type 4) from the client is a protocol
|
||||
/// violation (it's the server→client half); the adapter ignores it.
|
||||
async fn pump_client_to_backend<R>(
|
||||
@@ -828,7 +828,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `AsyncWrite` adapter over `mpsc::Sender<Bytes>` — the test backend's
|
||||
/// `AsyncWrite` adapter over `mpsc::Sender<Bytes>`, the test backend's
|
||||
/// stdin sink. On shutdown, drops the sender so the test's
|
||||
/// `stdin_rx` observes EOF (channel close).
|
||||
struct TestStdinSink {
|
||||
@@ -877,7 +877,7 @@ mod tests {
|
||||
|
||||
/// A kill-guard future wrapping `oneshot::Receiver<Result<i32, TtyError>>`.
|
||||
/// On `Drop`-without-resolve, sets `cancel_dropped` to true (the
|
||||
/// cancel-cleanup signal for tests — ADR-056).
|
||||
/// cancel-cleanup signal for tests, ADR-056).
|
||||
struct ExitFuture {
|
||||
rx: Option<oneshot::Receiver<Result<i32, TtyError>>>,
|
||||
cancel_dropped: Arc<Mutex<bool>>,
|
||||
@@ -1386,7 +1386,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn exit_chunk_arrives_on_ctrl_out_not_ctrl_in() {
|
||||
// Verifies the adapter emits `Exit` on `STREAM_CTRL_OUT` (4), not
|
||||
// `STREAM_CTRL_IN` (3) — the Phase 7 bidirectionality fix. A client
|
||||
// `STREAM_CTRL_IN` (3), the Phase 7 bidirectionality fix. A client
|
||||
// distinguishing the two halves can route exit vs. control
|
||||
// without parsing the JSON tag first.
|
||||
let (backend, _control, _cancel) = TestBackend::builder().build();
|
||||
|
||||
+20
-20
@@ -5,7 +5,7 @@
|
||||
//! (`crate::adapter`) and the backend crates (`alktty`'s own `local`
|
||||
//! feature module, future `alknet-docker`, `alknet-ssh`). alktty
|
||||
//! defines the trait; the
|
||||
//! backends implement it. The trait shape is a one-way door — changing it
|
||||
//! backends implement it. The trait shape is a one-way door: changing it
|
||||
//! after backends exist is a rewrite across crates. The adapter holds a
|
||||
//! `HashMap<String, Arc<dyn TtyBackend>>` keyed by the negotiation frame's
|
||||
//! `backend` string and pumps the `TtyHandle` fields bidirectionally;
|
||||
@@ -32,7 +32,7 @@ use crate::negotiation::{NegotiateRequest, TerminalParamsWire};
|
||||
/// `futures::future::BoxFuture<'static, T>` to match the alkcall
|
||||
/// convention (alkcall pulls in `futures`, so no new transitive dep).
|
||||
/// Functionally identical to the hand-rolled `Pin<Box<dyn Future +
|
||||
/// Send + 'static>>` — `futures::future::BoxFuture<'static, T>` is
|
||||
/// Send + 'static>>`: `futures::future::BoxFuture<'static, T>` is
|
||||
/// exactly that alias.
|
||||
pub type BoxFuture<T> = futures::future::BoxFuture<'static, T>;
|
||||
|
||||
@@ -40,7 +40,7 @@ pub type BoxFuture<T> = futures::future::BoxFuture<'static, T>;
|
||||
/// [`TtyHandle::exit_code`] future.
|
||||
///
|
||||
/// `#[non_exhaustive]` so new variants are additive (two-way-door extension
|
||||
/// within the one-way trait shape — ADR-053).
|
||||
/// within the one-way trait shape; see ADR-053).
|
||||
#[non_exhaustive]
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum TtyError {
|
||||
@@ -79,13 +79,13 @@ pub struct TerminalParams {
|
||||
pub rows: u16,
|
||||
pub pixel_width: u16,
|
||||
pub pixel_height: u16,
|
||||
/// Reserved — OQ-44; backends MUST ignore the content in v1.
|
||||
/// Reserved (OQ-44); backends MUST ignore the content in v1.
|
||||
pub modes: serde_json::Value,
|
||||
}
|
||||
|
||||
/// The allocation request the adapter passes to [`TtyBackend::allocate`].
|
||||
///
|
||||
/// `terminal: None` is pipe/runner mode (no PTY, separate stdout/stderr —
|
||||
/// `terminal: None` is pipe/runner mode (no PTY, separate stdout/stderr;
|
||||
/// ADR-054). `terminal: Some` is PTY mode (stdout/stderr merged into
|
||||
/// `stdout` by the kernel PTY, real terminal semantics).
|
||||
///
|
||||
@@ -95,7 +95,7 @@ pub struct TerminalParams {
|
||||
/// §"Backend params are opaque."
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TtyParams {
|
||||
/// Terminal parameters. `None` = pipe mode (no PTY — ADR-054). `Some` =
|
||||
/// Terminal parameters. `None` = pipe mode (no PTY; ADR-054). `Some` =
|
||||
/// allocate a PTY with these dimensions.
|
||||
pub terminal: Option<TerminalParams>,
|
||||
/// Command vector (`argv[0]` + args). Non-empty.
|
||||
@@ -127,7 +127,7 @@ impl From<TerminalParamsWire> for TerminalParams {
|
||||
/// Map a wire negotiation frame to the allocation request the backend
|
||||
/// consumes. The adapter calls this after parsing the negotiation
|
||||
/// carriage; `backend_params` is passed through verbatim (alktty has
|
||||
/// zero knowledge of any backend's params shape — ADR-053). Lives here so
|
||||
/// zero knowledge of any backend's params shape; ADR-053). Lives here so
|
||||
/// the adapter does not hand-roll the mapping.
|
||||
impl From<NegotiateRequest> for TtyParams {
|
||||
fn from(req: NegotiateRequest) -> Self {
|
||||
@@ -144,25 +144,25 @@ impl From<NegotiateRequest> for TtyParams {
|
||||
/// What a backend's `allocate()` produces. The adapter pumps these fields
|
||||
/// bidirectionally against the wire format (ADR-052).
|
||||
pub struct TtyHandle {
|
||||
/// Stdin writer — bytes the adapter pumps from client stdin chunks.
|
||||
/// `tokio::io::AsyncWrite` (the tokio flavor, not `futures::io` — they
|
||||
/// Stdin writer: bytes the adapter pumps from client stdin chunks.
|
||||
/// `tokio::io::AsyncWrite` (the tokio flavor, not `futures::io`, as they
|
||||
/// are incompatible traits; the tokio stack is the adapter's runtime).
|
||||
pub stdin: Box<dyn tokio::io::AsyncWrite + Send + Unpin>,
|
||||
/// Stdout stream — bytes the adapter pumps to client stdout chunks.
|
||||
/// Stdout stream: bytes the adapter pumps to client stdout chunks.
|
||||
/// Ends when the backend's stdout reaches EOF.
|
||||
pub stdout: Pin<Box<dyn Stream<Item = Bytes> + Send>>,
|
||||
/// Stderr stream — `None` for PTY backends (stdout/stderr merged into
|
||||
/// Stderr stream: `None` for PTY backends (stdout/stderr merged into
|
||||
/// `stdout` by the kernel PTY). `Some` for pipe backends (separate
|
||||
/// streams).
|
||||
pub stderr: Option<Pin<Box<dyn Stream<Item = Bytes> + Send>>>,
|
||||
/// Exit code — a `Future` the adapter awaits. Resolves when the
|
||||
/// Exit code: a `Future` the adapter awaits. Resolves when the
|
||||
/// process/container/SSH exec exits. The adapter sends the result as
|
||||
/// the `{"type":"exit","code":N}` control chunk (ADR-055) and closes
|
||||
/// the stream. This is a `BoxFuture`, not a method on `TtyHandle`, so
|
||||
/// the adapter can `select` between exit and stream-close without
|
||||
/// coupling to the other fields (REQ-TTY-01).
|
||||
///
|
||||
/// # ADR-056 — kill-on-Drop contract
|
||||
/// # ADR-056: kill-on-Drop contract
|
||||
///
|
||||
/// Dropping this future without driving it to completion MUST kill the
|
||||
/// session target (the child process, the docker exec, the SSH
|
||||
@@ -182,7 +182,7 @@ pub struct TtyHandle {
|
||||
/// adapter awaited it to completion). See ADR-056 and `tty-local.md`
|
||||
/// §"Cancel-Cleanup" for the local backend's mechanism.
|
||||
pub exit_code: BoxFuture<Result<i32, TtyError>>,
|
||||
/// Control handle (resize, signal) — `Clone` so the adapter can hand
|
||||
/// Control handle (resize, signal); `Clone` so the adapter can hand
|
||||
/// it to the spawned control-chunk dispatcher. `None` only when the
|
||||
/// backend genuinely has no control path. See OQ-43.
|
||||
pub control: Option<TtyControlHandle>,
|
||||
@@ -190,7 +190,7 @@ pub struct TtyHandle {
|
||||
|
||||
/// Control path for a live terminal session: resize and signal forwarding.
|
||||
///
|
||||
/// Object-safe (`Send + Sync`, no `Clone` — `Clone` is not object-safe).
|
||||
/// Object-safe (`Send + Sync`, no `Clone`, since `Clone` is not object-safe).
|
||||
/// The `Clone`-ability lives on the [`TtyControlHandle`] newtype, which
|
||||
/// holds the trait object behind an `Arc`. A backend produces its own
|
||||
/// control type via `TtyControlHandle::new(Arc::new(MyControl))` without
|
||||
@@ -209,7 +209,7 @@ pub trait TtyControl: Send + Sync {
|
||||
}
|
||||
|
||||
/// The `Clone`-able handle to a backend's control path. The
|
||||
/// [`TtyControl`] trait is NOT `Clone` (`Clone` is not object-safe —
|
||||
/// [`TtyControl`] trait is NOT `Clone` (`Clone` is not object-safe;
|
||||
/// `fn clone(&self) -> Self` returns `Self`, which forbids `dyn` dispatch);
|
||||
/// the `Clone`-ability lives on this concrete newtype, which holds the
|
||||
/// trait object behind an `Arc`. The adapter clones the `Arc` to hand a
|
||||
@@ -244,7 +244,7 @@ impl TtyControlHandle {
|
||||
/// `HashMap<String, Arc<dyn TtyBackend>>` keyed by the negotiation frame's
|
||||
/// `backend` string and dispatches by that key.
|
||||
///
|
||||
/// # REQ-TTY-01 — backends need not be natively async
|
||||
/// # REQ-TTY-01: backends need not be natively async
|
||||
///
|
||||
/// The adapter-facing types this trait returns (`AsyncWrite`,
|
||||
/// `Stream<Item = Bytes>`, `BoxFuture`, `TtyControl`) are the **adapter's
|
||||
@@ -260,7 +260,7 @@ impl TtyControlHandle {
|
||||
/// wrapped in a kill-guard future (exit). The adapter consumes the bridged
|
||||
/// async-facing types and is unaware of the threading.
|
||||
///
|
||||
/// # ADR-056 — kill-on-Drop contract
|
||||
/// # ADR-056: kill-on-Drop contract
|
||||
///
|
||||
/// The [`TtyHandle::exit_code`] future returned by `allocate()` MUST kill
|
||||
/// the session target when dropped without being driven to completion. See
|
||||
@@ -274,7 +274,7 @@ pub trait TtyBackend: Send + Sync {
|
||||
|
||||
/// The pre-existing resource this session targets, for ownership
|
||||
/// checks (ADR-050). `None` = no pre-existing resource (the session
|
||||
/// creates its own — local process, SSH channel). `Some((kind, id))`
|
||||
/// creates its own: a local process, an SSH channel). `Some((kind, id))`
|
||||
/// = the session targets an existing resource the caller must own
|
||||
/// (e.g., `DockerTtyBackend` returns `Some(("container", id))`). The
|
||||
/// adapter calls this at negotiation to gate access; the backend
|
||||
@@ -371,7 +371,7 @@ impl TtyBackend for MockBackend {
|
||||
}
|
||||
}
|
||||
|
||||
/// `AsyncWrite` adapter over an `mpsc::Sender<Bytes>` — the mock
|
||||
/// `AsyncWrite` adapter over an `mpsc::Sender<Bytes>`, the mock
|
||||
/// backend's stdin sink. Copies the buffer into a `Bytes` and best-effort
|
||||
/// sends; on a full channel returns `Pending`, on a closed channel returns
|
||||
/// a broken-pipe error.
|
||||
|
||||
+46
-46
@@ -1,4 +1,4 @@
|
||||
//! Producer half of the channels integration — the `register_openable`
|
||||
//! Producer half of the channels integration: the `register_openable`
|
||||
//! helper and the `TtyOpenHandler` factory (ADR-047 §3, amended by
|
||||
//! alkcall ADR-049 / alktty ADR-010 with an establishment phase).
|
||||
//!
|
||||
@@ -15,10 +15,10 @@
|
||||
//! `{ channel_id }`. The establisher performs the semantic validation
|
||||
//! (full `NegotiateRequest` parse, `carriage == "raw"`, backend
|
||||
//! lookup, ADR-050 ownership check) and fails the open op with
|
||||
//! `channel:open_failed` on rejection — no phantom channel. The
|
||||
//! `channel:open_failed` on rejection; no phantom channel. The
|
||||
//! `TtyOpenHandler` receives the channel's `Connection` (data-plane
|
||||
//! ALPN `alk/tty`), calls `accept_bi()` to get the channel's
|
||||
//! `BiStream`, and runs the session pumps on it — the same three-pump
|
||||
//! `BiStream`, and runs the session pumps on it: the same three-pump
|
||||
//! session driver as the direct-ALPN path, minus the wire-frame
|
||||
//! negotiation phase (the open op's `input` is the negotiation,
|
||||
//! ADR-009). Backend allocation runs in the establisher (alkcall 0.6
|
||||
@@ -28,7 +28,7 @@
|
||||
//!
|
||||
//! The access control (scope-gate + ownership) is wired into the
|
||||
//! `OperationSpec`'s `AccessControl` and enforced by the registry's
|
||||
//! `invoke`/`invoke_streaming` before the establisher runs — the
|
||||
//! `invoke`/`invoke_streaming` before the establisher runs; the
|
||||
//! establisher itself only runs the ADR-050 ownership check (the
|
||||
//! scope gate is the registry's); this is the difference from the
|
||||
//! direct-ALPN `TtyAdapter`, which does its own ad-hoc scope check;
|
||||
@@ -65,7 +65,7 @@ pub const OP_TTY_OPEN: &str = "channels/tty/sub";
|
||||
pub const TTY_ALPN: &str = "alk/tty";
|
||||
|
||||
/// Register the `channels/tty/sub` open op on a per-connection
|
||||
/// `OperationRegistry` (ADR-047 §3, as amended 2026-08-13 —
|
||||
/// `OperationRegistry` (ADR-047 §3, as amended 2026-08-13,
|
||||
/// per-connection registration).
|
||||
///
|
||||
/// The assembly layer (an `alk/channels` acceptor's
|
||||
@@ -76,14 +76,14 @@ pub const TTY_ALPN: &str = "alk/tty";
|
||||
/// `channels/tty/sub` with the `channel_open` marker set
|
||||
/// (`ChannelOpenSpec::new("alk/tty")`), an `AccessControl` carrying
|
||||
/// the `tty:open` scope gate, and the partial `NegotiateRequest`
|
||||
/// input schema (`carriage`/`backend`/`cmd` required — alkcall 0.4
|
||||
/// input schema (`carriage`/`backend`/`cmd` required, since alkcall 0.4
|
||||
/// enforces it at dispatch; the full parse and semantic validation
|
||||
/// runs in the handler). It then wraps the
|
||||
/// `TtyOpenHandler` factory and calls
|
||||
/// [`ChannelCore::register_openable`].
|
||||
///
|
||||
/// `backends` is the same backend map the direct-ALPN `TtyAdapter`
|
||||
/// holds — `HashMap<String, Arc<dyn TtyBackend>>` keyed by the
|
||||
/// holds: `HashMap<String, Arc<dyn TtyBackend>>` keyed by the
|
||||
/// negotiation frame's `backend` string. The assembly layer typically
|
||||
/// shares one backend map between the direct-ALPN adapter and the
|
||||
/// channels `register_openable` registration.
|
||||
@@ -92,7 +92,7 @@ pub const TTY_ALPN: &str = "alk/tty";
|
||||
/// resource-ownership check. `None` = scope-gate only. The provider
|
||||
/// is consulted inside `drive_session_pre_negotiated` (after the
|
||||
/// backend is selected and `resource_id` is extracted from
|
||||
/// `backend_params`), not by the channels wrapper — the wrapper's
|
||||
/// `backend_params`), not by the channels wrapper; the wrapper's
|
||||
/// `AccessControl` carries only the scope gate. This mirrors the
|
||||
/// direct-ALPN `TtyAdapter` shape: the scope gate is enforced once at
|
||||
/// the entry point (here by the registry's `AccessControl`), the
|
||||
@@ -101,14 +101,14 @@ pub const TTY_ALPN: &str = "alk/tty";
|
||||
/// `auth` is the peer's `AuthContext`, the install-time context passed
|
||||
/// through to `register_openable_with_establisher`. The establisher
|
||||
/// and pump handler read their identity from the per-call context
|
||||
/// alkcall derives (alkcall 0.7, CF-006 corollary) — the
|
||||
/// alkcall derives (alkcall 0.7, CF-006 corollary): the
|
||||
/// dispatch-resolved opener, the same view the registry's ACL gate
|
||||
/// checked.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if `ChannelCore::register_openable` rejects the
|
||||
/// spec (e.g., the spec has no `channel_open` marker — won't happen
|
||||
/// spec (e.g., the spec has no `channel_open` marker; won't happen
|
||||
/// with the spec this helper builds, but the underlying call is
|
||||
/// fallible).
|
||||
pub fn register_openable(
|
||||
@@ -133,7 +133,7 @@ pub fn register_openable(
|
||||
|
||||
/// Build the [`OperationSpec`] for `channels/tty/sub`.
|
||||
///
|
||||
/// The op is `Sub`-typed (consumer-opens — the initiator opens a
|
||||
/// The op is `Sub`-typed (consumer-opens: the initiator opens a
|
||||
/// channel and the data plane flows on the channel's `BiStream`, not
|
||||
/// in the call response stream). The `channel_open` marker is set to
|
||||
/// `ChannelOpenSpec::new("alk/tty")` so the channels wrapper allocates
|
||||
@@ -141,26 +141,26 @@ pub fn register_openable(
|
||||
/// gate (the same scope the direct-ALPN `TtyAdapter` checks).
|
||||
///
|
||||
/// The input schema is the shared part of the `NegotiateRequest` shape
|
||||
/// (`carriage`/`backend`/`cmd` required — alkcall 0.4 enforces it at
|
||||
/// dispatch). The schema is deliberately partial: `tty`, `cwd`, `env`,
|
||||
/// (`carriage`/`backend`/`cmd` required, enforced at dispatch by
|
||||
/// alkcall 0.4). The schema is deliberately partial: `tty`, `cwd`, `env`,
|
||||
/// and backend-specific selector fields are free-form (`additionalProperties`
|
||||
/// defaults to `true` in raw JSON Schema), because the
|
||||
/// `NegotiateRequest` wire shape is owned by `negotiation.rs` and the
|
||||
/// backend params are opaque (ADR-053) — duplicating their full schemas
|
||||
/// backend params are opaque (ADR-053); duplicating their full schemas
|
||||
/// here would create a second definition to drift. The registry check
|
||||
/// catches structurally-broken opens (missing command, wrong types);
|
||||
/// the full parse and semantic validation (`carriage == "raw"`,
|
||||
/// backend lookup, ownership) and backend allocation run in the
|
||||
/// establisher (ADR-010, as amended for alkcall 0.6 — failures are
|
||||
/// establisher (ADR-010, as amended for alkcall 0.6: failures are
|
||||
/// `channel:open_failed`, including `allocate_failed` as
|
||||
/// `dial_failed`).
|
||||
///
|
||||
/// The op declares an [`ErrorDefinition`] for `channel:open_failed`
|
||||
/// (ADR-016 — establishment failures are disclosed so `services/schema`
|
||||
/// (ADR-016: establishment failures are disclosed so `services/schema`
|
||||
/// shows the failure contract; reason ∈ `unknown_resource` /
|
||||
/// `handler_error` / `timeout`, per the ADR-049 vocabulary — see
|
||||
/// `make_tty_establisher` for the mapping) and carries a
|
||||
/// `description` (review 006 E-02 — discovery enrichment).
|
||||
/// `handler_error` / `timeout`, per the ADR-049 vocabulary, with the
|
||||
/// mapping in `make_tty_establisher`) and carries a
|
||||
/// `description` (review 006 E-02, discovery enrichment).
|
||||
pub fn tty_open_spec() -> OperationSpec {
|
||||
let open_failed_schema = json!({
|
||||
"type": "object",
|
||||
@@ -222,7 +222,7 @@ pub fn tty_open_spec() -> OperationSpec {
|
||||
.with_channel_open(ChannelOpenSpec::new(TTY_ALPN))
|
||||
}
|
||||
|
||||
/// Build the [`OpenEstablisher`] for `channels/tty/sub` — the awaited
|
||||
/// Build the [`OpenEstablisher`] for `channels/tty/sub`: the awaited
|
||||
/// establishment phase (alkcall ADR-049 §1, alktty ADR-010).
|
||||
///
|
||||
/// The establisher runs the semantic validation that used to be
|
||||
@@ -230,45 +230,45 @@ pub fn tty_open_spec() -> OperationSpec {
|
||||
/// full `NegotiateRequest` parse of the registry-schema-validated
|
||||
/// `input` (the schema is deliberately partial so the opaque ADR-053
|
||||
/// backend params pass through), `carriage == "raw"`, non-empty
|
||||
/// `cmd`, the backend lookup, the ADR-050 ownership check, and —
|
||||
/// `cmd`, the backend lookup, the ADR-050 ownership check, and,
|
||||
/// since alkcall 0.6 (ADR-049 amendment 2, review 007 R-01) gave
|
||||
/// `Establishment` its plan payload — the backend allocation
|
||||
/// `Establishment` its plan payload, the backend allocation
|
||||
/// (ADR-010 as amended: the allocated `TtyHandle` crosses to the pump
|
||||
/// handler via the plan). Any rejection resolves
|
||||
/// `Err(EstablishmentError)` and the wrapper replies
|
||||
/// `channel:open_failed` with `details.reason` — the SSH contract:
|
||||
/// `channel:open_failed` with `details.reason` (the SSH contract:
|
||||
/// the channel never exists opener-side, no `channel_id` is ever
|
||||
/// returned.
|
||||
/// returned).
|
||||
///
|
||||
/// The `tty:open` scope gate is NOT checked here — the registry's
|
||||
/// The `tty:open` scope gate is NOT checked here; the registry's
|
||||
/// `AccessControl` enforced it before the establisher ran. The
|
||||
/// establisher's `auth` is the per-call context (alkcall 0.7, CF-006
|
||||
/// corollary): its identity is the dispatch-resolved opener — the same
|
||||
/// identity view the registry's ACL gate checked — and is the
|
||||
/// corollary): its identity is the dispatch-resolved opener (the same
|
||||
/// identity view the registry's ACL gate checked) and is the
|
||||
/// ownership-check subject (the same split as the old handler-side
|
||||
/// check).
|
||||
///
|
||||
/// Mapping into the ADR-049 reason vocabulary (no new codes — the
|
||||
/// Mapping into the ADR-049 reason vocabulary (no new codes; the
|
||||
/// vocabulary is alkcall's one-way wire surface):
|
||||
///
|
||||
/// - `NegotiateRequest` parse failure, `carriage != "raw"`, empty
|
||||
/// `cmd` → [`EstablishmentError::HandlerError`] (`malformed_
|
||||
/// negotiation` semantics — the request shape is wrong).
|
||||
/// negotiation` semantics: the request shape is wrong).
|
||||
/// - Unknown backend → [`EstablishmentError::UnknownResource`].
|
||||
/// - Ownership denial → [`EstablishmentError::HandlerError`] (an ACL
|
||||
/// outcome, not a dial outcome; the registry's scope gate stays the
|
||||
/// pre-allocation `FORBIDDEN` path).
|
||||
/// - `backend.allocate` failure (ADR-005's `AllocFailed`: PTY
|
||||
/// couldn't be allocated, docker exec failed to start, SSH channel
|
||||
/// request rejected) → [`EstablishmentError::DialFailed`] — the
|
||||
/// request rejected) → [`EstablishmentError::DialFailed`] (the
|
||||
/// target refused or the backend lacked capacity; the review-007
|
||||
/// verification gate's "allocate in the establisher →
|
||||
/// `allocate_failed` is a call error, no in-band frame" shape.
|
||||
///
|
||||
/// The allocation is handed to the pump handler through the
|
||||
/// establishment plan: a private `AllocatedHandle` slot (the
|
||||
/// `TtyHandle` itself is not `Sync` — its fields are boxed dyn
|
||||
/// streams — so it cannot be the `ChannelPlan` directly). The slot is
|
||||
/// `TtyHandle` itself is not `Sync`: its fields are boxed dyn
|
||||
/// streams, so it cannot be the `ChannelPlan` directly). The slot is
|
||||
/// created per establisher invocation (per open), so the
|
||||
/// shared-slot same-resource race alkcall's R-01 test pins is
|
||||
/// unreachable by construction; the wrapper moves the plan to this
|
||||
@@ -361,23 +361,23 @@ impl AllocatedHandle {
|
||||
|
||||
/// Build the [`OpenHandler`] for `channels/tty/sub`.
|
||||
///
|
||||
/// The handler receives the open op's `input` — the `NegotiateRequest`
|
||||
/// The handler receives the open op's `input`: the `NegotiateRequest`
|
||||
/// params, validated (and allocated against) by the establisher before
|
||||
/// the handler was spawned (alkcall ADR-049 §1: the establisher is
|
||||
/// awaited bounded, before the reply and before the pump handler) —
|
||||
/// awaited bounded, before the reply and before the pump handler),
|
||||
/// plus the establisher's `plan`: the [`AllocatedHandle`] slot
|
||||
/// carrying the backend-allocated `TtyHandle` (alkcall 0.6 ADR-049
|
||||
/// amendment 2, review 007 R-01). It re-parses `input`, calls
|
||||
/// `accept_bi()` to get the channel's [`BiStream`], splits it into
|
||||
/// read/write halves (the stdlib `tokio::io::split` idiom — the same
|
||||
/// read/write halves (the stdlib `tokio::io::split` idiom, the same
|
||||
/// split the direct-ALPN `TtyAdapter::handle` does), and runs the
|
||||
/// pre-allocated session driver on them — the same three-pump session
|
||||
/// pre-allocated session driver on them: the same three-pump session
|
||||
/// driver as the direct-ALPN path.
|
||||
///
|
||||
/// With a plan the handler never allocates: the establisher already
|
||||
/// ran `backend.allocate` (ADR-010 as amended), so no negotiation
|
||||
/// error frame is reachable on the registered path. With `plan: None`
|
||||
/// (a no-establisher registration — defense-in-depth, the ADR-010
|
||||
/// (a no-establisher registration: defense-in-depth, the ADR-010
|
||||
/// pre-0.3.0 shape) the handler re-runs the full inline
|
||||
/// validate-and-allocate, whose negotiation error frames
|
||||
/// (`malformed_negotiation`, `allocate_failed`, ...) remain the only
|
||||
@@ -385,11 +385,11 @@ impl AllocatedHandle {
|
||||
///
|
||||
/// The returned task awaits the session driver inline (R-02 lifetime
|
||||
/// contract): the `JoinHandle`'s completion is the wrapper's teardown
|
||||
/// trigger, so the pumps must finish before the task returns — never
|
||||
/// trigger, so the pumps must finish before the task returns; never
|
||||
/// spawn-and-forget.
|
||||
///
|
||||
/// The `tty:open` scope gate is enforced by the registry's `AccessControl`
|
||||
/// before this handler runs — the handler does not re-check it.
|
||||
/// before this handler runs; the handler does not re-check it.
|
||||
fn make_tty_open_handler(backends: Arc<HashMap<String, Arc<dyn TtyBackend>>>) -> OpenHandler {
|
||||
Arc::new(
|
||||
move |input: Value,
|
||||
@@ -474,7 +474,7 @@ mod tests {
|
||||
/// feed `make_tty_open_handler` a hand-built `input` the (partial)
|
||||
/// schema would accept but the full `NegotiateRequest` parse rejects
|
||||
/// (`cwd` typed as a number). Post-ADR-010 the establisher rejects
|
||||
/// this class before the handler ever spawns — this test pins the
|
||||
/// this class before the handler ever spawns; this test pins the
|
||||
/// handler's own parse arm, which must still write a
|
||||
/// `0x00`-prefixed `malformed_negotiation` error frame rather than
|
||||
/// silently EOFing if that input ever reaches it (e.g. a
|
||||
@@ -718,10 +718,10 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Allocation runs in the establisher (alkcall 0.6 — the
|
||||
/// `TtyHandle` crosses to the pump handler via the `Establishment`
|
||||
/// Allocation runs in the establisher (alkcall 0.6, with the
|
||||
/// `TtyHandle` crossing to the pump handler via the `Establishment`
|
||||
/// plan payload, ADR-010 as amended). Its failure maps to
|
||||
/// `DialFailed` (`details.reason == "dial_failed"` — the target
|
||||
/// `DialFailed` (`details.reason == "dial_failed"`, the target
|
||||
/// refused or the backend lacked capacity), and a successful
|
||||
/// allocation lands in the plan slot the handler takes.
|
||||
#[tokio::test]
|
||||
@@ -776,7 +776,7 @@ mod tests {
|
||||
|
||||
/// E-02: the spec carries a description (disclosed by
|
||||
/// `services/list`) and declares the `channel:open_failed` error
|
||||
/// schema (ADR-016 — the establishment-failure contract is
|
||||
/// schema (ADR-016: the establishment-failure contract is
|
||||
/// discoverable via `services/schema`).
|
||||
#[test]
|
||||
fn tty_open_spec_declares_description_and_open_failed_error() {
|
||||
@@ -795,7 +795,7 @@ mod tests {
|
||||
.expect("reason enum");
|
||||
let reasons: Vec<&str> = enum_values.iter().filter_map(|v| v.as_str()).collect();
|
||||
// The establisher produces these four (resource_shortage has
|
||||
// no TTY producer path — ADR-010; allocation failure maps to
|
||||
// no TTY producer path, ADR-010; allocation failure maps to
|
||||
// dial_failed, the amended mapping).
|
||||
assert_eq!(
|
||||
reasons,
|
||||
@@ -954,7 +954,7 @@ mod tests {
|
||||
|
||||
/// Sanity that the `MockBackend` we use in the channels tests
|
||||
/// compiles as a `TtyBackend` and resolves to the configured exit
|
||||
/// code. Belt-and-suspenders — the backend module already tests
|
||||
/// code. Belt-and-suspenders: the backend module already tests
|
||||
/// this, but the channels tests depend on it.
|
||||
#[tokio::test]
|
||||
async fn mock_backend_resolves_configured_exit_code() {
|
||||
|
||||
+6
-6
@@ -7,7 +7,7 @@
|
||||
//! server→client control (`Exit`). The previous single
|
||||
//! `STREAM_CONTROL = 3` was documented as "bidirectional" but the adapter
|
||||
//! ignored `Exit` from the client because it had no way to distinguish
|
||||
//! the two directions on the same stream_type — see the Phase 7 notes
|
||||
//! the two directions on the same stream_type (see the Phase 7 notes
|
||||
//! in `docs/architecture/tty-wire.md` (the bidirectionality fix that
|
||||
//! split the single `STREAM_CONTROL = 3` into `STREAM_CTRL_IN` (3)
|
||||
//! and `STREAM_CTRL_OUT` (4)).
|
||||
@@ -30,7 +30,7 @@
|
||||
//!
|
||||
//! Unknown `type` values: `from_slice` returns a `serde_json::Error`. The
|
||||
//! adapter (task `tty/adapter`) ignores that error per the wire spec's
|
||||
//! "unknown types are ignored" policy — this keeps the enum exhaustive and
|
||||
//! "unknown types are ignored" policy; this keeps the enum exhaustive and
|
||||
//! the policy an adapter-level concern, not a schema-level leak.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -51,7 +51,7 @@ use serde::{Deserialize, Serialize};
|
||||
/// arriving on `STREAM_CTRL_OUT` is a protocol violation (the adapter
|
||||
/// ignores it), and an `Exit` arriving on `STREAM_CTRL_IN` is likewise a
|
||||
/// protocol violation (the adapter ignores it). The split is what makes
|
||||
/// the control channel genuinely bidirectional — the previous single
|
||||
/// the control channel genuinely bidirectional: the previous single
|
||||
/// `STREAM_CONTROL = 3` was documented as "bidirectional" but the
|
||||
/// adapter had to ignore `Exit` from the client because the two
|
||||
/// directions were indistinguishable on the same stream_type.
|
||||
@@ -87,10 +87,10 @@ pub enum ControlMessage {
|
||||
/// chunk is the last control chunk before stream close (ADR-055).
|
||||
/// `code` is `i32`: `ExitStatus::code()` on normal exit; on Unix,
|
||||
/// the negative signal number on signal-terminated exit (e.g.,
|
||||
/// `-9` for SIGKILL — the crate's convention, not
|
||||
/// `-9` for SIGKILL; the crate's convention, not
|
||||
/// `ExitStatus::code()`'s, which returns `None` there). `-1` is
|
||||
/// the adapter's best-effort "backend could not determine the
|
||||
/// exit code" sentinel (ADR-055 §4) — colliding with the SIGHUP
|
||||
/// exit code" sentinel (ADR-055 §4), colliding with the SIGHUP
|
||||
/// encoding on the Unix pipe path.
|
||||
Exit { code: i32 },
|
||||
}
|
||||
@@ -117,7 +117,7 @@ impl ControlMessage {
|
||||
/// (Ctrl-C → `INT`, Ctrl-\ → `QUIT`, Ctrl-Z → `TSTP`). Unknown names
|
||||
/// return `None`; the caller (the local backend) decides whether to
|
||||
/// ignore or fall back to the backend's default kill
|
||||
/// (`portable_pty`'s `ChildKiller::kill` sends SIGHUP — see
|
||||
/// (`portable_pty`'s `ChildKiller::kill` sends SIGHUP; see
|
||||
/// tty-local.md REQ-TTY-02).
|
||||
///
|
||||
/// Unix-only: the non-Unix path falls back to `ChildKiller::kill`
|
||||
|
||||
+5
-5
@@ -3,18 +3,18 @@
|
||||
//! Producer/consumer protocol crate on top of alkcall channels. Two
|
||||
//! halves (per alkcall's protocol-crate pattern):
|
||||
//!
|
||||
//! - **Producer half** — [`adapter::TtyAdapter`] (direct `alk/tty`
|
||||
//! - **Producer half**: [`adapter::TtyAdapter`] (direct `alk/tty`
|
||||
//! ALPN via `ProtocolHandler`) + [`channels`] (registers the
|
||||
//! `channels/tty/sub` open op via `ChannelCore::register_openable`
|
||||
//! for the `alk/channels` multiplexed path).
|
||||
//! - **Consumer half** — [`session::TtySession`] (typed client wrapper
|
||||
//! - **Consumer half**: [`session::TtySession`] (typed client wrapper
|
||||
//! around the wire protocol, with `connect_direct` and
|
||||
//! `open_via_channels` constructors).
|
||||
//!
|
||||
//! Two-carriage wire format (ADR-052): a JSON negotiation frame, then
|
||||
//! raw chunks (`[stream_type: u8][length: u32 be][payload]`).
|
||||
//! Backend-agnostic via the [`backend::TtyBackend`] trait (ADR-053).
|
||||
//! Depends on alkcall (ADR-057 — the negotiation framing is
|
||||
//! Depends on alkcall (ADR-057: the negotiation framing is
|
||||
//! self-contained; alkcall's `EventEnvelope` framing is not reused).
|
||||
//!
|
||||
//! # WASM target
|
||||
@@ -48,7 +48,7 @@
|
||||
//!
|
||||
//! # Crate-root surface
|
||||
//!
|
||||
//! The primary types are re-exported at the crate root —
|
||||
//! The primary types are re-exported at the crate root:
|
||||
//! [`TtyAdapter`]/[`drive_session`] (producer), [`TtySession`]
|
||||
//! (consumer), [`TtyBackend`]/[`TtyHandle`]/[`TtyParams`] (backend
|
||||
//! authors), plus the wire codec ([`Chunk`], [`ChunkReader`],
|
||||
@@ -56,7 +56,7 @@
|
||||
//! [`MAX_CHUNK_LEN`], ...), [`ControlMessage`], the negotiation types
|
||||
//! [`NegotiateRequest`], ...), the channels registration helpers
|
||||
//! ([`register_openable`]), and `local::LocalTtyBackend` under the
|
||||
//! `local` feature. The module paths below remain the full public surface —
|
||||
//! `local` feature. The module paths below remain the full public surface;
|
||||
//! the root re-exports are the ergonomic short paths for the common
|
||||
//! imports.
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ use async_trait::async_trait;
|
||||
/// (the runner case, this crate's `pipe` module). One backend serves both
|
||||
/// (ADR-054).
|
||||
///
|
||||
/// Takes no constructor dependencies — unlike `DockerTtyBackend` (wraps a
|
||||
/// Takes no constructor dependencies: unlike `DockerTtyBackend` (wraps a
|
||||
/// `bollard::Docker` client) or `SshTtyBackend` (wraps an SSH session), the
|
||||
/// `portable_pty` system is process-global. The assembly layer constructs one
|
||||
/// `LocalTtyBackend` and registers it as `"local"`.
|
||||
@@ -43,9 +43,9 @@ impl TtyBackend for LocalTtyBackend {
|
||||
/// `params.terminal`.
|
||||
///
|
||||
/// `Some(TerminalParams)` dispatches to [`crate::local::pty::allocate_pty`]
|
||||
/// (PTY mode, real terminal semantics — resize, process-group signal
|
||||
/// (PTY mode, real terminal semantics: resize, process-group signal
|
||||
/// forwarding, merged stdout/stderr). `None` dispatches to
|
||||
/// [`crate::local::pipe::allocate_pipe`] (pipe mode, the runner case —
|
||||
/// [`crate::local::pipe::allocate_pipe`] (pipe mode, the runner case:
|
||||
/// separate stdout/stderr, no-op resize, pid-only signal).
|
||||
///
|
||||
/// `backend_params` is ignored: the local backend has no
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
//! non-wasm by design (`portable_pty` + `tokio::process` need a real
|
||||
//! OS). The default crate (no features) compiles to
|
||||
//! `wasm32-unknown-unknown`; enabling `local` on a wasm target is a
|
||||
//! build error by design — the local-process backend runs on a real
|
||||
//! build error by design: the local-process backend runs on a real
|
||||
//! OS (Linux, macOS, Windows), never in a sandbox.
|
||||
|
||||
pub mod backend;
|
||||
|
||||
+11
-11
@@ -122,13 +122,13 @@ where
|
||||
}
|
||||
|
||||
/// Control handle for pipe mode: no-op resize and `libc::kill(pid, sig)`
|
||||
/// signal forwarding (the runner case — ADR-054).
|
||||
/// signal forwarding (the runner case; ADR-054).
|
||||
///
|
||||
/// # Signal scope — no process group (documented limitation)
|
||||
/// # Signal scope: no process group (documented limitation)
|
||||
///
|
||||
/// Pipe mode has no session leader / controlling tty, so `signal()` targets
|
||||
/// the direct child pid only via `libc::kill(pid, sig)`. If the child has
|
||||
/// spawned its own children, they will NOT receive the signal — this is a
|
||||
/// spawned its own children, they will NOT 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. See
|
||||
/// `tty-local.md` §"Pipe Mode".
|
||||
@@ -149,11 +149,11 @@ impl PipeControl {
|
||||
|
||||
impl TtyControl for PipeControl {
|
||||
fn resize(&self, _cols: u16, _rows: u16, _pixel_width: u16, _pixel_height: u16) {
|
||||
// No-op — no PTY in pipe mode.
|
||||
// No-op: no PTY in pipe mode.
|
||||
}
|
||||
|
||||
fn signal(&self, name: &str) {
|
||||
// Targets the direct child pid only (no process group — see the
|
||||
// Targets the direct child pid only (no process group; see the
|
||||
// type-level doc comment on `PipeControl`).
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@@ -197,13 +197,13 @@ impl TtyControl for PipeControl {
|
||||
/// The `exit_code` future for pipe mode: wraps `tokio::process::Child::wait`
|
||||
/// plus the ADR-056 kill guard.
|
||||
///
|
||||
/// # ADR-056 — kill-on-Drop contract
|
||||
/// # ADR-056: kill-on-Drop contract
|
||||
///
|
||||
/// On cancel (the future is dropped without being driven to completion),
|
||||
/// `Drop` calls `Child::start_kill()` (SIGKILL); the child exits and the
|
||||
/// inner `wait` future reaps it. On the happy path, `poll` resolves the
|
||||
/// `wait` future, then **disarms** the guard by taking the `Child` out of
|
||||
/// `self.child` — so the subsequent `Drop` is a no-op. The guard is the
|
||||
/// `self.child`, so the subsequent `Drop` is a no-op. The guard is the
|
||||
/// spec-compliant mechanism; `kill_on_drop(true)` on the `Command` is a
|
||||
/// defense-in-depth backstop, not the primary path (the `Child` is moved
|
||||
/// into the future and the future's `Drop` is the cancel path, not the
|
||||
@@ -283,10 +283,10 @@ impl Drop for PipeExitFuture {
|
||||
|
||||
/// Map an `ExitStatus` to the adapter's `i32` exit code: `code()` on normal
|
||||
/// exit; on Unix, the negative signal number on signal-terminated exit
|
||||
/// (ADR-055 §4's convention — `ExitStatus::code()` returns `None` for
|
||||
/// (ADR-055 §4's convention: `ExitStatus::code()` returns `None` for
|
||||
/// signal termination, so this crate's encoding is via
|
||||
/// `ExitStatusExt::signal()`). Falls back to `-1` (the adapter's
|
||||
/// "could not determine" sentinel — ADR-055 §4).
|
||||
/// "could not determine" sentinel; ADR-055 §4).
|
||||
fn exit_code_from(status: ExitStatus) -> i32 {
|
||||
if let Some(code) = status.code() {
|
||||
return code;
|
||||
@@ -385,7 +385,7 @@ mod tests {
|
||||
|
||||
/// Readiness signal (review #001 N4): the child writes `ready` to
|
||||
/// the marker file right before `exec sleep`, so the test knows
|
||||
/// the exec'd process exists — no fixed pre-signal sleep.
|
||||
/// the exec'd process exists; no fixed pre-signal sleep.
|
||||
async fn wait_marker(marker: &std::path::Path) {
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
loop {
|
||||
@@ -453,7 +453,7 @@ mod tests {
|
||||
let pid_str = std::fs::read_to_string(&pid_file).expect("pid file written");
|
||||
let pid: i32 = pid_str.trim().parse().expect("pid parses");
|
||||
let _ = std::fs::remove_file(&pid_file);
|
||||
// Drop the handle without awaiting exit_code — the ADR-056 guard
|
||||
// Drop the handle without awaiting exit_code; the ADR-056 guard
|
||||
// must kill the child.
|
||||
drop(handle);
|
||||
// Poll for the child's death (pid_file written before exec; the
|
||||
|
||||
+29
-29
@@ -6,7 +6,7 @@
|
||||
//! forwarding (`libc::kill(-pgid, sig)`), and the ADR-056 kill guard on the
|
||||
//! `exit_code` future.
|
||||
//!
|
||||
//! # REQ-TTY-01 — blocking→async bridge
|
||||
//! # REQ-TTY-01: blocking→async bridge
|
||||
//!
|
||||
//! `portable_pty` exposes a blocking `std::io` API
|
||||
//! (`MasterPty::try_clone_reader()`, `take_writer()`, `Child::wait()`).
|
||||
@@ -14,7 +14,7 @@
|
||||
//! backend: dedicated std threads feed tokio mpsc/oneshot channels, and
|
||||
//! the async-facing `TtyHandle` fields are wrappers over those channels.
|
||||
//!
|
||||
//! # REQ-TTY-02 — signal targets the process group
|
||||
//! # REQ-TTY-02: signal targets the process group
|
||||
//!
|
||||
//! `portable_pty` spawns the child as a session leader
|
||||
//! (`CommandBuilder::set_controlling_tty(true)`, the default), so the
|
||||
@@ -24,12 +24,12 @@
|
||||
//! child already exited). Unknown signal names fall back to
|
||||
//! `ChildKiller::kill` (SIGHUP). See `tty-local.md` §"REQ-TTY-02".
|
||||
//!
|
||||
//! # ADR-056 — kill-on-Drop guard
|
||||
//! # ADR-056: kill-on-Drop guard
|
||||
//!
|
||||
//! `LocalExitFuture` wraps the `oneshot::Receiver<i32>` from the waiter
|
||||
//! thread alongside a `portable_pty::ChildKiller`. On cancel (the future
|
||||
//! is dropped without being driven to completion), `Drop` calls
|
||||
//! `ChildKiller::kill()` (SIGHUP) — best-effort, the child may already be
|
||||
//! `ChildKiller::kill()` (SIGHUP); best-effort, the child may already be
|
||||
//! exiting. On the happy path (the future resolves), `poll` disarms the
|
||||
//! guard (`Option::take()`), so the subsequent `Drop` is a no-op. The
|
||||
//! waiter thread reaps the killed child via its blocking wait (Unix:
|
||||
@@ -43,7 +43,7 @@
|
||||
//! number (ADR-004 decision 1): `signal("TERM")` resolves to `-15`, the
|
||||
//! ADR-056 cancel kill (SIGHUP) to `-1`. portable-pty 0.9.0's `ExitStatus`
|
||||
//! would flatten the same death to code `1` with the signal only as a
|
||||
//! strsignal name string — indistinguishable from a real `exit 1`. The pipe
|
||||
//! strsignal name string, indistinguishable from a real `exit 1`. The pipe
|
||||
//! path encodes via `std::os::unix::process::ExitStatusExt::signal()`
|
||||
//! (`pipe.rs::exit_code_from`); both backends now agree on the convention.
|
||||
//! See `wait_exit_code` and review #004.
|
||||
@@ -53,19 +53,19 @@
|
||||
//! The three-thread bridge's error arms are documented-unreachable
|
||||
//! through the public path, per the review's disposition:
|
||||
//!
|
||||
//! - **reader `try_clone_reader` failure** — the master fd is open and
|
||||
//! - **reader `try_clone_reader` failure**: the master fd is open and
|
||||
//! owned by this allocation; `dup` failure requires an exhausted
|
||||
//! fd table, which no test can force deterministically.
|
||||
//! - **reader read error** — the `PtyFd` `Read` impl maps `EIO`
|
||||
//! - **reader read error**: the `PtyFd` `Read` impl maps `EIO`
|
||||
//! (slave closed) to EOF, the only realistic master read error; any
|
||||
//! other error kind has no trigger from the public path.
|
||||
//! - **writer `take_writer` failure** — portable-pty 0.9 fails this
|
||||
//! - **writer `take_writer` failure**: portable-pty 0.9 fails this
|
||||
//! only on the *second* take (`took_writer` flag); the bridge takes
|
||||
//! it exactly once.
|
||||
//! - **writer write/flush failure** — the master fd is open and the
|
||||
//! - **writer write/flush failure**: the master fd is open and the
|
||||
//! writer thread is the sole writer; a write error requires an
|
||||
//! externally-closed fd the bridge never observes.
|
||||
//! - **waiter `wait()` failure** — reached only on the waitpid-failure
|
||||
//! - **waiter `wait()` failure**, reached only on the waitpid-failure
|
||||
//! fallback (see `wait_exit_code`); `std::process::Child::wait` fails
|
||||
//! only if the child was already reaped (the bridge normally reaps via
|
||||
//! `libc::waitpid` and never calls `try_wait`). The ADR-055 §4
|
||||
@@ -136,7 +136,7 @@ impl PtyControl {
|
||||
}
|
||||
|
||||
impl TtyControl for PtyControl {
|
||||
/// Resize the PTY. Safe to call from the async pump —
|
||||
/// Resize the PTY. Safe to call from the async pump, since
|
||||
/// `MasterPty::resize` is non-blocking (it issues an `ioctl`).
|
||||
fn resize(&self, cols: u16, rows: u16, pixel_width: u16, pixel_height: u16) {
|
||||
let size = PtySize {
|
||||
@@ -155,7 +155,7 @@ impl TtyControl for PtyControl {
|
||||
///
|
||||
/// On Unix: maps the name to a `libc` signal number via
|
||||
/// `crate::control::signal_from_name`, then calls `kill(-pgid, sig)` (the
|
||||
/// negative pid reaches the whole process group — the child is a
|
||||
/// negative pid reaches the whole process group; the child is a
|
||||
/// session leader because `set_controlling_tty(true)`, the default).
|
||||
/// Falls back to `kill(pid, sig)` if the group signal fails (e.g. the
|
||||
/// child already exited). Unknown names fall back to
|
||||
@@ -207,9 +207,9 @@ impl TtyControl for PtyControl {
|
||||
/// `portable_pty::ChildKiller`.
|
||||
///
|
||||
/// `poll` delegates to the oneshot receiver (resolves on natural exit). On
|
||||
/// `Ready`, the killer is taken (`Option::take()`) — disarmed — so the
|
||||
/// `Ready`, the killer is taken (`Option::take()`, disarming it) so the
|
||||
/// subsequent `Drop` is a no-op. On cancel (the future is dropped before
|
||||
/// resolving), `Drop` calls `ChildKiller::kill()` (SIGHUP) — best-effort;
|
||||
/// resolving), `Drop` calls `ChildKiller::kill()` (SIGHUP), best-effort;
|
||||
/// the child may already be exiting. The waiter thread's blocking wait
|
||||
/// (Unix: `libc::waitpid`) reaps the killed child, so there is no zombie.
|
||||
/// The contract is "kill on cancel; no-op on resolve."
|
||||
@@ -234,7 +234,7 @@ impl Future for LocalExitFuture {
|
||||
match Pin::new(&mut self.rx).poll(cx) {
|
||||
Poll::Pending => Poll::Pending,
|
||||
Poll::Ready(Ok(code)) => {
|
||||
// Disarm the kill guard — the child exited naturally.
|
||||
// Disarm the kill guard: the child exited naturally.
|
||||
self.killer.take();
|
||||
Poll::Ready(Ok(code))
|
||||
}
|
||||
@@ -253,7 +253,7 @@ impl Future for LocalExitFuture {
|
||||
impl Drop for LocalExitFuture {
|
||||
fn drop(&mut self) {
|
||||
if let Some(killer) = self.killer.take() {
|
||||
// ADR-056: kill on cancel. Best-effort — the child may already
|
||||
// ADR-056: kill on cancel. Best-effort, since the child may already
|
||||
// be exiting. SIGHUP is what portable_pty sends on Unix.
|
||||
let mut killer = killer;
|
||||
if let Err(e) = killer.kill() {
|
||||
@@ -269,9 +269,9 @@ impl Drop for LocalExitFuture {
|
||||
///
|
||||
/// When the channel is full, `poll_write` parks in an in-flight send
|
||||
/// future stored on the struct (so a re-poll resumes the same send rather
|
||||
/// than starting a new one — `reserve()` is not `Unpin`).
|
||||
/// than starting a new one; `reserve()` is not `Unpin`).
|
||||
/// `poll_shutdown` parks the same way (a separate slot, so the EOF send
|
||||
/// cannot be confused with a byte send) — returning `Pending` without a
|
||||
/// cannot be confused with a byte send). Returning `Pending` without a
|
||||
/// registered waker would strand the EOF forever on a full channel
|
||||
/// (review #003 P8).
|
||||
struct StdinSink {
|
||||
@@ -410,19 +410,19 @@ impl tokio::io::AsyncWrite for StdinSink {
|
||||
|
||||
/// Wait for the child to terminate and resolve to the ADR-004 exit-status
|
||||
/// encoding: `ExitStatus::code()` on normal exit; on Unix, the negative
|
||||
/// signal number on signal-terminated exit (e.g. `-15` for SIGTERM —
|
||||
/// signal number on signal-terminated exit (e.g. `-15` for SIGTERM;
|
||||
/// Python's `Popen.returncode` parity).
|
||||
///
|
||||
/// On Unix the reap goes through `libc::waitpid(pid, ..)` on the direct
|
||||
/// child pid, not portable-pty's `Child::wait()`: portable-pty 0.9.0
|
||||
/// converts a signal-terminated child to `ExitStatus { code: 1, signal:
|
||||
/// Some(name) }` — the signal identity survives only as a strsignal name
|
||||
/// Some(name) }`; the signal identity survives only as a strsignal name
|
||||
/// string that cannot be mapped back numerically (it is locale-dependent),
|
||||
/// and `code = 1` collides with a real `exit 1`. The reaped child is a
|
||||
/// direct child of this process (portable-pty's unix backend spawns a
|
||||
/// `std::process::Child`, whose `Drop` neither kills nor reaps), and
|
||||
/// `Child::wait()` is reachable only on the waitpid-failure path — where
|
||||
/// the child was not reaped — so exactly one of the two paths reaps.
|
||||
/// `Child::wait()` is reachable only on the waitpid-failure path (where
|
||||
/// the child was not reaped), so exactly one of the two paths reaps.
|
||||
///
|
||||
/// Non-Unix has no Unix signal semantics; `Child::wait()` →
|
||||
/// `exit_code() as i32` matches the convention for normal exits. `-1` is
|
||||
@@ -478,7 +478,7 @@ fn wait_exit_code(pid: Option<i32>, child: &mut Box<dyn Child + Send + Sync>) ->
|
||||
/// `TtyHandle`.
|
||||
///
|
||||
/// Spawns the child as a session leader with a controlling tty
|
||||
/// (`CommandBuilder::set_controlling_tty(true)` — the default; REQ-TTY-02
|
||||
/// (`CommandBuilder::set_controlling_tty(true)`, the default; REQ-TTY-02
|
||||
/// depends on it). Wires the three-thread bridge (reader/writer/waiter),
|
||||
/// the `PtyControl`, and the ADR-056 `LocalExitFuture`.
|
||||
pub fn allocate_pty(
|
||||
@@ -516,7 +516,7 @@ pub fn allocate_pty(
|
||||
for (k, v) in env {
|
||||
builder.env(k, v);
|
||||
}
|
||||
// Session leader + controlling tty — the default. REQ-TTY-02:
|
||||
// Session leader + controlling tty (the default). REQ-TTY-02:
|
||||
// `kill(-pgid, sig)` reaches the whole group only when the child is a
|
||||
// session leader, which requires a controlling tty.
|
||||
builder.set_controlling_tty(true);
|
||||
@@ -684,7 +684,7 @@ mod tests {
|
||||
|
||||
/// Readiness signal (review #001 N4): the child writes `ready` to
|
||||
/// the marker file right before `exec sleep`, so the test knows
|
||||
/// the exec'd process exists — no fixed pre-signal sleep.
|
||||
/// the exec'd process exists (no fixed pre-signal sleep).
|
||||
async fn wait_marker(marker: &std::path::Path) {
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
loop {
|
||||
@@ -802,7 +802,7 @@ mod tests {
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn signal_reaches_process_group_child() {
|
||||
// bash -c 'echo ready > marker; sleep 60' — sleep is a child of
|
||||
// bash -c 'echo ready > marker; sleep 60', where sleep is a child of
|
||||
// bash. The group signal must reach sleep too (REQ-TTY-02). bash
|
||||
// exits when its child does.
|
||||
let marker = marker_path("pgroup_ready");
|
||||
@@ -854,7 +854,7 @@ mod tests {
|
||||
// ADR-056: dropping the TtyHandle (and thus the exit_code future)
|
||||
// without awaiting it MUST kill the child. The waiter thread reaps
|
||||
// the killed child (no zombie). We assert the kill happened by
|
||||
// spawning a second session that completes promptly — i.e., the
|
||||
// spawning a second session that completes promptly, i.e., the
|
||||
// dropped session's child does not outlive a short grace period
|
||||
// (if it did, the SIGHUP from Drop would not have fired).
|
||||
let handle = allocate_pty(
|
||||
@@ -900,7 +900,7 @@ mod tests {
|
||||
wait_marker(&marker).await;
|
||||
// "NOSUCH" is not a known signal name → falls back to
|
||||
// ChildKiller::kill (SIGHUP). The child should die by SIGHUP,
|
||||
// which resolves to `-1` — the documented ADR-004 sentinel
|
||||
// which resolves to `-1`, the documented ADR-004 sentinel
|
||||
// collision (SIGHUP = signal 1).
|
||||
control.signal("NOSUCH");
|
||||
let code = tokio::time::timeout(std::time::Duration::from_secs(5), handle.exit_code)
|
||||
@@ -919,7 +919,7 @@ mod tests {
|
||||
// The signal path's fallback chain (REQ-TTY-02): `kill(-pgid, sig)`
|
||||
// fails once the child (and its group) is gone, then the
|
||||
// `kill(pid, sig)` fallback fails too, and the call degrades to a
|
||||
// warn + return — no panic, no cascade. A late signal (the client
|
||||
// warn + return: no panic, no cascade. A late signal (the client
|
||||
// signals after the process already exited) must be safe.
|
||||
let handle = allocate_pty(
|
||||
term(),
|
||||
|
||||
+10
-10
@@ -8,8 +8,8 @@
|
||||
//!
|
||||
//! The framing is self-contained in alktty (ADR-057): a 4-byte
|
||||
//! big-endian length prefix + UTF-8 JSON body. The format coincides with
|
||||
//! alkcall's `EventEnvelope` framing by convention, not by code reuse
|
||||
//! — alktty does not depend on alkcall's internal wire types.
|
||||
//! alkcall's `EventEnvelope` framing by convention, not by code reuse:
|
||||
//! alktty does not depend on alkcall's internal wire types.
|
||||
//!
|
||||
//! # Framing disambiguation
|
||||
//!
|
||||
@@ -22,7 +22,7 @@
|
||||
//! because error frames MUST be under 16 MiB ([`MAX_CHUNK_LEN`]) so the
|
||||
//! high byte is zero (a wire-format invariant, not an assumption).
|
||||
//! - A raw chunk's first byte is a `stream_type`. The server never sends
|
||||
//! `0` (stdin — client→server only) or `3` (`STREAM_CTRL_IN` —
|
||||
//! `0` (stdin, client→server only) or `3` (`STREAM_CTRL_IN`,
|
||||
//! client→server only), so the server-sent set is `{1, 2, 4}`
|
||||
//! (stdout, stderr, `STREAM_CTRL_OUT`); `0x00` is unambiguous.
|
||||
//!
|
||||
@@ -68,7 +68,7 @@ pub struct NegotiateRequest {
|
||||
pub carriage: String,
|
||||
/// Backend selector key (`"local"`, `"docker"`, `"ssh"`).
|
||||
pub backend: String,
|
||||
/// `None` = pipe mode (no PTY — ADR-054). `Some` = allocate a PTY with
|
||||
/// `None` = pipe mode (no PTY; ADR-054). `Some` = allocate a PTY with
|
||||
/// these dimensions.
|
||||
#[serde(default)]
|
||||
pub tty: Option<TerminalParamsWire>,
|
||||
@@ -95,7 +95,7 @@ pub struct NegotiateRequest {
|
||||
///
|
||||
/// Maps to SSH's `pty_request` parameters, to docker's
|
||||
/// `CreateExecOptions { tty: true }`, and to `portable_pty::PtySystem::openpty`
|
||||
/// for the local backend. The `modes` field is reserved (OQ-44 — default
|
||||
/// for the local backend. The `modes` field is reserved (OQ-44: default
|
||||
/// terminal modes suffice for the current scope); backends MUST ignore its
|
||||
/// content in v1.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
@@ -114,7 +114,7 @@ pub struct TerminalParamsWire {
|
||||
/// Pixel height (most terminals don't report this; defaults to 0).
|
||||
#[serde(default)]
|
||||
pub pixel_height: u16,
|
||||
/// Reserved — OQ-44; backends MUST ignore the content in v1.
|
||||
/// Reserved (OQ-44); backends MUST ignore the content in v1.
|
||||
#[serde(default)]
|
||||
pub modes: serde_json::Value,
|
||||
}
|
||||
@@ -122,7 +122,7 @@ pub struct TerminalParamsWire {
|
||||
/// Errors from the negotiation framing reader/writer.
|
||||
///
|
||||
/// `ConnectionClosed` is returned (rather than `Io`) when `read_frame` hits
|
||||
/// a clean `UnexpectedEof` reading either the length prefix or the body —
|
||||
/// a clean `UnexpectedEof` reading either the length prefix or the body:
|
||||
/// the peer closed the stream cleanly rather than failing the transport.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum NegotiationError {
|
||||
@@ -217,7 +217,7 @@ impl<R: AsyncRead + Unpin> NegotiationReader<R> {
|
||||
/// prefix followed by the body, then flushes.
|
||||
///
|
||||
/// For server-side error responses, the body MUST be under 16 MiB
|
||||
/// ([`MAX_CHUNK_LEN`]) so the high byte of the length prefix is `0x00` —
|
||||
/// ([`MAX_CHUNK_LEN`]) so the high byte of the length prefix is `0x00`;
|
||||
/// this is the wire-format invariant that makes the framing-disambiguation
|
||||
/// trick sound (see ADR-052 §5). [`Self::write_frame`] does not enforce
|
||||
/// this; callers building error responses with [`error_response_bytes`] are
|
||||
@@ -251,13 +251,13 @@ impl<W: AsyncWrite + Unpin> NegotiationWriter<W> {
|
||||
|
||||
/// Serialize a negotiation error response to JSON bytes.
|
||||
///
|
||||
/// Produces `{"error":"<error>","<field>":"<value>",...}` — the
|
||||
/// Produces `{"error":"<error>","<field>":"<value>",...}`, the
|
||||
/// length-prefixed error frame the server sends when it cannot allocate
|
||||
/// the session (unknown backend, malformed negotiation, allocate failed).
|
||||
/// The caller writes the result via [`NegotiationWriter::write_frame`].
|
||||
///
|
||||
/// Error frames MUST be under 16 MiB ([`MAX_CHUNK_LEN`]) so the high byte
|
||||
/// of the 4-byte length prefix is `0x00` (framing disambiguation — ADR-052
|
||||
/// of the 4-byte length prefix is `0x00` (framing disambiguation; ADR-052
|
||||
/// §5). Realistic error responses are tens of bytes; this invariant holds
|
||||
/// by construction.
|
||||
pub fn error_response_bytes(error: &str, fields: &[(&str, &str)]) -> serde_json::Result<Vec<u8>> {
|
||||
|
||||
+53
-54
@@ -1,9 +1,9 @@
|
||||
//! Consumer half — `TtySession`, the typed client wrapper around the
|
||||
//! Consumer half: `TtySession`, the typed client wrapper around the
|
||||
//! `alk/tty` wire protocol (per alkcall's protocol-crate pattern).
|
||||
//!
|
||||
//! Two constructors:
|
||||
//!
|
||||
//! - [`TtySession::connect_direct`] — for a direct `alk/tty` ALPN
|
||||
//! - [`TtySession::connect_direct`]: for a direct `alk/tty` ALPN
|
||||
//! connection. The consumer dials the transport (TLS, QUIC,
|
||||
//! WebSocket), negotiates the `alk/tty` ALPN, and hands the
|
||||
//! `Connection` to `connect_direct`. The session takes ownership of
|
||||
@@ -11,35 +11,35 @@
|
||||
//! ADR-065), writes the negotiation frame, and exposes the typed
|
||||
//! methods.
|
||||
//!
|
||||
//! - [`TtySession::open_via_channels`] — for the `alk/channels`
|
||||
//! - [`TtySession::open_via_channels`]: for the `alk/channels`
|
||||
//! multiplexed path. The consumer holds a
|
||||
//! [`alkcall::channels::client::ChannelClient`], calls
|
||||
//! `open_via_channels(client, params)`, which invokes
|
||||
//! `channels/tty/sub` on channel 0, adopts the resulting channel,
|
||||
//! builds a `Connection` from the reassembled read half + mux write
|
||||
//! half, and runs the typed-methods flow directly in raw chunk mode
|
||||
//! (ADR-009: the open op's `params` — validated by the registry's
|
||||
//! input schema — *are* the negotiation; no second negotiation
|
||||
//! (ADR-009: the open op's `params`, validated by the registry's
|
||||
//! input schema, *are* the negotiation; no second negotiation
|
||||
//! frame is written on the channel's data stream). Semantic
|
||||
//! establishment failures — including allocation failure since
|
||||
//! alkcall 0.6 — are rejected by the producer's establisher and
|
||||
//! establishment failures (including allocation failure since
|
||||
//! alkcall 0.6) are rejected by the producer's establisher and
|
||||
//! surface as `channel:open_failed` call errors (alkcall ADR-049 /
|
||||
//! alktty ADR-010, as amended for the 0.6 plan payload; see
|
||||
//! `open_via_channels`' docs).
|
||||
//!
|
||||
//! The session handle exposes:
|
||||
//! - [`TtySession::send_stdin`] / [`TtySession::close_stdin`] — write
|
||||
//! - [`TtySession::send_stdin`] / [`TtySession::close_stdin`]: write
|
||||
//! stdin chunks, close stdin (zero-length sentinel).
|
||||
//! - [`TtySession::recv_stdout`] / [`TtySession::recv_stderr`] —
|
||||
//! - [`TtySession::recv_stdout`] / [`TtySession::recv_stderr`]:
|
||||
//! streams of stdout/stderr chunks (`Stream<Item = Bytes>`).
|
||||
//! - [`TtySession::resize`] — send a `Resize` control message.
|
||||
//! - [`TtySession::signal`] — send a `Signal` control message.
|
||||
//! - [`TtySession::wait`] — await the `Exit` control chunk (the
|
||||
//! - [`TtySession::resize`]: send a `Resize` control message.
|
||||
//! - [`TtySession::signal`]: send a `Signal` control message.
|
||||
//! - [`TtySession::wait`]: await the `Exit` control chunk (the
|
||||
//! process exit code).
|
||||
//!
|
||||
//! The session does NOT re-serialize the negotiation request itself
|
||||
//! — the caller passes a `NegotiateRequest` (direct path) or a
|
||||
//! `serde_json::Value` (channels path — the open op's `input` is the
|
||||
//! The session does NOT re-serialize the negotiation request itself:
|
||||
//! the caller passes a `NegotiateRequest` (direct path) or a
|
||||
//! `serde_json::Value` (channels path, where the open op's `input` is the
|
||||
//! negotiation). The direct path writes the frame and switches to
|
||||
//! raw-chunk mode; the channels path starts in raw-chunk mode. See
|
||||
//! ADR-052 for the two-carriage model.
|
||||
@@ -48,7 +48,7 @@
|
||||
//!
|
||||
//! `TtySession` is wasm-clean (no `tokio::process`, no `std::thread`,
|
||||
//! no `libc`). The consumer half is exactly the part a browser-side
|
||||
//! or Python-wasm adapter would use — it runs the wire protocol
|
||||
//! or Python-wasm adapter would use: it runs the wire protocol
|
||||
//! against a `Connection` the consumer dials (a WebSocket binary
|
||||
//! stream, a WebTransport bidi stream, etc.). The producer half
|
||||
//! (`TtyAdapter` + `register_openable`) runs on a real OS with a
|
||||
@@ -100,7 +100,7 @@ pub enum TtySessionError {
|
||||
/// `ChannelOpenError` verbatim (alkcall 0.5.0, ADR-049 §4): a
|
||||
/// `CallFailed` variant wraps the wire `CallError`, so a
|
||||
/// semantic-establishment failure (unknown backend, malformed
|
||||
/// negotiation, ownership denial — rejected by the producer's
|
||||
/// negotiation, ownership denial, all rejected by the producer's
|
||||
/// establisher) surfaces as `channel:open_failed` with
|
||||
/// `details.reason` branchable via
|
||||
/// `ChannelOpenError::establishment_reason()`; ACL denial and
|
||||
@@ -110,7 +110,7 @@ pub enum TtySessionError {
|
||||
#[error("channels open: {0}")]
|
||||
ChannelsOpen(#[from] alkcall::channels::client::ChannelOpenError),
|
||||
/// The channels open op's `params` failed the consumer's local
|
||||
/// `NegotiateRequest` parse — the fail-fast check in
|
||||
/// `NegotiateRequest` parse: the fail-fast check in
|
||||
/// [`TtySession::open_via_channels`] before a channel is allocated.
|
||||
/// (R5: the parse previously surfaced as `NegotiationSerialize`,
|
||||
/// a variant whose name and doc describe serializing the
|
||||
@@ -120,9 +120,9 @@ pub enum TtySessionError {
|
||||
/// The server sent a negotiation error frame (the first frame on
|
||||
/// the stream is a length-prefixed JSON `{"error":"..."}` rather
|
||||
/// than a raw chunk). On the channels path this is
|
||||
/// defense-in-depth only — a registered producer rejects every
|
||||
/// defense-in-depth only, because a registered producer rejects every
|
||||
/// failure class (including allocation, since alkcall 0.6) in the
|
||||
/// establisher as `channel:open_failed` before the reply — but
|
||||
/// establisher as `channel:open_failed` before the reply, but
|
||||
/// the variant stays reachable (no-establisher registrations, the
|
||||
/// direct-ALPN path's full in-band vocabulary).
|
||||
#[error("negotiation rejected: {error}")]
|
||||
@@ -141,12 +141,12 @@ pub enum TtySessionError {
|
||||
/// The transport refused the session open (the `Connection`'s
|
||||
/// `accept_bi` failed on either constructor path). Carries the
|
||||
/// upstream error type rather than flattening it into an
|
||||
/// `io::Error` (review #003 P15 — additive pre-consumers).
|
||||
/// `io::Error` (review #003 P15, additive pre-consumers).
|
||||
#[error("session open failed: {0}")]
|
||||
Open(#[from] alkcall::core::StreamError),
|
||||
}
|
||||
|
||||
/// A live `alk/tty` session — the typed consumer-side handle.
|
||||
/// A live `alk/tty` session, the typed consumer-side handle.
|
||||
///
|
||||
/// Constructed via [`TtySession::connect_direct`] (direct `alk/tty`
|
||||
/// ALPN) or [`TtySession::open_via_channels`] (multiplexed over
|
||||
@@ -217,18 +217,18 @@ impl TtySession {
|
||||
Self::from_bidi_stream(stream, negotiate).await
|
||||
}
|
||||
|
||||
/// Open a TTY session via `alk/channels` — the multiplexed path.
|
||||
/// Open a TTY session via `alk/channels`: the multiplexed path.
|
||||
///
|
||||
/// The consumer holds a [`ChannelClient`], calls
|
||||
/// `open_via_channels(client, params)`, which invokes
|
||||
/// `channels/tty/sub` on channel 0, adopts the resulting channel,
|
||||
/// builds a `Connection` from the reassembled read half + mux
|
||||
/// write half, and runs the typed-methods flow directly in raw
|
||||
/// chunk mode (ADR-009: the open op's `params` — validated by the
|
||||
/// registry's input schema — *are* the negotiation; no second
|
||||
/// chunk mode (ADR-009: the open op's `params`, validated by the
|
||||
/// registry's input schema, *are* the negotiation; no second
|
||||
/// negotiation frame is written on the channel's data stream).
|
||||
///
|
||||
/// `params` is the `NegotiateRequest` as a `serde_json::Value` —
|
||||
/// `params` is the `NegotiateRequest` as a `serde_json::Value`:
|
||||
/// the channels open op takes a `Value`, not a typed struct. The
|
||||
/// session parses it as a `NegotiateRequest` before opening (so a
|
||||
/// malformed request fails fast, before a channel is allocated)
|
||||
@@ -241,12 +241,12 @@ impl TtySession {
|
||||
/// establishment failures (a `NegotiateRequest` parse failure of a
|
||||
/// schema-valid-but-unparseable params value, `carriage != "raw"`,
|
||||
/// empty `cmd`, unknown backend, ownership denial) are rejected by
|
||||
/// the producer's establisher before the reply — they surface as
|
||||
/// the producer's establisher before the reply; they surface as
|
||||
/// [`TtySessionError::ChannelsOpen`] wrapping a `CallFailed` whose
|
||||
/// `CallError` is `channel:open_failed` with
|
||||
/// `details.reason` (alkcall 0.5.0 ADR-049; the SSH contract: no
|
||||
/// `channel_id` is ever returned). Since alkcall 0.6 (ADR-010 as
|
||||
/// amended — allocation moved into the establisher, the allocated
|
||||
/// amended: allocation moved into the establisher, the allocated
|
||||
/// handle crossing via the `Establishment` plan payload) no
|
||||
/// failure class arrives in-band on the channels path: allocation
|
||||
/// failure surfaces as `channel:open_failed` with
|
||||
@@ -259,7 +259,7 @@ impl TtySession {
|
||||
) -> Result<Self, TtySessionError> {
|
||||
// Borrowing deserialize (`&Value` implements `Deserializer`):
|
||||
// fails fast before a channel is allocated, and the owned
|
||||
// `params` still goes to `open_channel` (review #003 P15 — no
|
||||
// `params` still goes to `open_channel` (review #003 P15, no
|
||||
// clone).
|
||||
use serde::Deserialize as _;
|
||||
NegotiateRequest::deserialize(¶ms)
|
||||
@@ -296,7 +296,7 @@ impl TtySession {
|
||||
/// Like `from_bidi_stream` but takes the channel's `Connection`
|
||||
/// directly (the channels path already has the `Connection` from
|
||||
/// `Connection::from_source`). The negotiation already happened in
|
||||
/// the open op (ADR-009) — the stream starts in raw-chunk mode.
|
||||
/// the open op (ADR-009): the stream starts in raw-chunk mode.
|
||||
async fn from_bidi_stream_via(channel_conn: Connection) -> Result<Self, TtySessionError> {
|
||||
let stream = channel_conn.accept_bi().await?;
|
||||
let (read, write) = tokio::io::split(stream);
|
||||
@@ -327,7 +327,7 @@ impl TtySession {
|
||||
// `{1, 2, 4}` (the server never sends `0` or `3`). If the server
|
||||
// rejected the negotiation, read the error frame and return
|
||||
// `NegotiationRejected`; otherwise hand the peeked reader to the
|
||||
// read pump — `ChunkReader` tracks the peeked byte, so the pump's
|
||||
// read pump; `ChunkReader` tracks the peeked byte, so the pump's
|
||||
// `read_chunk()` completes that first chunk.
|
||||
match reader.peek_stream_type().await {
|
||||
Ok(0x00) => {
|
||||
@@ -345,11 +345,11 @@ impl TtySession {
|
||||
}
|
||||
|
||||
/// Core inner for the channels path (ADR-009): the negotiation
|
||||
/// already happened in the open op — the stream is already in
|
||||
/// already happened in the open op: the stream is already in
|
||||
/// raw-chunk mode. The peek still applies as defense-in-depth: a
|
||||
/// `0x00`-prefixed error frame means the producer rejected the
|
||||
/// session post-open (only reachable from a no-establisher
|
||||
/// registration — the establisher rejects every semantic class
|
||||
/// registration: the establisher rejects every semantic class
|
||||
/// before the reply, and allocation runs there too since alkcall
|
||||
/// 0.6 / ADR-010 as amended), while a raw chunk (`stream_type` in
|
||||
/// `{1, 2, 4}`) is the normal first data.
|
||||
@@ -379,7 +379,7 @@ impl TtySession {
|
||||
/// read pump. Shared by `from_halves` and `from_halves_raw`. The
|
||||
/// reader may arrive with a peeked first byte (the negotiation
|
||||
/// disambiguation peek); `ChunkReader` tracks that state, so the
|
||||
/// pump reads every chunk — including the first — via `read_chunk`.
|
||||
/// pump reads every chunk, including the first, via `read_chunk`.
|
||||
fn start_pump<R>(
|
||||
writer: ChunkWriter<Box<dyn AsyncWrite + Send + Unpin>>,
|
||||
reader: ChunkReader<R>,
|
||||
@@ -404,7 +404,7 @@ impl TtySession {
|
||||
|
||||
/// Send stdin bytes. Writes a stdin chunk (stream_type 0) with the
|
||||
/// given payload. An empty `bytes` writes a zero-length sentinel
|
||||
/// (client stdin EOF — see `tty-wire.md` §"Sentinels"); callers
|
||||
/// (client stdin EOF; see `tty-wire.md` §"Sentinels"); callers
|
||||
/// that want to signal EOF should use [`Self::close_stdin`] instead,
|
||||
/// which is explicit.
|
||||
pub async fn send_stdin(&self, bytes: Bytes) -> Result<(), TtySessionError> {
|
||||
@@ -414,7 +414,7 @@ impl TtySession {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Close stdin — send a zero-length stdin chunk (the EOF sentinel)
|
||||
/// Close stdin: send a zero-length stdin chunk (the EOF sentinel)
|
||||
/// and flush. The server closes the backend's stdin but keeps
|
||||
/// pumping stdout + the exit chunk (see `tty-wire.md` §"Stdin
|
||||
/// Closure").
|
||||
@@ -451,7 +451,7 @@ impl TtySession {
|
||||
/// Send a `Signal` control message (client→server,
|
||||
/// `STREAM_CTRL_IN`). `name` is an uppercase string from the
|
||||
/// supported set (`HUP`, `INT`, `QUIT`, `TERM`, `KILL`, `USR1`,
|
||||
/// `USR2`, `TSTP`, `CONT` — see [`crate::control::signal_from_name`]).
|
||||
/// `USR2`, `TSTP`, `CONT`; see [`crate::control::signal_from_name`]).
|
||||
/// Unknown names are forwarded as-is; the backend decides whether
|
||||
/// to ignore or fall back to its default kill.
|
||||
pub async fn signal(&self, name: &str) -> Result<(), TtySessionError> {
|
||||
@@ -467,11 +467,11 @@ impl TtySession {
|
||||
|
||||
/// Get the stdout stream. Returns a `Stream<Item = Bytes>` that
|
||||
/// yields stdout chunks as they arrive. The stream ends when the
|
||||
/// server's stdout reaches EOF — the zero-length stdout sentinel
|
||||
/// server's stdout reaches EOF: the zero-length stdout sentinel
|
||||
/// chunk (`tty-wire.md` §"Sentinels") terminates the stream and is
|
||||
/// NOT yielded as an item.
|
||||
///
|
||||
/// This consumes the stdout receiver — calling it twice returns
|
||||
/// This consumes the stdout receiver: calling it twice returns
|
||||
/// an empty stream the second time (the receiver is behind a
|
||||
/// `Mutex<Option<...>>` and is taken).
|
||||
pub async fn recv_stdout(&self) -> Pin<Box<dyn Stream<Item = Bytes> + Send>> {
|
||||
@@ -488,7 +488,7 @@ impl TtySession {
|
||||
}
|
||||
}));
|
||||
}
|
||||
// Already taken — return an empty stream (documented on
|
||||
// Already taken; return an empty stream (documented on
|
||||
// `recv_stdout`).
|
||||
Box::pin(futures::stream::empty())
|
||||
}
|
||||
@@ -497,7 +497,7 @@ impl TtySession {
|
||||
/// (stdout/stderr merged into stdout by the kernel PTY), or if
|
||||
/// already taken. The stream ends when the server's stderr reaches
|
||||
/// EOF. Stderr has no sentinel on the wire (the adapter's stderr
|
||||
/// pump emits none — only stdout carries the drained sentinel), so
|
||||
/// pump emits none, since only stdout carries the drained sentinel), so
|
||||
/// this stream ends when the read pump terminates.
|
||||
pub async fn recv_stderr(&self) -> Option<Pin<Box<dyn Stream<Item = Bytes> + Send>>> {
|
||||
let mut guard = self.stderr_rx.lock().await;
|
||||
@@ -511,10 +511,10 @@ impl TtySession {
|
||||
/// code. The exit chunk is the last control chunk before stream
|
||||
/// close (ADR-055). `code` is `i32`: `ExitStatus::code()` on
|
||||
/// normal exit; on Unix, the negative signal number on
|
||||
/// signal-terminated exit (e.g., `-15` for SIGTERM — the crate's
|
||||
/// signal-terminated exit (e.g., `-15` for SIGTERM; the crate's
|
||||
/// convention, not `ExitStatus::code()`'s, which returns `None`
|
||||
/// there). `-1` is the adapter's best-effort "backend could not
|
||||
/// determine the exit code" sentinel — colliding with the SIGHUP
|
||||
/// determine the exit code" sentinel, colliding with the SIGHUP
|
||||
/// encoding on the Unix pipe path (ADR-055 §4).
|
||||
///
|
||||
/// Returns [`TtySessionError::NoExitChunk`] if the session ends
|
||||
@@ -522,7 +522,7 @@ impl TtySession {
|
||||
pub async fn wait(&self) -> Result<i32, TtySessionError> {
|
||||
let mut rx = self.exit_code.clone();
|
||||
// If the pump already resolved before we started waiting, the
|
||||
// watch's current value is `Some(_)` — return it.
|
||||
// watch's current value is `Some(_)`; return it.
|
||||
{
|
||||
let borrow = rx.borrow();
|
||||
if let Some(outcome) = borrow.as_ref() {
|
||||
@@ -615,7 +615,7 @@ where
|
||||
/// - `STREAM_STDERR` (2) → stderr channel
|
||||
/// - `STREAM_CTRL_OUT` (4) → control message; parses as
|
||||
/// `ControlMessage` and, if it's `Exit`, resolves the exit watch
|
||||
/// - `STREAM_STDIN` (0) / `STREAM_CTRL_IN` (3) — client→server only;
|
||||
/// - `STREAM_STDIN` (0) / `STREAM_CTRL_IN` (3), client→server only;
|
||||
/// the server shouldn't send these, the pump ignores them (with a
|
||||
/// debug log)
|
||||
async fn read_pump<R>(
|
||||
@@ -701,7 +701,7 @@ mod tests {
|
||||
use std::sync::Arc;
|
||||
use tokio::io::duplex;
|
||||
|
||||
/// Build a `NegotiateRequest` for tests — minimal valid shape.
|
||||
/// Build a `NegotiateRequest` for tests: minimal valid shape.
|
||||
fn test_negotiate(backend: &str) -> NegotiateRequest {
|
||||
NegotiateRequest {
|
||||
carriage: "raw".to_string(),
|
||||
@@ -764,7 +764,7 @@ mod tests {
|
||||
/// A backend whose exit is held back until the test releases it, so
|
||||
/// typed-method writes happen while the session data plane is
|
||||
/// definitively open (the exit-resolves-immediately `MockBackend`
|
||||
/// races them against session teardown — review #003 P15's
|
||||
/// races them against session teardown; review #003 P15's
|
||||
/// `input_pump.abort()` made that race fail loudly).
|
||||
struct GatedBackend {
|
||||
release: Arc<tokio::sync::Mutex<Option<tokio::sync::oneshot::Receiver<()>>>>,
|
||||
@@ -904,7 +904,7 @@ mod tests {
|
||||
let len = u32::from_be_bytes(len_buf) as usize;
|
||||
let mut body = vec![0u8; len];
|
||||
let _ = server.read_exact(&mut body).await;
|
||||
// Drop `server` — the client's read pump hits EOF.
|
||||
// Drop `server`: the client's read pump hits EOF.
|
||||
});
|
||||
let client_conn = Connection::from_bidi(client, b"alk/tty".to_vec(), None);
|
||||
let session = TtySession::connect_direct(client_conn, test_negotiate("mock"))
|
||||
@@ -1079,14 +1079,13 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Client→server stream types (0 and 3) arriving from the server are
|
||||
/// protocol violations the read pump ignores — they must be skipped
|
||||
/// protocol violations the read pump ignores; they must be skipped
|
||||
/// without ending the pump, desynchronizing framing, or disturbing
|
||||
/// the stdout routing. The server interleaves them with real stdout
|
||||
/// chunks, then sends a well-formed exit; the session should deliver
|
||||
/// exactly the stdout chunks and the exit code. (The first chunk
|
||||
/// must be a legitimate server→client type: a leading `0x00` byte
|
||||
/// is the negotiation-error disambiguation marker — ADR-052 §5 —
|
||||
/// and would surface `NegotiationRejected` before the pump starts.)
|
||||
/// is the negotiation-error disambiguation marker (ADR-052 §5) and would surface `NegotiationRejected` before the pump starts.)
|
||||
#[tokio::test]
|
||||
async fn read_pump_ignores_client_to_server_stream_types_from_server() {
|
||||
let (client, mut server) = duplex(64 * 1024);
|
||||
@@ -1237,13 +1236,13 @@ mod tests {
|
||||
}
|
||||
|
||||
/// The ADR-049 §5 migration gate: an unknown backend is rejected by
|
||||
/// the producer's establisher before the reply — the open op fails
|
||||
/// the producer's establisher before the reply: the open op fails
|
||||
/// with `channel:open_failed` + `details.reason == "unknown_resource"`
|
||||
/// (the SSH "channel never exists opener-side" contract: no
|
||||
/// `channel_id`, no session), instead of the pre-0.2.0 post-open
|
||||
/// in-band `unknown_backend` negotiation error frame
|
||||
/// (`NegotiationRejected`). The direct-ALPN path keeps the in-band
|
||||
/// frame (two transports, two contracts — see
|
||||
/// frame (two transports, two contracts; see
|
||||
/// `connect_direct_returns_negotiation_rejected`).
|
||||
#[tokio::test]
|
||||
async fn open_via_channels_surfaces_negotiation_rejected() {
|
||||
@@ -1325,7 +1324,7 @@ mod tests {
|
||||
|
||||
/// End-to-end with an emitting backend (L2's backend): stdout and
|
||||
/// stderr route through the channels data plane to the consumer's
|
||||
/// typed streams — the full producer+consumer channels path with
|
||||
/// typed streams: the full producer+consumer channels path with
|
||||
/// real data.
|
||||
#[tokio::test]
|
||||
async fn open_via_channels_routes_backend_stdout_and_stderr() {
|
||||
|
||||
+7
-7
@@ -1,7 +1,7 @@
|
||||
//! Shared test wiring for the channels-based tests (the producer
|
||||
//! harness in `channels.rs::tests` and the consumer end-to-end tests
|
||||
//! in `session.rs::tests` both need the same `ChannelClient` ↔
|
||||
//! server wiring). `#[cfg(test)]`-only — never part of the public
|
||||
//! server wiring). `#[cfg(test)]`-only; never part of the public
|
||||
//! API.
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
@@ -67,7 +67,7 @@ pub(crate) async fn wire_client_and_server(
|
||||
// `resolve_identity` sees it. The
|
||||
// `ChannelsAdapter` builds `channel0_conn` fresh
|
||||
// from `channel_source` and does NOT inherit the
|
||||
// outer connection's identity — we set it here
|
||||
// outer connection's identity; we set it here
|
||||
// so the `AccessControl` scope-gate can check it.
|
||||
if let Some(id) = _identity {
|
||||
let _ = channel0_conn.set_identity(id);
|
||||
@@ -192,7 +192,7 @@ mod tests {
|
||||
|
||||
// The producer writes a 5-byte TTY header (stdout sentinel)
|
||||
// almost immediately after the open (MockBackend resolves
|
||||
// exit right away) — a push-first producer whose first write
|
||||
// exit right away), a push-first producer whose first write
|
||||
// races the consumer's adopt. Read it: alkcall 0.4.1's
|
||||
// early-arrival park guarantees the first chunks are not lost.
|
||||
let mut first = [0u8; 5];
|
||||
@@ -209,14 +209,14 @@ mod tests {
|
||||
|
||||
/// End-to-end (R4, post-ADR-010): `input` that passes the registry's
|
||||
/// (partial) schema but fails the establisher's full
|
||||
/// `NegotiateRequest` parse — `cwd` typed as a number. The
|
||||
/// `NegotiateRequest` parse (`cwd` typed as a number). The
|
||||
/// establisher rejects it before the reply: the open op fails with
|
||||
/// `channel:open_failed` + `details.reason == "handler_error"` (the
|
||||
/// malformed-negotiation mapping), the channel is torn down, and no
|
||||
/// `channel_id` is ever returned. Uses `ChannelClient::open_channel`
|
||||
/// directly (the consumer's local fail-fast parse in
|
||||
/// `open_via_channels` would reject these params before the open op
|
||||
/// runs — R5's fail-fast path).
|
||||
/// runs; R5's fail-fast path).
|
||||
#[tokio::test]
|
||||
async fn schema_valid_but_unparseable_input_fails_open_with_open_failed() {
|
||||
use alkcall::channels::client::ChannelOpenError;
|
||||
@@ -268,8 +268,8 @@ mod tests {
|
||||
/// longer arrives in-band. `backend.allocate` runs in the
|
||||
/// establisher (the allocated `TtyHandle` crosses to the pump
|
||||
/// handler via the `Establishment` plan payload), so its failure
|
||||
/// fails the open op itself — `channel:open_failed` with
|
||||
/// `details.reason == "dial_failed"` — and no channel survives
|
||||
/// fails the open op itself (`channel:open_failed` with
|
||||
/// `details.reason == "dial_failed"`), and no channel survives
|
||||
/// (the SSH contract: no `channel_id`, no `0x00`-prefixed
|
||||
/// in-band frame on a channel that was reported as succeeding).
|
||||
#[tokio::test]
|
||||
|
||||
+20
-20
@@ -10,8 +10,8 @@
|
||||
//! - 0 = stdin (client→server, raw bytes)
|
||||
//! - 1 = stdout (server→client, raw bytes)
|
||||
//! - 2 = stderr (server→client, raw bytes)
|
||||
//! - 3 = ctrl_in (client→server, JSON control message — see [`crate::control`])
|
||||
//! - 4 = ctrl_out (server→client, JSON control message — see [`crate::control`])
|
||||
//! - 3 = ctrl_in (client→server, JSON control message; see [`crate::control`])
|
||||
//! - 4 = ctrl_out (server→client, JSON control message; see [`crate::control`])
|
||||
//!
|
||||
//! The control channel is split into two halves so it is genuinely
|
||||
//! bidirectional on the wire: `STREAM_CTRL_IN = 3` carries client→server
|
||||
@@ -19,7 +19,7 @@
|
||||
//! server→client control (exit). The previous single `STREAM_CONTROL = 3`
|
||||
//! was documented as "bidirectional" but the adapter ignored `Exit` from
|
||||
//! the client because it had no way to distinguish the two directions on
|
||||
//! the same stream_type — see the Phase 7 notes in
|
||||
//! the same stream_type. See the Phase 7 notes in
|
||||
//! `docs/architecture/tty-wire.md` (the bidirectionality fix that
|
||||
//! split the single `STREAM_CONTROL = 3` into `STREAM_CTRL_IN` (3)
|
||||
//! and `STREAM_CTRL_OUT` (4)).
|
||||
@@ -27,7 +27,7 @@
|
||||
//! Zero-length data chunks are sentinels: a zero-length stdin chunk is EOF
|
||||
//! from the client; a zero-length stdout chunk is "drained" from the
|
||||
//! server. Control chunks are never zero-length (the JSON payload is at
|
||||
//! least `{}`). The codec does not special-case sentinels — they are just
|
||||
//! least `{}`). The codec does not special-case sentinels: they are just
|
||||
//! chunks with `length == 0`; the adapter interprets them. See
|
||||
//! `docs/architecture/tty-wire.md` §"Sentinels".
|
||||
|
||||
@@ -41,17 +41,17 @@ pub const STREAM_STDIN: u8 = 0;
|
||||
pub const STREAM_STDOUT: u8 = 1;
|
||||
/// stderr channel (server→client, raw bytes).
|
||||
pub const STREAM_STDERR: u8 = 2;
|
||||
/// Control channel, client→server half (JSON control message —
|
||||
/// Control channel, client→server half (JSON control message:
|
||||
/// `Resize`, `Signal`, `Eof`).
|
||||
pub const STREAM_CTRL_IN: u8 = 3;
|
||||
/// Control channel, server→client half (JSON control message — `Exit`).
|
||||
/// Control channel, server→client half (JSON control message: `Exit`).
|
||||
pub const STREAM_CTRL_OUT: u8 = 4;
|
||||
|
||||
/// Chunk header length in bytes: 1 byte `stream_type` + 4 bytes `length`.
|
||||
pub const CHUNK_HEADER_LEN: usize = 5;
|
||||
/// Maximum payload length. A larger chunk is a `ChunkTooLarge` protocol
|
||||
/// error. Shared with the negotiation module so error frames (which reuse
|
||||
/// the 4-byte length-prefix framing) stay under 16 MiB — this keeps the
|
||||
/// the 4-byte length-prefix framing) stay under 16 MiB; this keeps the
|
||||
/// high byte of the length prefix `0x00`, which is what makes the
|
||||
/// framing-disambiguation trick sound (see ADR-052 §5).
|
||||
pub const MAX_CHUNK_LEN: u32 = 16 * 1024 * 1024;
|
||||
@@ -59,7 +59,7 @@ pub const MAX_CHUNK_LEN: u32 = 16 * 1024 * 1024;
|
||||
/// Errors from the raw chunk codec.
|
||||
///
|
||||
/// `ConnectionClosed` is returned (rather than `Io`) when `read_chunk`
|
||||
/// hits a clean `UnexpectedEof` reading either the header or the payload —
|
||||
/// hits a clean `UnexpectedEof` reading either the header or the payload:
|
||||
/// the peer closed the stream cleanly rather than failing the transport.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum RawError {
|
||||
@@ -117,7 +117,7 @@ impl Chunk {
|
||||
}
|
||||
}
|
||||
|
||||
/// A client→server control chunk (stream_type 3) — `Resize`, `Signal`,
|
||||
/// A client→server control chunk (stream_type 3): `Resize`, `Signal`,
|
||||
/// or `Eof`.
|
||||
pub fn ctrl_in(bytes: bytes::Bytes) -> Self {
|
||||
Self {
|
||||
@@ -126,7 +126,7 @@ impl Chunk {
|
||||
}
|
||||
}
|
||||
|
||||
/// A server→client control chunk (stream_type 4) — `Exit`.
|
||||
/// A server→client control chunk (stream_type 4): `Exit`.
|
||||
pub fn ctrl_out(bytes: bytes::Bytes) -> Self {
|
||||
Self {
|
||||
stream_type: STREAM_CTRL_OUT,
|
||||
@@ -141,7 +141,7 @@ impl Chunk {
|
||||
/// `stream_type` (≤ 4, else [`RawError::InvalidStreamType`]) and the
|
||||
/// payload length (≤ [`MAX_CHUNK_LEN`], else [`RawError::ChunkTooLarge`]),
|
||||
/// then reads the payload. On a clean `UnexpectedEof` reading either the
|
||||
/// header or the payload, it returns [`RawError::ConnectionClosed`] — the
|
||||
/// header or the payload, it returns [`RawError::ConnectionClosed`]; the
|
||||
/// stream ended cleanly, not with a transport error.
|
||||
///
|
||||
/// The reader tracks peeked state: after
|
||||
@@ -190,7 +190,7 @@ impl<R: AsyncRead + Unpin> ChunkReader<R> {
|
||||
/// consuming the rest of the header. Used by the consumer to
|
||||
/// disambiguate a negotiation error frame (whose 4-byte length prefix
|
||||
/// starts with `0x00`) from a raw chunk (whose first byte is a
|
||||
/// `stream_type` in `{1, 2, 4}` — the server never sends `0` or `3`).
|
||||
/// `stream_type` in `{1, 2, 4}`; the server never sends `0` or `3`).
|
||||
/// After peeking, the caller either reads the error frame itself (via
|
||||
/// [`ChunkReader::into_inner`]) or completes the chunk with
|
||||
/// [`ChunkReader::read_chunk_after_peek`] or
|
||||
@@ -261,10 +261,10 @@ impl<R: AsyncRead + Unpin> ChunkReader<R> {
|
||||
|
||||
/// Shared header invariant for both codec directions: a `stream_type` in
|
||||
/// `{0..=4}` and a payload length ≤ [`MAX_CHUNK_LEN`] (which keeps the
|
||||
/// length prefix's high byte `0x00` — the negotiation-disambiguation
|
||||
/// length prefix's high byte `0x00`, the negotiation-disambiguation
|
||||
/// invariant, ADR-052 §5). The read path validates peer input (length is
|
||||
/// already a `u32` from the header); the write paths validate caller
|
||||
/// input *before* the length is cast to `u32` — validating the truncated
|
||||
/// input *before* the length is cast to `u32`; validating the truncated
|
||||
/// value would let a ≥2³² payload wrap past the check and corrupt the
|
||||
/// peer's framing, which is exactly the bug this validation exists to
|
||||
/// catch.
|
||||
@@ -291,7 +291,7 @@ fn validate_header(stream_type: u8, length: u64) -> Result<(), RawError> {
|
||||
/// [`RawError::InvalidStreamType`] / [`RawError::ChunkTooLarge`] instead
|
||||
/// of writing a header the peer cannot frame (a truncated `u32` length
|
||||
/// would corrupt the stream). An empty payload writes the same header as
|
||||
/// a zero-length payload — every helper takes `&[u8]`-shaped input and
|
||||
/// a zero-length payload. Every helper takes `&[u8]`-shaped input and
|
||||
/// emits `length == 0` with no payload bytes.
|
||||
pub struct ChunkWriter<W: AsyncWrite + Unpin> {
|
||||
writer: W,
|
||||
@@ -309,7 +309,7 @@ impl<W: AsyncWrite + Unpin> ChunkWriter<W> {
|
||||
}
|
||||
|
||||
/// Write a chunk: header + payload (if non-empty) + flush. The
|
||||
/// chunk's `stream_type` and payload length are validated first —
|
||||
/// chunk's `stream_type` and payload length are validated first:
|
||||
/// an invalid chunk fails locally with [`RawError::InvalidStreamType`]
|
||||
/// or [`RawError::ChunkTooLarge`] instead of corrupting the peer's
|
||||
/// framing.
|
||||
@@ -483,7 +483,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Write-side validation: a payload over `MAX_CHUNK_LEN` fails with
|
||||
/// `ChunkTooLarge` before any header is written — previously the
|
||||
/// `ChunkTooLarge` before any header is written; previously the
|
||||
/// `as u32` cast would silently write a truncated length and
|
||||
/// desynchronize the peer.
|
||||
#[tokio::test]
|
||||
@@ -554,7 +554,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// P7 regression: after `peek_stream_type`, plain `read_chunk` must
|
||||
/// complete the already-peeked chunk — not consume a second header
|
||||
/// complete the already-peeked chunk, not consume a second header
|
||||
/// byte and desynchronize the stream. The old shape silently read
|
||||
/// the peeked byte's `length` bytes from the *payload* as a header.
|
||||
#[tokio::test]
|
||||
@@ -680,7 +680,7 @@ mod tests {
|
||||
/// the BAST's index→name table matches the on-wire `uint8` values.
|
||||
/// Catches the common drift case (a new stream_type added to
|
||||
/// `wire.rs` but not the BAST, or vice versa) without requiring
|
||||
/// alktype as a dep — just `serde_json`, which alktty already has.
|
||||
/// alktype as a dep: just `serde_json`, which alktty already has.
|
||||
/// See the project setup plan's "Risk: BAST schema drift"
|
||||
/// mitigation.
|
||||
#[test]
|
||||
@@ -727,7 +727,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
values.len(),
|
||||
expected.len(),
|
||||
"StreamType enum has {} values, wire.rs has {} — drift",
|
||||
"StreamType enum has {} values, wire.rs has {} (drift)",
|
||||
values.len(),
|
||||
expected.len()
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user