2 Commits
Author SHA1 Message Date
glm-5.3-flash bc4a9b6024 docs: align remaining alloc-failure references with ADR-010 §2A (prepublish review for v0.3.0)
- README.md ADR index: ADR-010 status notes the §2A amendment
- ADR-009: amendment note records the §2A supersession (in-band path
  shrinks to nothing from registered producers)
- ADR-010 Consequences: dial_failed replaces the stale in-band
  allocate_failed retry-policy bullet
- AllocFailed doc comment + tty-backend.md: describe both failure
  surfaces (direct-path frame / channels-path dial_failed)
2026-09-07 10:04:44 +00:00
glm-5.3-flash af9dbaedea feat: allocation moves into the channels establisher (alkcall 0.6.0 / review 007 R-01) — bump to 0.3.0
- Bump alkcall 0.5.0 -> 0.6.0 (Establishment plan payload, ADR-049
  amendment 2); alktty 0.2.0 -> 0.3.0 (breaking channels-path
  allocation-failure shape)
- make_tty_establisher runs backend.allocate; failure maps to
  EstablishmentError::DialFailed (channel:open_failed reason
  dial_failed); the TtyHandle crosses to the pump handler via a
  private per-open AllocatedHandle one-shot slot (the handle is not
  Sync — it cannot be the ChannelPlan directly)
- make_tty_open_handler gains the Option<ChannelPlan> parameter; a
  plan drives the new drive_session_pre_allocated (pumps only), a
  None plan falls back to drive_session_pre_negotiated (inline
  validate-and-allocate — defense-in-depth for no-establisher
  registrations)
- tty_open_spec's channel:open_failed ErrorDefinition declares
  dial_failed (four reachable reasons)
- Pinned in-band allocate test flipped:
  allocate_failure_fails_open_as_dial_failed (end-to-end, no channel
  survives); establisher unit gate for the plan slot + failure
  mapping
- ADR-010 amended (§2A; §2 kept as historical record), tty-adapter.md,
  session/channels/adapter docs, CHANGELOG, AGENTS.md alkcall pin
  updated

Verification: cargo test (113) + --all-features (137), clippy
-D warnings (native + wasm32, default + all-features), fmt, doc,
wasm32 check — all clean
2026-09-07 09:53:57 +00:00
14 changed files with 454 additions and 154 deletions

No files matched your search

+8 -5
View File
@@ -199,8 +199,9 @@ implementation agents.
registry runs the ACL before the wrapper) and runs the ownership 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 establishment phase since alkcall 0.5.0 ADR-049, so a denial is
`channel:open_failed`, not an in-band frame; the pump handler `channel:open_failed`, not an in-band frame; since alkcall 0.6.0
only allocates and pumps). Terminal sessions are resources per ADR-010 §2A the establisher also allocates — the pump handler
only pumps). Terminal sessions are resources per
ADR-050 (ported as alktty ADR-003); `OwnershipStore::record(&self, ADR-050 (ported as alktty ADR-003); `OwnershipStore::record(&self,
identity, resource_type, resource_id)` is the 3-arg shape (alkcall identity, resource_type, resource_id)` is the 3-arg shape (alkcall
dropped the old `action` arg). dropped the old `action` arg).
@@ -314,6 +315,8 @@ wasm-clean" invariant — run it whenever a non-`local` module changes.
`StreamError`) come from `alkcall::core`. alkcall is v0.4.x — `StreamError`) come from `alkcall::core`. alkcall is v0.4.x —
breaking changes are expected at this major-zero stage; this is the breaking changes are expected at this major-zero stage; this is the
first real consumer, so we find and fix issues upstream rather than first real consumer, so we find and fix issues upstream rather than
working around them. Pin `alkcall = "0.5.0"` (the working around them. Pin `alkcall = "0.6.0"` (the
channel-open establishment phase — ADR-049, review 006 E-01/N-1; establishment-plan payload — ADR-049 amendment 2, review 007 R-01;
adopted by alktty ADR-010) and bump deliberately. adopted by alktty ADR-010 §2A — allocation runs in the channels
establisher, the `TtyHandle` crosses via the plan) and bump
deliberately.
+37
View File
@@ -4,6 +4,43 @@ All notable changes to this crate are documented here. The format is
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
this crate adheres to [Semantic Versioning](https://semver.org/). this crate adheres to [Semantic Versioning](https://semver.org/).
## [0.3.0] - 2026-09-07
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
unchanged.
### Changed
- **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
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{
channel:open_failed, reason: "dial_failed" })` instead of the
post-open in-band `NegotiationRejected{ "allocate_failed" }`. The
SSH contract now covers every failure class: no channel ever exists
opener-side, no `channel_id`, no `0x00`-prefixed in-band frame. The
direct-ALPN path's in-band vocabulary (including `allocate_failed`)
is unchanged.
- `tty_open_spec()`'s `channel:open_failed` `ErrorDefinition` declares
`dial_failed` in the `details.reason` enum (four reachable reasons:
`dial_failed` / `unknown_resource` / `handler_error` / `timeout`).
- 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
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
end-to-end `dial_failed` open-failure surface (flipped from the
pinned in-band test), and the handler's `plan: None` fallback arm.
## [0.2.0] - 2026-09-06 ## [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
Generated
+3 -3
View File
@@ -27,9 +27,9 @@ dependencies = [
[[package]] [[package]]
name = "alkcall" name = "alkcall"
version = "0.5.0" version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bbaeb718c370b74f9136d1d59b4608249bd7d1dd2332c0259f98351d709737f" checksum = "a44500900b82904d2a870e444051fc041a042e42ff7190803bbc5b3973f4feeb"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"bytes", "bytes",
@@ -48,7 +48,7 @@ dependencies = [
[[package]] [[package]]
name = "alktty" name = "alktty"
version = "0.2.0" version = "0.3.0"
dependencies = [ dependencies = [
"alkcall", "alkcall",
"async-trait", "async-trait",
+2 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "alktty" name = "alktty"
version = "0.2.0" version = "0.3.0"
edition = "2021" edition = "2021"
rust-version = "1.85" rust-version = "1.85"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
@@ -25,7 +25,7 @@ default = []
local = ["dep:portable-pty", "dep:tokio-util", "tokio/process", "tokio/rt-multi-thread"] local = ["dep:portable-pty", "dep:tokio-util", "tokio/process", "tokio/rt-multi-thread"]
[dependencies] [dependencies]
alkcall = "0.5.0" alkcall = "0.6.0"
# Minimal, wasm-clean tokio features. `local` adds `process` + # Minimal, wasm-clean tokio features. `local` adds `process` +
# `rt-multi-thread` (non-wasm). Do NOT use `features = ["full"]` — it # `rt-multi-thread` (non-wasm). Do NOT use `features = ["full"]` — it
# pulls in `signal`/`fs`/`net` which break `wasm32-unknown-unknown`. # pulls in `signal`/`fs`/`net` which break `wasm32-unknown-unknown`.
+1 -1
View File
@@ -37,7 +37,7 @@ tty-specific and therefore not ported into alktty's ADR range).
| [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) | | [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) | | [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 — in-band frames shrink to `allocate_failed`) | | [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) |
## Key Design Principles ## Key Design Principles
@@ -21,6 +21,13 @@ cannot carry the allocated handle across — see ADR-010 §2). The R4
tests that asserted the in-band frames for those classes now assert tests that asserted the in-band frames for those classes now assert
the call-error shape. the call-error shape.
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
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.
## Context ## Context
Before this ADR, the channels path carried the negotiation twice. The Before this ADR, the channels path carried the negotiation twice. The
@@ -10,6 +10,19 @@ error frame is retained — two transports, two contracts").
Prerequisites: alkcall 0.5.0 (`ChannelCore::register_openable_with_establisher`, Prerequisites: alkcall 0.5.0 (`ChannelCore::register_openable_with_establisher`,
`channel:open_failed`, typed `ChannelOpenError`). `channel:open_failed`, typed `ChannelOpenError`).
**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
the establisher; the allocated `TtyHandle` crosses to the pump
handler via the plan; `allocate_failed` joins the call-error surface
as `details.reason == "dial_failed"` (the target refused or the
backend lacked capacity). No failure class arrives in-band on the
channels path from a registered producer; the in-band error-frame
fallback stays for no-establisher registrations (defense-in-depth)
and the direct-ALPN path. See §2A below; §2 is retained as
historical record.
Amends ADR-009's R4 amendment: the in-band error-frame path on the Amends ADR-009's R4 amendment: the in-band error-frame path on the
channels path shrinks to one failure class (`allocate_failed`). channels path shrinks to one failure class (`allocate_failed`).
@@ -68,18 +81,26 @@ the vocabulary is alkcall's one-way wire surface):
| `carriage != "raw"`, empty `cmd` | `HandlerError` | `handler_error` | | `carriage != "raw"`, empty `cmd` | `HandlerError` | `handler_error` |
| unknown backend | `UnknownResource` | `unknown_resource` | | unknown backend | `UnknownResource` | `unknown_resource` |
| ownership denial | `HandlerError` | `handler_error` | | ownership denial | `HandlerError` | `handler_error` |
| **allocation failure** (amended — §2A) | `DialFailed` | `dial_failed` |
| establishment deadline | (wrapper) | `timeout` | | establishment deadline | (wrapper) | `timeout` |
`dial_failed` and `resource_shortage` have no TTY producer path (TTY `resource_shortage` has no TTY producer path (allocation-capacity
dials nothing at open time; `allocate` capacity failures are the failures map to `dial_failed` — the target refused or the backend
in-band class below) — the spec's `ErrorDefinition` for lacked capacity; §2A). `HandlerError` covers both the
`channel:open_failed` declares only the three reachable reasons. malformed-negotiation semantics (the request shape is wrong) and the
`HandlerError` covers both the malformed-negotiation semantics (the ACL-outcome ownership denial (an authorization failure, not a dial
request shape is wrong) and the ACL-outcome ownership denial (an failure).
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
> 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
> second allocate to be unsound about.
The one failure class that cannot migrate: `backend.allocate`. Two The one failure class that cannot migrate: `backend.allocate`. Two
reasons, both structural: reasons, both structural:
@@ -114,6 +135,60 @@ no-establisher registration still compiles against alkcall's API and
must not silently EOF), and the R4 seam test pins the handler-side must not silently EOF), and the R4 seam test pins the handler-side
frame. frame.
### 2A. Amendment (alkcall 0.6.0): allocation moves into the establisher
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
is gone; the double-`allocate` hazard (blocker 2) is structurally
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(&params)`. 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
`allocate_failed` frame always had). The
`channel:open_failed` `ErrorDefinition` on `tty_open_spec()`
therefore declares four reachable reasons (`dial_failed`,
`unknown_resource`, `handler_error`, `timeout`).
2. **The handle crosses via the plan.** `TtyHandle` is not `Sync`
(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
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
(ADR-049 §1). The shared-slot race §2 described is unreachable by
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
`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
an establisher, not a registered-producer path.
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
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
(`allocate_failure_fails_open_as_dial_failed` in `testing.rs`,
plus the establisher unit gate `establisher_allocates_and_maps_
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
no lifetime changes.
### 3. The direct-ALPN path is unchanged ### 3. The direct-ALPN path is unchanged
`TtyAdapter::handle` / `drive_session` keep the wire-frame `TtyAdapter::handle` / `drive_session` keep the wire-frame
@@ -149,10 +224,10 @@ unchanged (`true` direct, `false` channels — the registry's
| malformed negotiation (schema-valid) | establisher | `ChannelsOpen(CallFailed{ channel:open_failed, reason: handler_error })` | | malformed negotiation (schema-valid) | establisher | `ChannelsOpen(CallFailed{ channel:open_failed, reason: handler_error })` |
| unknown backend | establisher | `ChannelsOpen(CallFailed{ channel:open_failed, reason: unknown_resource })` | | unknown backend | establisher | `ChannelsOpen(CallFailed{ channel:open_failed, reason: unknown_resource })` |
| ownership denial | establisher | `ChannelsOpen(CallFailed{ channel:open_failed, reason: handler_error })` | | ownership denial | establisher | `ChannelsOpen(CallFailed{ channel:open_failed, reason: handler_error })` |
| **allocation failure** (amended — §2A) | establisher | **`ChannelsOpen(CallFailed{ channel:open_failed, reason: dial_failed })`** |
| establishment timeout | wrapper bound | `ChannelsOpen(CallFailed{ channel:open_failed, reason: timeout })` | | establishment timeout | wrapper bound | `ChannelsOpen(CallFailed{ channel:open_failed, reason: timeout })` |
| ACL denial (scope) | registry gate | `ChannelsOpen(CallFailed{ FORBIDDEN })` | | ACL denial (scope) | registry gate | `ChannelsOpen(CallFailed{ FORBIDDEN })` |
| channel cap | wrapper `check_open` | `ChannelsOpen(CallFailed{ channel:too_many_channels })` | | channel cap | wrapper `check_open` | `ChannelsOpen(CallFailed{ channel:too_many_channels })` |
| **allocate failure** | pump handler, post-open | **`NegotiationRejected{ "allocate_failed" }`** |
`TtySessionError::ChannelsOpen` now carries alkcall's typed `TtySessionError::ChannelsOpen` now carries alkcall's typed
`ChannelOpenError` verbatim (`#[from]`) instead of a flattened `ChannelOpenError` verbatim (`#[from]`) instead of a flattened
@@ -167,10 +242,12 @@ on `establishment_reason()` without unwrapping strings.
open no longer allocates → succeeds → in-band-fails. Ledger, policy open no longer allocates → succeeds → in-band-fails. Ledger, policy
count, and manager state balance on every rejection. 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), `allocate_failed` (capacity — retry), `timeout` (maybe retry), `dial_failed` (capacity/target
still distinguishable 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 `allocate_failed`. no peeking at the data stream for anything but the (now
unreachable-from-registered-producers) error-frame formality.
**Negative:** **Negative:**
@@ -180,9 +257,11 @@ on `establishment_reason()` without unwrapping strings.
direct path's failure surface is unchanged. direct path's failure surface is unchanged.
- The `channel:open_failed` reason codes the establisher emits join - The `channel:open_failed` reason codes the establisher emits join
the wire-stable error set (ADR-049's one-way door). the wire-stable error set (ADR-049's one-way door).
- `allocate_failed` remains a second, in-band failure shape on the - **Amendment (0.3.0):** allocation failure changed shape again —
channels path — one residual asymmetry, pinned and documented until `NegotiationRejected{ "allocate_failed" }` (in-band) →
`Establishment` gains a payload. `ChannelsOpen(CallFailed{ channel:open_failed, reason:
dial_failed })`. The direct path's `allocate_failed` frame is
unchanged (two transports, two contracts).
## Door type ## Door type
@@ -205,12 +284,16 @@ ADR moves failure *reporting*, not the negotiation *location*.
- ADR-009 (the open op's `input` is the negotiation; the R4 - 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) this ADR)
- ADR-005 (kill-on-`Drop` — why establisher-side allocation is - ADR-005 (kill-on-`Drop` — why a *second* establisher-side allocate
unsound) 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 and the channels `allocate_failed` frame) path; the channels-path peek is now a formality)
- ADR-002 (backend selection/allocation ownership) - ADR-002 (backend selection/allocation ownership)
- `src/channels.rs` — `make_tty_establisher`, `make_tty_open_handler`, - alkcall ADR-049 amendment 2 / review 007 R-01 (the `Establishment`
`tty_open_spec` 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/session.rs` — `TtySessionError::ChannelsOpen`, `open_via_channels`
- `src/testing.rs` — the pinned `allocate_failed`-stays-in-band test - `src/testing.rs` — the pinned `allocate_failure_fails_open_as_dial_failed`
test (superseded `allocate_failure_still_arrives_in_band_on_channels_path`)
+11 -9
View File
@@ -177,17 +177,18 @@ entering raw mode. The error response shape:
On the **direct-ALPN path** this is the failure surface for every On the **direct-ALPN path** this is the failure surface for every
negotiation/allocation failure class (below). On the **channels path** negotiation/allocation failure class (below). On the **channels path**
the semantic classes (unknown backend, malformed negotiation, the semantic classes (unknown backend, malformed negotiation,
ownership denial) are rejected by the establisher before the open ownership denial) **and allocation** are rejected by the establisher
reply and surface as `channel:open_failed` call errors (alkcall 0.5.0 before the open reply and surface as `channel:open_failed` call
ADR-049 / alktty ADR-010) — the in-band frames on that path are errors (alkcall ADR-049 / alktty ADR-010, as amended for alkcall 0.6's
defense-in-depth arms plus the one non-migratable class `Establishment` plan payload — ADR-010 §2A) — the in-band frames on
(`allocate_failed`, ADR-010 §2). that path are defense-in-depth arms only (reachable from
no-establisher registrations).
| Error | When | Shape | | 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":"..."}` | | `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":"..."}` | | `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` — on both paths: the establisher cannot carry the allocated handle across to the pump handler (ADR-010 §2), so allocation failure is the one in-band failure class on the channels path | `{"error":"allocate_failed","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 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 the bidi stream. The client reads the error frame and treats stream close
@@ -369,7 +370,7 @@ architectural commitment.
| Backend cleanup on session cancel | [ADR-005](decisions/005-backend-cleanup-on-session-cancel.md) | Dropping `exit_code` future kills the session target; the adapter triggers it by dropping the `TtyHandle` on cancel | | Backend cleanup on session cancel | [ADR-005](decisions/005-backend-cleanup-on-session-cancel.md) | Dropping `exit_code` future kills the session target; the adapter triggers it by dropping the `TtyHandle` on cancel |
| Channels pure channel multiplexing | [ADR-008](decisions/008-channels-pure-channel-multiplexing.md) | The same session driver runs in both direct and channels modes; only the `BiStream` source differs | | Channels pure channel multiplexing | [ADR-008](decisions/008-channels-pure-channel-multiplexing.md) | The same session driver runs in both direct and channels modes; only the `BiStream` source differs |
| Negotiation carried in the open op | [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 (`drive_session_pre_negotiated`) | | Negotiation carried in the open op | [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 (`drive_session_pre_negotiated`) |
| Channels establishment failures are call errors | [ADR-010](decisions/010-channels-establisher-migration.md) | The channels path's semantic failures are rejected by the establisher (`channel:open_failed`, alkcall ADR-049); `allocate_failed` stays in-band | | Channels establishment failures are call errors | [ADR-010](decisions/010-channels-establisher-migration.md) | The channels path's semantic failures and allocation are rejected/run in the establisher (`channel:open_failed`, alkcall ADR-049; alkcall 0.6 plan payload carries the `TtyHandle`); no in-band frame from a registered producer |
| Dynamic resource ownership | alknet ADR-050 | Terminal sessions as runtime-spawned resources; the adapter's access-control shape | | Dynamic resource ownership | alknet ADR-050 | Terminal sessions as runtime-spawned resources; the adapter's access-control shape |
## Open Questions ## Open Questions
@@ -394,8 +395,9 @@ architectural commitment.
registry-validated `input` is the negotiation) 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 channels path's semantic failures are establisher call errors
(`channel:open_failed`); the in-band frames shrink to (`channel:open_failed`); since alkcall 0.6 the establisher also
`allocate_failed` 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 declares against
- alknet ADR-007 — `Connection`, `accept_bi`, the handler-receives- - alknet ADR-007 — `Connection`, `accept_bi`, the handler-receives-
+4 -2
View File
@@ -125,8 +125,10 @@ 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 to start, the SSH channel request was rejected. Returned by
`allocate()`; the adapter sends `{"error":"allocate_failed",...}` and `allocate()`; on the direct path the adapter sends
closes (tty-adapter.md §"Negotiation errors"). `{"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 exit code. Returned by the `exit_code` future; the adapter sends
`{"type":"exit","code":-1}` (ADR-004 §4). `{"type":"exit","code":-1}` (ADR-004 §4).
+26
View File
@@ -253,6 +253,32 @@ 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
/// (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).
/// The raw-chunk data plane starts immediately; nothing is read or
/// written before the pumps.
///
/// Not public API: the only caller is the channels open handler. A
/// consumer that somehow reaches a pre-allocated stream without the
/// producer-side establisher (no-establisher registration) goes
/// through [`drive_session_pre_negotiated`] instead, whose inline
/// validate-and-allocate keeps the in-band error-frame fallback.
pub(crate) async fn drive_session_pre_allocated<W, R>(
client_send: W,
client_recv: R,
handle: TtyHandle,
) where
W: AsyncWrite + Send + Unpin + 'static,
R: AsyncRead + Send + Unpin + 'static,
{
if let Err(e) = pump_session(client_send, client_recv, handle).await {
debug!("tty: session ended with error: {e}");
}
}
/// Validate a parsed `NegotiateRequest`, select the backend, run the /// Validate a parsed `NegotiateRequest`, select the backend, run the
/// ADR-050 ownership check, and allocate. Shared by the direct /// ADR-050 ownership check, and allocate. Shared by the direct
/// (wire-negotiated) and channels (pre-negotiated) paths. Failures are /// (wire-negotiated) and channels (pre-negotiated) paths. Failures are
+5 -2
View File
@@ -45,8 +45,11 @@ pub type BoxFuture<T> = futures::future::BoxFuture<'static, T>;
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum TtyError { pub enum TtyError {
/// The PTY couldn't be allocated, the docker exec failed to start, the /// The PTY couldn't be allocated, the docker exec failed to start, the
/// SSH channel request was rejected. Returned by `allocate()`; the /// SSH channel request was rejected. Returned by `allocate()`; on the
/// adapter sends `{"error":"allocate_failed",...}` and closes. /// direct path the adapter sends `{"error":"allocate_failed",...}` and
/// closes; on the channels path (alkcall 0.6, ADR-010 §2A) the
/// establisher maps it to `channel:open_failed` /
/// `details.reason == "dial_failed"`.
#[error("allocate failed: {message}")] #[error("allocate failed: {message}")]
AllocFailed { message: String }, AllocFailed { message: String },
/// The backend couldn't reap the child / determine the exit code. /// The backend couldn't reap the child / determine the exit code.
+187 -60
View File
@@ -18,13 +18,13 @@
//! `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 //! `TtyOpenHandler` receives the channel's `Connection` (data-plane
//! ALPN `alk/tty`), calls `accept_bi()` to get the channel's //! ALPN `alk/tty`), calls `accept_bi()` to get the channel's
//! `BiStream`, and runs [`crate::adapter::drive_session_pre_negotiated`] //! `BiStream`, and runs the session pumps on it — the same three-pump
//! on it — the same three-pump session driver as the direct-ALPN path, //! session driver as the direct-ALPN path, minus the wire-frame
//! minus the wire-frame negotiation phase (the open op's `input` is //! negotiation phase (the open op's `input` is the negotiation,
//! the negotiation, ADR-009). Backend allocation stays in the pump //! ADR-009). Backend allocation runs in the establisher (alkcall 0.6
//! handler (`allocate_failed` still arrives in-band as a negotiation //! R-01: the allocated `TtyHandle` crosses via the `Establishment`
//! error frame — the establisher cannot carry the allocated handle //! plan payload); no negotiation error frame is ever written on the
//! across to the handler; ADR-010). //! channels path by a registered producer.
//! //!
//! The access control (scope-gate + ownership) is wired into the //! The access control (scope-gate + ownership) is wired into the
//! `OperationSpec`'s `AccessControl` and enforced by the registry's //! `OperationSpec`'s `AccessControl` and enforced by the registry's
@@ -37,10 +37,10 @@
//! [`ChannelCore`]: alkcall::channels::operations::ChannelCore //! [`ChannelCore`]: alkcall::channels::operations::ChannelCore
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::{Arc, Mutex};
use alkcall::channels::operations::{ use alkcall::channels::operations::{
ChannelCore, Establishment, EstablishmentError, OpenEstablisher, OpenHandler, ChannelCore, ChannelPlan, Establishment, EstablishmentError, OpenEstablisher, OpenHandler,
}; };
use alkcall::core::auth::AuthContext; use alkcall::core::auth::AuthContext;
use alkcall::core::ownership::OwnershipProvider; use alkcall::core::ownership::OwnershipProvider;
@@ -148,9 +148,10 @@ pub fn register_openable(
/// here would create a second definition to drift. The registry check /// here would create a second definition to drift. The registry check
/// catches structurally-broken opens (missing command, wrong types); /// catches structurally-broken opens (missing command, wrong types);
/// the full parse and semantic validation (`carriage == "raw"`, /// the full parse and semantic validation (`carriage == "raw"`,
/// backend lookup, ownership) runs in the establisher (ADR-010 — /// backend lookup, ownership) and backend allocation run in the
/// failures are `channel:open_failed`); `backend.allocate` runs in the /// establisher (ADR-010, as amended for alkcall 0.6 — failures are
/// pump handler. /// `channel:open_failed`, including `allocate_failed` as
/// `dial_failed`).
/// ///
/// The op declares an [`ErrorDefinition`] for `channel:open_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`
@@ -164,7 +165,7 @@ pub fn tty_open_spec() -> OperationSpec {
"properties": { "properties": {
"reason": { "reason": {
"type": "string", "type": "string",
"enum": ["unknown_resource", "handler_error", "timeout"] "enum": ["dial_failed", "unknown_resource", "handler_error", "timeout"]
}, },
"message": { "type": "string" } "message": { "type": "string" }
}, },
@@ -196,8 +197,9 @@ pub fn tty_open_spec() -> OperationSpec {
code: "channel:open_failed".to_string(), code: "channel:open_failed".to_string(),
description: "Establishment failed after channel allocation: the establisher \ description: "Establishment failed after channel allocation: the establisher \
rejected the negotiation (malformed negotiation, unknown backend, \ rejected the negotiation (malformed negotiation, unknown backend, \
ownership denial) or the establishment deadline expired. Details: \ ownership denial), the backend allocation failed (dial_failed), or \
{ reason, message }. No channel_id is returned." the establishment deadline expired. Details: { reason, message }. \
No channel_id is returned."
.to_string(), .to_string(),
schema: open_failed_schema, schema: open_failed_schema,
http_status: None, http_status: None,
@@ -226,11 +228,15 @@ pub fn tty_open_spec() -> OperationSpec {
/// full `NegotiateRequest` parse of the registry-schema-validated /// full `NegotiateRequest` parse of the registry-schema-validated
/// `input` (the schema is deliberately partial so the opaque ADR-053 /// `input` (the schema is deliberately partial so the opaque ADR-053
/// backend params pass through), `carriage == "raw"`, non-empty /// backend params pass through), `carriage == "raw"`, non-empty
/// `cmd`, the backend lookup, and the ADR-050 ownership check. Any /// `cmd`, the backend lookup, the ADR-050 ownership check, and —
/// rejection resolves `Err(EstablishmentError)` and the wrapper /// since alkcall 0.6 (ADR-049 amendment 2, review 007 R-01) gave
/// replies `channel:open_failed` with `details.reason` — the SSH /// `Establishment` its plan payload — the backend allocation
/// contract: the channel never exists opener-side, no `channel_id` is /// (ADR-010 as amended: the allocated `TtyHandle` crosses to the pump
/// ever returned. /// handler via the plan). Any rejection resolves
/// `Err(EstablishmentError)` and the wrapper replies
/// `channel:open_failed` with `details.reason` — the SSH contract:
/// the channel never exists opener-side, no `channel_id` is ever
/// 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 /// `AccessControl` enforced it before the establisher ran (the
@@ -249,14 +255,23 @@ pub fn tty_open_spec() -> OperationSpec {
/// - Ownership denial → [`EstablishmentError::HandlerError`] (an ACL /// - Ownership denial → [`EstablishmentError::HandlerError`] (an ACL
/// outcome, not a dial outcome; the registry's scope gate stays the /// outcome, not a dial outcome; the registry's scope gate stays the
/// pre-allocation `FORBIDDEN` path). /// 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
/// 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.
/// ///
/// Backend allocation deliberately does NOT happen here: the /// The allocation is handed to the pump handler through the
/// establisher has no way to hand the allocated `TtyHandle` to the /// establishment plan: a private `AllocatedHandle` slot (the
/// pump handler (`Establishment` is payloadless — reserved for a /// `TtyHandle` itself is not `Sync` — its fields are boxed dyn
/// channel plan), and a second `allocate` for the same open would /// streams — so it cannot be the `ChannelPlan` directly). The slot is
/// violate the kill-on-`Drop` contract (ADR-005). `allocate_failed` /// created per establisher invocation (per open), so the
/// therefore stays a post-open in-band negotiation error frame /// shared-slot same-resource race alkcall's R-01 test pins is
/// (ADR-010). /// unreachable by construction; the wrapper moves the plan to this
/// open's handler and the slot is `take`n exactly once. The
/// establisher-before-handler ordering is alkcall's wrapper
/// guarantee (ADR-049 §1).
fn make_tty_establisher( fn make_tty_establisher(
backends: Arc<HashMap<String, Arc<dyn TtyBackend>>>, backends: Arc<HashMap<String, Arc<dyn TtyBackend>>>,
ownership: Option<Arc<dyn OwnershipProvider>>, ownership: Option<Arc<dyn OwnershipProvider>>,
@@ -311,44 +326,77 @@ fn make_tty_establisher(
} }
} }
} }
Ok(Establishment {}) let handle = match backend.allocate(&params).await {
Ok(h) => h,
Err(e) => {
debug!("tty: channels establisher: allocation failed: {e}");
return Err(EstablishmentError::DialFailed {
message: e.to_string(),
});
}
};
Ok(Establishment::new(Arc::new(AllocatedHandle {
handle: Mutex::new(Some(handle)),
}) as ChannelPlan))
}) })
}) })
} }
/// The establishment plan payload for the channels path: the
/// `TtyHandle` the establisher allocated, behind a one-shot slot (the
/// handle's boxed dyn fields make the struct non-`Sync`, so it cannot
/// be the `ChannelPlan` directly). Created per open; `take`n by the
/// pump handler.
struct AllocatedHandle {
handle: Mutex<Option<crate::backend::TtyHandle>>,
}
impl AllocatedHandle {
fn take(&self) -> Option<crate::backend::TtyHandle> {
self.handle.lock().unwrap_or_else(|e| e.into_inner()).take()
}
}
/// Build the [`OpenHandler`] for `channels/tty/sub`. /// 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 by the establisher before the handler was spawned /// params, validated (and allocated against) by the establisher before
/// (alkcall ADR-049 §1: the establisher is awaited bounded, before the /// the handler was spawned (alkcall ADR-049 §1: the establisher is
/// reply and before the pump handler) — re-parses it, calls /// 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 /// `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 /// split the direct-ALPN `TtyAdapter::handle` does), and runs the
/// [`crate::adapter::drive_session_pre_negotiated`] on them — the same /// pre-allocated session driver on them — the same three-pump session
/// three-pump session driver as the direct-ALPN path. The establisher /// driver as the direct-ALPN path.
/// already rejected the semantic-failure classes (malformed params, ///
/// `carriage != "raw"`, unknown backend, ownership denial) as /// With a plan the handler never allocates: the establisher already
/// `channel:open_failed`, so the driver's `validate_and_allocate` pass /// ran `backend.allocate` (ADR-010 as amended), so no negotiation
/// on this path only re-runs the allocation — its negotiation error /// error frame is reachable on the registered path. With `plan: None`
/// frames remain reachable only for `allocate_failed` (the establisher /// (a no-establisher registration — defense-in-depth, the ADR-010
/// cannot carry the handle across — 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
/// failure surface for that registration shape.
///
/// 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
/// spawn-and-forget.
/// ///
/// The `tty:open` scope gate is enforced by the registry's `AccessControl` /// 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.
///
/// The handler's `JoinHandle` is recorded by the channels wrapper for
/// teardown (abort on `channel/close` / connection drop). When the
/// session ends (exit chunk sent, stream closed, or stream reset),
/// `drive_session_pre_negotiated` returns and the spawned task
/// completes; the wrapper's teardown task then calls
/// `manager.teardown_channel` and `policy.on_close` (ADR-047 §7).
fn make_tty_open_handler( fn make_tty_open_handler(
backends: Arc<HashMap<String, Arc<dyn TtyBackend>>>, backends: Arc<HashMap<String, Arc<dyn TtyBackend>>>,
identity: Option<alkcall::core::auth::Identity>, identity: Option<alkcall::core::auth::Identity>,
) -> OpenHandler { ) -> OpenHandler {
Arc::new( Arc::new(
move |input: Value, channel_conn: Connection, auth: AuthContext| { move |input: Value,
plan: Option<ChannelPlan>,
channel_conn: Connection,
auth: AuthContext| {
let backends = Arc::clone(&backends); let backends = Arc::clone(&backends);
let identity = identity.clone().or_else(|| auth.identity.clone()); let identity = identity.clone().or_else(|| auth.identity.clone());
@@ -382,15 +430,31 @@ fn make_tty_open_handler(
} }
}; };
let (client_read, client_write) = tokio::io::split(stream); let (client_read, client_write) = tokio::io::split(stream);
drive_session_pre_negotiated( let allocated = plan
client_write, .as_ref()
client_read, .and_then(|p| p.downcast_ref::<AllocatedHandle>())
req, .and_then(|slot| slot.take());
backends, match allocated {
None, Some(handle) => {
identity, crate::adapter::drive_session_pre_allocated(
) client_write,
.await; client_read,
handle,
)
.await;
}
None => {
drive_session_pre_negotiated(
client_write,
client_read,
req,
backends,
None,
identity,
)
.await;
}
}
}) })
}, },
) )
@@ -468,6 +532,7 @@ mod tests {
"cmd": ["true"], "cmd": ["true"],
"cwd": 42 "cwd": 42
}), }),
None,
channel_conn, channel_conn,
AuthContext::anonymous(b"test"), AuthContext::anonymous(b"test"),
); );
@@ -652,6 +717,62 @@ mod tests {
} }
} }
/// Allocation runs in the establisher (alkcall 0.6 — the
/// `TtyHandle` crosses to the pump handler via the `Establishment`
/// plan payload, ADR-010 as amended). Its failure maps to
/// `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]
async fn establisher_allocates_and_maps_failure_to_dial_failed() {
struct AllocFailBackend;
#[async_trait::async_trait]
impl TtyBackend for AllocFailBackend {
async fn allocate(
&self,
_params: &crate::backend::TtyParams,
) -> Result<crate::backend::TtyHandle, crate::backend::TtyError> {
Err(crate::backend::TtyError::AllocFailed {
message: "out of ptys".to_string(),
})
}
}
let mut failing: HashMap<String, Arc<dyn TtyBackend>> = HashMap::new();
failing.insert("mock".to_string(), Arc::new(AllocFailBackend));
let result = establisher_result(
Arc::new(failing),
None,
json!({ "carriage": "raw", "backend": "mock", "cmd": ["true"] }),
)
.await;
match result {
Err(e) => {
assert_eq!(e.reason(), "dial_failed");
assert!(e.message().contains("out of ptys"));
}
Ok(_) => panic!("allocation failure must be rejected by the establisher"),
}
let result = establisher_result(
mock_backends_one(),
None,
json!({ "carriage": "raw", "backend": "mock", "cmd": ["true"] }),
)
.await;
let establishment = result.expect("successful allocate carries the handle");
let slot = establishment
.plan
.as_ref()
.and_then(|p| p.downcast_ref::<AllocatedHandle>())
.expect("plan is the AllocatedHandle slot")
.take()
.expect("the allocated handle is in the slot");
// The handle is live: its exit_code resolves (MockBackend
// resolves immediately with the configured code).
let code = slot.exit_code.await.expect("exit_code resolves");
assert_eq!(code, 0);
}
/// E-02: the spec carries a description (disclosed by /// E-02: the spec carries a description (disclosed by
/// `services/list`) and declares the `channel:open_failed` error /// `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
@@ -672,11 +793,17 @@ mod tests {
.as_array() .as_array()
.expect("reason enum"); .expect("reason enum");
let reasons: Vec<&str> = enum_values.iter().filter_map(|v| v.as_str()).collect(); let reasons: Vec<&str> = enum_values.iter().filter_map(|v| v.as_str()).collect();
// The establisher produces only these three (dial_failed and // The establisher produces these four (resource_shortage has
// resource_shortage have no TTY producer path — ADR-010). // no TTY producer path — ADR-010; allocation failure maps to
// dial_failed, the amended mapping).
assert_eq!( assert_eq!(
reasons, reasons,
vec!["unknown_resource", "handler_error", "timeout"] vec![
"dial_failed",
"unknown_resource",
"handler_error",
"timeout"
]
); );
} }
+26 -16
View File
@@ -21,10 +21,11 @@
//! (ADR-009: the open op's `params` — validated by the registry's //! (ADR-009: the open op's `params` — validated by the registry's
//! input schema — *are* the negotiation; no second negotiation //! input schema — *are* the negotiation; no second negotiation
//! frame is written on the channel's data stream). Semantic //! frame is written on the channel's data stream). Semantic
//! establishment failures are rejected by the producer's establisher //! establishment failures — including allocation failure since
//! and surface as `channel:open_failed` call errors (alkcall 0.5.0 //! alkcall 0.6 — are rejected by the producer's establisher and
//! ADR-049 / alktty ADR-010); allocation failure stays in-band //! surface as `channel:open_failed` call errors (alkcall ADR-049 /
//! (see `open_via_channels`' docs). //! alktty ADR-010, as amended for the 0.6 plan payload; see
//! `open_via_channels`' docs).
//! //!
//! The session handle exposes: //! The session handle exposes:
//! - [`TtySession::send_stdin`] / [`TtySession::close_stdin`] — write //! - [`TtySession::send_stdin`] / [`TtySession::close_stdin`] — write
@@ -118,7 +119,12 @@ pub enum TtySessionError {
InvalidParams(String), InvalidParams(String),
/// The server sent a negotiation error frame (the first frame on /// The server sent a negotiation error frame (the first frame on
/// the stream is a length-prefixed JSON `{"error":"..."}` rather /// the stream is a length-prefixed JSON `{"error":"..."}` rather
/// than a raw chunk). /// than a raw chunk). On the channels path this is
/// defense-in-depth only — a registered producer rejects every
/// failure class (including allocation, since alkcall 0.6) in the
/// 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}")] #[error("negotiation rejected: {error}")]
NegotiationRejected { NegotiationRejected {
error: String, error: String,
@@ -239,12 +245,14 @@ impl TtySession {
/// [`TtySessionError::ChannelsOpen`] wrapping a `CallFailed` whose /// [`TtySessionError::ChannelsOpen`] wrapping a `CallFailed` whose
/// `CallError` is `channel:open_failed` with /// `CallError` is `channel:open_failed` with
/// `details.reason` (alkcall 0.5.0 ADR-049; the SSH contract: no /// `details.reason` (alkcall 0.5.0 ADR-049; the SSH contract: no
/// `channel_id` is ever returned). The one post-open failure class /// `channel_id` is ever returned). Since alkcall 0.6 (ADR-010 as
/// that still arrives in-band is allocation failure /// amended — allocation moved into the establisher, the allocated
/// (`allocate_failed`): it surfaces as /// handle crossing via the `Establishment` plan payload) no
/// [`TtySessionError::NegotiationRejected`] via the `0x00` /// failure class arrives in-band on the channels path: allocation
/// disambiguation read (the establisher cannot carry the allocated /// failure surfaces as `channel:open_failed` with
/// backend handle across to the pump handler — ADR-010). /// `details.reason == "dial_failed"`, and the `0x00`
/// disambiguation read is a keep-the-pump formality (a registered
/// producer never writes an error frame first).
pub async fn open_via_channels( pub async fn open_via_channels(
client: &ChannelClient, client: &ChannelClient,
params: serde_json::Value, params: serde_json::Value,
@@ -338,11 +346,13 @@ impl TtySession {
/// Core inner for the channels path (ADR-009): the negotiation /// 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: the producer sends a /// raw-chunk mode. The peek still applies as defense-in-depth: a
/// `0x00`-prefixed error frame on any post-open failure (a /// `0x00`-prefixed error frame means the producer rejected the
/// `NegotiateRequest` parse failure of the open op's `input`, /// session post-open (only reachable from a no-establisher
/// unknown backend, allocate failure, ownership denial), and a raw /// registration — the establisher rejects every semantic class
/// chunk (`stream_type` in `{1, 2, 4}`) on success. /// 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.
async fn from_halves_raw<R, W>(read: R, write: W) -> Result<Self, TtySessionError> async fn from_halves_raw<R, W>(read: R, write: W) -> Result<Self, TtySessionError>
where where
R: AsyncRead + Send + Unpin + 'static, R: AsyncRead + Send + Unpin + 'static,
+34 -34
View File
@@ -264,17 +264,17 @@ mod tests {
); );
} }
/// Post-ADR-010, the one failure class that still arrives in-band on /// Post-alkcall-0.6 (ADR-010 as amended): allocation failure no
/// the channels path is allocation failure (`allocate_failed`): the /// longer arrives in-band. `backend.allocate` runs in the
/// establisher cannot carry the allocated `TtyHandle` across to the /// establisher (the allocated `TtyHandle` crosses to the pump
/// pump handler (ADR-010), so `backend.allocate` runs in the handler /// handler via the `Establishment` plan payload), so its failure
/// and its failure surfaces as a `0x00`-prefixed /// fails the open op itself — `channel:open_failed` with
/// `allocate_failed` negotiation error frame via the consumer's /// `details.reason == "dial_failed"` — and no channel survives
/// `from_halves_raw` peek. Pins the boundary of the establisher /// (the SSH contract: no `channel_id`, no `0x00`-prefixed
/// migration. /// in-band frame on a channel that was reported as succeeding).
#[tokio::test] #[tokio::test]
async fn allocate_failure_still_arrives_in_band_on_channels_path() { async fn allocate_failure_fails_open_as_dial_failed() {
use tokio::io::AsyncReadExt; use alkcall::channels::client::ChannelOpenError;
struct AllocFailBackend; struct AllocFailBackend;
@@ -295,7 +295,7 @@ mod tests {
let client = let client =
wire_client_and_server(Arc::new(backends), None, Some(tty_identity("alice"))).await; wire_client_and_server(Arc::new(backends), None, Some(tty_identity("alice"))).await;
let (_channel_id, _send, mut recv) = tokio::time::timeout( let result = tokio::time::timeout(
std::time::Duration::from_secs(5), std::time::Duration::from_secs(5),
client.open_channel( client.open_channel(
OP_TTY_OPEN, OP_TTY_OPEN,
@@ -304,28 +304,28 @@ mod tests {
), ),
) )
.await .await
.expect("open_channel timed out") .expect("open_channel timed out");
.expect("open_channel — establishment succeeds, allocation is post-open"); match result {
Err(ChannelOpenError::CallFailed { error }) => {
// The consumer-side post-open sequence (`from_halves_raw`): assert_eq!(error.code, "channel:open_failed");
// peek the first byte; `0x00` = negotiation error frame. let details = error.details.expect("details carry the reason");
let mut first = [0u8; 1]; assert_eq!(details["reason"], "dial_failed");
tokio::time::timeout( assert!(
std::time::Duration::from_secs(5), details["message"]
recv.read_exact(&mut first), .as_str()
) .is_some_and(|m| m.contains("out of ptys")),
.await "message carries the backend allocation failure detail, got {details}"
.expect("no response byte from producer") );
.expect("read first byte"); }
assert_eq!(first[0], 0x00, "error frame length prefix starts with 0x00"); Ok(_) => panic!("expected channel:open_failed, got Ok(channel)"),
Err(other) => panic!("expected CallFailed(channel:open_failed), got {other:?}"),
let mut len_rest = [0u8; 3]; }
recv.read_exact(&mut len_rest).await.expect("read len rest"); // The SSH contract: no data channel survives a failed
let len = u32::from_be_bytes([first[0], len_rest[0], len_rest[1], len_rest[2]]) as usize; // establishment (channel 0 is the call channel), and no
let mut body = vec![0u8; len]; // in-band frame was ever written (the channel never existed).
recv.read_exact(&mut body).await.expect("read error body"); assert!(
let v: serde_json::Value = serde_json::from_slice(&body).expect("parse error frame"); client.manager().channel_ids().iter().all(|&id| id == 0),
assert_eq!(v["error"], "allocate_failed"); "no channel survives a failed allocation"
assert!(v["message"].as_str().is_some_and(|m| !m.is_empty())); );
} }
} }