7 Commits
Author SHA1 Message Date
glm-5.3-flash e95459c425 feat: alkcall 0.8.0 adoption — bump to 0.5.0
- BREAKING (ecosystem-coordination): alkcall bumped to 0.8.0. No type
  alktty touches changed shape (ChannelCore/openable machinery,
  OpenEstablisher/OpenHandler/Establishment/ChannelPlan,
  ChannelClient::open_channel, ChannelOpenError::CallFailed are
  identical to 0.7.1); the new reply-projection / 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 new
  flavor-form discovery path stays on the byte-stable standard
  derivation, and with no establisher reply-fields configured the open
  reply is byte-identical to pre-0.8.0. The minor bump is for
  downstream lockstep, since alktty's public signatures reference
  alkcall types.
- Verification counts unchanged from the 0.7.1 baseline (113 default /
  156 all-features); the adoption required no source change in alktty.

Verification:
- cargo test: 113 passed (default), 156 passed (--all-features)
- cargo clippy --all-targets -- -D warnings: clean
- cargo fmt --check: clean
- cargo doc --no-deps: clean
- cargo check/clippy --target wasm32-unknown-unknown -D warnings: clean
- cargo publish --dry-run --allow-dirty: clean
2026-09-18 13:55:53 +00:00
glm-5.3-flash d14941ffb1 chore: bump to 0.4.1, changelog for the MSRV floor raise (1.85 -> 1.88)
Verification: 113 default / 156 all-features tests on stable (1.94) and
on a real 1.88 toolchain, clippy (all-targets) clean, fmt clean, doc
clean, wasm32 check + clippy clean, publish dry-run OK.
2026-09-10 02:55:11 +00:00
glm-5.3-flash 01e94914c1 chore: raise rust-version floor to 1.88
The 1.85 claim is now false at the dependency level: the lockfile pins
alkcall 0.7.x, and alkcall 0.7.1 raised its MSRV floor to 1.88, so no
1.85 toolchain can resolve this dependency graph regardless of what
Cargo.toml declares. Raising the floor keeps the claim honest and
aligns with the ecosystem floor (noq's 1.88, matching the QUIC path;
iroh sits above at 1.91). alktty's own code is 1.85-clean (verified:
cargo check under 1.85 passes with the pre-bump lockfile) — the raise
is dependency-driven only, so no clippy fixes are needed.

- Cargo.toml: rust-version 1.85 -> 1.88 (version-line bump deferred to
  release time)

Verification:
- 1.88: cargo test --locked (113 default / 156 all-features pass),
  clippy --all-targets -D warnings
- stable 1.94: test --locked, clippy --all-targets -D warnings,
  fmt, doc --no-deps, wasm32-unknown-unknown check + clippy clean
2026-09-10 02:53:33 +00:00
glm-5.3-flash 32809af840 docs: fill changelog version link refs (0.4.0/0.3.0/0.2.0) 2026-09-07 18:07:13 +00:00
glm-5.3-flash 67cf253621 feat: alkcall 0.7.0 adoption (CF-006 identity alignment) — bump to 0.4.0
- BREAKING (ecosystem-coordination): alkcall bumped to 0.7.0. No type
  alktty touches changed shape (OpenEstablisher/OpenHandler/
  Establishment/ChannelPlan identical to 0.6.0); the minor bump is for
  downstream lockstep, since alktty's public signatures reference
  alkcall types.
- CF-006 alignment: the channels establisher and pump handler now take
  their identity from the per-call auth (alkcall 0.7 derives the
  dispatch-resolved opener — the same identity view the registry's ACL
  gate checked) instead of the captured install-time override.
  Behavior-identical in the per-connection-registry deployment; the
  ownership check and the ACL gate see the same subject in every
  deployment. make_tty_establisher / make_tty_open_handler drop their
  captured-identity parameter (both private).

Verification:
- cargo test: 113 passed (default), 156 passed (--all-features)
- cargo clippy --all-targets -- -D warnings: clean
- cargo fmt --check: clean
- cargo doc --no-deps: clean
- cargo check/clippy --target wasm32-unknown-unknown -D warnings: clean
- cargo publish --dry-run --allow-dirty: clean
2026-09-07 17:57:36 +00:00
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 564 additions and 175 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
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; the pump handler
only allocates and pumps). Terminal sessions are resources per
`channel:open_failed`, not an in-band frame; since alkcall 0.6.0
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,
identity, resource_type, resource_id)` is the 3-arg shape (alkcall
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 —
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.5.0"` (the
channel-open establishment phase — ADR-049, review 006 E-01/N-1;
adopted by alktty ADR-010) and bump deliberately.
working around them. Pin `alkcall = "0.6.0"` (the
establishment-plan payload — ADR-049 amendment 2, review 007 R-01;
adopted by alktty ADR-010 §2A — allocation runs in the channels
establisher, the `TtyHandle` crosses via the plan) and bump
deliberately.
+127
View File
@@ -4,6 +4,128 @@ 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
this crate adheres to [Semantic Versioning](https://semver.org/).
## [0.5.0] - 2026-09-18
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
post-landing hardening sweep; all new surfaces are additive on the
call-plane JSON and the registry seams, and the channels data plane is
untouched. 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.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
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
new flavor-form `channel_open_alpn` discovery path stays on the
byte-stable standard derivation, and with no establisher
reply-fields configured the open reply is byte-identical to
pre-0.8.0. alktty's public signatures reference alkcall types, so
downstream crates must now unify on alkcall 0.8.0. Minor bump per
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
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
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
regardless of what Cargo.toml declares. The raise breaks no downstream
that could build the crate before. No API or wire-format change;
downstreams on caret requirements pick this up on their next `cargo
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
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
resolved family); alkcall 0.7.1 changed no type alktty touches.
## [0.4.0] - 2026-09-07
The alkcall 0.7.0 adoption (the connect-side caller-identity seam,
CF-005/006/007): a dependency bump plus one behavior-alignment change
on the channels path. The wire format, the direct-ALPN path, the
`TtyBackend` trait, and the public API surface are unchanged.
### Changed
- **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
alktty's public signatures reference alkcall types, so downstream
crates must now unify on alkcall 0.7.0. Minor bump per the 0.x
ecosystem-coordination rule.
- **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
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
per-connection-registry deployment (install-time identity was the
caller's); the ownership check and the ACL gate now see the same
subject in every deployment. Two factory parameters dropped
(`make_tty_establisher`/`make_tty_open_handler` are private; no
public-surface change).
## [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
The alkcall 0.5.0 adoption (ADR-049 — review 006 E-01/N-1): the
@@ -199,4 +321,9 @@ alknet mono-repo.
the client→backend pump is aborted at session end instead of
lingering until the client disconnects.
[0.5.0]: https://git.alk.dev/alkdev/alktty/releases/tag/v0.5.0
[0.4.1]: https://git.alk.dev/alkdev/alktty/releases/tag/v0.4.1
[0.4.0]: https://git.alk.dev/alkdev/alktty/releases/tag/v0.4.0
[0.3.0]: https://git.alk.dev/alkdev/alktty/releases/tag/v0.3.0
[0.2.0]: https://git.alk.dev/alkdev/alktty/releases/tag/v0.2.0
[0.1.0]: https://git.alk.dev/alkdev/alktty/releases/tag/v0.1.0
Generated
+3 -3
View File
@@ -27,9 +27,9 @@ dependencies = [
[[package]]
name = "alkcall"
version = "0.5.0"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bbaeb718c370b74f9136d1d59b4608249bd7d1dd2332c0259f98351d709737f"
checksum = "8695d47cee5b5a5fe68471f04a505bc257a7e7ed9e64d827de33ff83adf4db12"
dependencies = [
"async-trait",
"bytes",
@@ -48,7 +48,7 @@ dependencies = [
[[package]]
name = "alktty"
version = "0.2.0"
version = "0.5.0"
dependencies = [
"alkcall",
"async-trait",
+3 -3
View File
@@ -1,8 +1,8 @@
[package]
name = "alktty"
version = "0.2.0"
version = "0.5.0"
edition = "2021"
rust-version = "1.85"
rust-version = "1.88"
license = "MIT OR Apache-2.0"
description = "Terminal session protocol: wire format, TtyBackend trait, TtyAdapter, and typed consumer client. Producer/consumer protocol crate on top of alkcall channels."
repository = "https://git.alk.dev/alkdev/alktty"
@@ -25,7 +25,7 @@ default = []
local = ["dep:portable-pty", "dep:tokio-util", "tokio/process", "tokio/rt-multi-thread"]
[dependencies]
alkcall = "0.5.0"
alkcall = "0.8.0"
# Minimal, wasm-clean tokio features. `local` adds `process` +
# `rt-multi-thread` (non-wasm). Do NOT use `features = ["full"]` — it
# 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) |
| [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 — 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
@@ -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
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
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`,
`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
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` |
| unknown backend | `UnknownResource` | `unknown_resource` |
| ownership denial | `HandlerError` | `handler_error` |
| **allocation failure** (amended — §2A) | `DialFailed` | `dial_failed` |
| establishment deadline | (wrapper) | `timeout` |
`dial_failed` and `resource_shortage` have no TTY producer path (TTY
dials nothing at open time; `allocate` capacity failures are the
in-band class below) — the spec's `ErrorDefinition` for
`channel:open_failed` declares only the three reachable reasons.
`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).
`resource_shortage` has no TTY producer path (allocation-capacity
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
> **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
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
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
`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 })` |
| 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 })`** |
| 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 })` |
| **allocate failure** | pump handler, post-open | **`NegotiationRejected{ "allocate_failed" }`** |
`TtySessionError::ChannelsOpen` now carries alkcall's typed
`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
count, and manager state balance on every rejection.
- Retry policy / UX can branch: `unknown_resource` (bad config — don't
retry), `timeout` (maybe retry), `allocate_failed` (capacity —
still distinguishable in-band).
retry), `timeout` (maybe retry), `dial_failed` (capacity/target
refused — maybe retry; amended §2A, was the in-band
`allocate_failed`).
- 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:**
@@ -180,9 +257,11 @@ 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).
- `allocate_failed` remains a second, in-band failure shape on the
channels path — one residual asymmetry, pinned and documented until
`Establishment` gains a payload.
- **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
unchanged (two transports, two contracts).
## 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
amendment's in-band error frames — shrunk to `allocate_failed` by
this ADR)
- ADR-005 (kill-on-`Drop` — why establisher-side allocation is
unsound)
- 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
path and the channels `allocate_failed` frame)
path; the channels-path peek is now a formality)
- ADR-002 (backend selection/allocation ownership)
- `src/channels.rs` — `make_tty_establisher`, `make_tty_open_handler`,
`tty_open_spec`
- 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`,
`make_tty_open_handler`, `tty_open_spec`
- `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
negotiation/allocation failure class (below). On the **channels path**
the semantic classes (unknown backend, malformed negotiation,
ownership denial) are rejected by the establisher before the open
reply and surface as `channel:open_failed` call errors (alkcall 0.5.0
ADR-049 / alktty ADR-010) — the in-band frames on that path are
defense-in-depth arms plus the one non-migratable class
(`allocate_failed`, ADR-010 §2).
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
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` — 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
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 |
| 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`) |
| 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 |
## Open Questions
@@ -394,8 +395,9 @@ architectural commitment.
registry-validated `input` is the negotiation)
- [ADR-010](decisions/010-channels-establisher-migration.md) — the
channels path's semantic failures are establisher call errors
(`channel:open_failed`); the in-band frames shrink to
`allocate_failed`
(`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
declares against
- 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
to start, the SSH channel request was rejected. Returned by
`allocate()`; the adapter sends `{"error":"allocate_failed",...}` and
closes (tty-adapter.md §"Negotiation errors").
`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
exit code. Returned by the `exit_code` future; the adapter sends
`{"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
/// ADR-050 ownership check, and allocate. Shared by the direct
/// (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)]
pub enum TtyError {
/// The PTY couldn't be allocated, the docker exec failed to start, the
/// SSH channel request was rejected. Returned by `allocate()`; the
/// adapter sends `{"error":"allocate_failed",...}` and closes.
/// SSH channel request was rejected. Returned by `allocate()`; on the
/// 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}")]
AllocFailed { message: String },
/// The backend couldn't reap the child / determine the exit code.
+206 -80
View File
@@ -18,13 +18,13 @@
//! `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 [`crate::adapter::drive_session_pre_negotiated`]
//! 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 stays in the pump
//! handler (`allocate_failed` still arrives in-band as a negotiation
//! error frame — the establisher cannot carry the allocated handle
//! across to the handler; ADR-010).
//! `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
//! R-01: the allocated `TtyHandle` crosses via the `Establishment`
//! plan payload); no negotiation error frame is ever written on the
//! channels path by a registered producer.
//!
//! The access control (scope-gate + ownership) is wired into the
//! `OperationSpec`'s `AccessControl` and enforced by the registry's
@@ -37,10 +37,10 @@
//! [`ChannelCore`]: alkcall::channels::operations::ChannelCore
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use alkcall::channels::operations::{
ChannelCore, Establishment, EstablishmentError, OpenEstablisher, OpenHandler,
ChannelCore, ChannelPlan, Establishment, EstablishmentError, OpenEstablisher, OpenHandler,
};
use alkcall::core::auth::AuthContext;
use alkcall::core::ownership::OwnershipProvider;
@@ -98,10 +98,12 @@ pub const TTY_ALPN: &str = "alk/tty";
/// the entry point (here by the registry's `AccessControl`), the
/// ownership check is enforced once at the backend-selection point.
///
/// `auth` is the peer's `AuthContext`, captured at
/// `install_channel_zero` time and closed over by the wrapper so the
/// `TtyOpenHandler` receives it without the wrapper having to reach
/// into `OperationContext` for it (per the ADR-047 §4 amendment).
/// `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
/// dispatch-resolved opener, the same view the registry's ACL gate
/// checked.
///
/// # Errors
///
@@ -117,8 +119,8 @@ pub fn register_openable(
auth: AuthContext,
) -> Result<(), String> {
let spec = tty_open_spec();
let establisher = make_tty_establisher(Arc::clone(&backends), ownership, auth.identity.clone());
let open_handler = make_tty_open_handler(backends, auth.identity.clone());
let establisher = make_tty_establisher(Arc::clone(&backends), ownership);
let open_handler = make_tty_open_handler(backends);
core.register_openable_with_establisher(
spec,
Some(establisher),
@@ -148,9 +150,10 @@ pub fn register_openable(
/// 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) runs in the establisher (ADR-010 —
/// failures are `channel:open_failed`); `backend.allocate` runs in the
/// pump handler.
/// backend lookup, ownership) and backend allocation run in the
/// 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`
@@ -164,7 +167,7 @@ pub fn tty_open_spec() -> OperationSpec {
"properties": {
"reason": {
"type": "string",
"enum": ["unknown_resource", "handler_error", "timeout"]
"enum": ["dial_failed", "unknown_resource", "handler_error", "timeout"]
},
"message": { "type": "string" }
},
@@ -196,8 +199,9 @@ pub fn tty_open_spec() -> OperationSpec {
code: "channel:open_failed".to_string(),
description: "Establishment failed after channel allocation: the establisher \
rejected the negotiation (malformed negotiation, unknown backend, \
ownership denial) or the establishment deadline expired. Details: \
{ reason, message }. No channel_id is returned."
ownership denial), the backend allocation failed (dial_failed), or \
the establishment deadline expired. Details: { reason, message }. \
No channel_id is returned."
.to_string(),
schema: open_failed_schema,
http_status: None,
@@ -226,18 +230,23 @@ 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, and the ADR-050 ownership check. 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.
/// `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
/// (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:
/// the channel never exists opener-side, no `channel_id` is ever
/// returned.
///
/// The `tty:open` scope gate is NOT checked here — the registry's
/// `AccessControl` enforced it before the establisher ran (the
/// identity view the registry checked, resolved from the connection,
/// is the authoritative one; the establisher-side identity is only
/// the ownership-check subject — the same split as the old
/// handler-side check).
/// `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
/// ownership-check subject (the same split as the old handler-side
/// check).
///
/// Mapping into the ADR-049 reason vocabulary (no new codes — the
/// vocabulary is alkcall's one-way wire surface):
@@ -249,23 +258,31 @@ pub fn tty_open_spec() -> OperationSpec {
/// - 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
/// 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
/// establisher has no way to hand the allocated `TtyHandle` to the
/// pump handler (`Establishment` is payloadless — reserved for a
/// channel plan), and a second `allocate` for the same open would
/// violate the kill-on-`Drop` contract (ADR-005). `allocate_failed`
/// therefore stays a post-open in-band negotiation error frame
/// (ADR-010).
/// 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
/// 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
/// 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(
backends: Arc<HashMap<String, Arc<dyn TtyBackend>>>,
ownership: Option<Arc<dyn OwnershipProvider>>,
identity: Option<alkcall::core::auth::Identity>,
) -> OpenEstablisher {
Arc::new(move |input: Value, auth: AuthContext| {
let backends = Arc::clone(&backends);
let ownership = ownership.clone();
let identity = identity.clone().or_else(|| auth.identity.clone());
let identity = auth.identity.clone();
Box::pin(async move {
let req: NegotiateRequest = match serde_json::from_value(input) {
Ok(r) => r,
@@ -311,46 +328,76 @@ 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`.
///
/// The handler receives the open op's `input` — the `NegotiateRequest`
/// params, validated 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) — re-parses it, calls
/// 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) —
/// 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
/// split the direct-ALPN `TtyAdapter::handle` does), and runs
/// [`crate::adapter::drive_session_pre_negotiated`] on them — the same
/// three-pump session driver as the direct-ALPN path. The establisher
/// already rejected the semantic-failure classes (malformed params,
/// `carriage != "raw"`, unknown backend, ownership denial) as
/// `channel:open_failed`, so the driver's `validate_and_allocate` pass
/// on this path only re-runs the allocation — its negotiation error
/// frames remain reachable only for `allocate_failed` (the establisher
/// cannot carry the handle across — ADR-010).
/// split the direct-ALPN `TtyAdapter::handle` does), and runs the
/// 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
/// 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`
/// 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(
backends: Arc<HashMap<String, Arc<dyn TtyBackend>>>,
identity: Option<alkcall::core::auth::Identity>,
) -> OpenHandler {
fn make_tty_open_handler(backends: Arc<HashMap<String, Arc<dyn TtyBackend>>>) -> OpenHandler {
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 identity = identity.clone().or_else(|| auth.identity.clone());
let identity = auth.identity.clone();
tokio::spawn(async move {
let req: NegotiateRequest = match serde_json::from_value(input) {
@@ -382,15 +429,31 @@ fn make_tty_open_handler(
}
};
let (client_read, client_write) = tokio::io::split(stream);
drive_session_pre_negotiated(
client_write,
client_read,
req,
backends,
None,
identity,
)
.await;
let allocated = plan
.as_ref()
.and_then(|p| p.downcast_ref::<AllocatedHandle>())
.and_then(|slot| slot.take());
match allocated {
Some(handle) => {
crate::adapter::drive_session_pre_allocated(
client_write,
client_read,
handle,
)
.await;
}
None => {
drive_session_pre_negotiated(
client_write,
client_read,
req,
backends,
None,
identity,
)
.await;
}
}
})
},
)
@@ -459,7 +522,7 @@ mod tests {
let mut backends: HashMap<String, Arc<dyn TtyBackend>> = HashMap::new();
backends.insert("mock".to_string(), Arc::new(MockBackend::with_exit_code(0)));
let handler = make_tty_open_handler(Arc::new(backends), None);
let handler = make_tty_open_handler(Arc::new(backends));
let task = handler(
serde_json::json!({
@@ -468,6 +531,7 @@ mod tests {
"cmd": ["true"],
"cwd": 42
}),
None,
channel_conn,
AuthContext::anonymous(b"test"),
);
@@ -512,7 +576,7 @@ mod tests {
ownership: Option<Arc<dyn OwnershipProvider>>,
input: Value,
) -> Result<Establishment, EstablishmentError> {
let establisher = make_tty_establisher(backends, ownership, None);
let establisher = make_tty_establisher(backends, ownership);
establisher(input, AuthContext::anonymous(b"test")).await
}
@@ -652,6 +716,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
/// `services/list`) and declares the `channel:open_failed` error
/// schema (ADR-016 — the establishment-failure contract is
@@ -672,11 +792,17 @@ mod tests {
.as_array()
.expect("reason enum");
let reasons: Vec<&str> = enum_values.iter().filter_map(|v| v.as_str()).collect();
// The establisher produces only these three (dial_failed and
// resource_shortage have no TTY producer path — ADR-010).
// The establisher produces these four (resource_shortage has
// no TTY producer path — ADR-010; allocation failure maps to
// dial_failed, the amended mapping).
assert_eq!(
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
//! input schema — *are* the negotiation; no second negotiation
//! frame is written on the channel's data stream). Semantic
//! establishment failures are rejected by the producer's establisher
//! and surface as `channel:open_failed` call errors (alkcall 0.5.0
//! ADR-049 / alktty ADR-010); allocation failure stays in-band
//! (see `open_via_channels`' docs).
//! 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
@@ -118,7 +119,12 @@ pub enum TtySessionError {
InvalidParams(String),
/// The server sent a negotiation error frame (the first frame on
/// 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}")]
NegotiationRejected {
error: String,
@@ -239,12 +245,14 @@ impl TtySession {
/// [`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). The one post-open failure class
/// that still arrives in-band is allocation failure
/// (`allocate_failed`): it surfaces as
/// [`TtySessionError::NegotiationRejected`] via the `0x00`
/// disambiguation read (the establisher cannot carry the allocated
/// backend handle across to the pump handler — ADR-010).
/// `channel_id` is ever returned). Since alkcall 0.6 (ADR-010 as
/// 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
/// `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(
client: &ChannelClient,
params: serde_json::Value,
@@ -338,11 +346,13 @@ impl TtySession {
/// Core inner for the channels path (ADR-009): the negotiation
/// already happened in the open op — the stream is already in
/// raw-chunk mode. The peek still applies: the producer sends a
/// `0x00`-prefixed error frame on any post-open failure (a
/// `NegotiateRequest` parse failure of the open op's `input`,
/// unknown backend, allocate failure, ownership denial), and a raw
/// chunk (`stream_type` in `{1, 2, 4}`) on success.
/// 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
/// 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>
where
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
/// the channels path is allocation failure (`allocate_failed`): the
/// establisher cannot carry the allocated `TtyHandle` across to the
/// pump handler (ADR-010), so `backend.allocate` runs in the handler
/// and its failure surfaces as a `0x00`-prefixed
/// `allocate_failed` negotiation error frame via the consumer's
/// `from_halves_raw` peek. Pins the boundary of the establisher
/// migration.
/// Post-alkcall-0.6 (ADR-010 as amended): allocation failure no
/// 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
/// (the SSH contract: no `channel_id`, no `0x00`-prefixed
/// in-band frame on a channel that was reported as succeeding).
#[tokio::test]
async fn allocate_failure_still_arrives_in_band_on_channels_path() {
use tokio::io::AsyncReadExt;
async fn allocate_failure_fails_open_as_dial_failed() {
use alkcall::channels::client::ChannelOpenError;
struct AllocFailBackend;
@@ -295,7 +295,7 @@ mod tests {
let client =
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),
client.open_channel(
OP_TTY_OPEN,
@@ -304,28 +304,28 @@ mod tests {
),
)
.await
.expect("open_channel timed out")
.expect("open_channel — establishment succeeds, allocation is post-open");
// The consumer-side post-open sequence (`from_halves_raw`):
// peek the first byte; `0x00` = negotiation error frame.
let mut first = [0u8; 1];
tokio::time::timeout(
std::time::Duration::from_secs(5),
recv.read_exact(&mut first),
)
.await
.expect("no response byte from producer")
.expect("read first byte");
assert_eq!(first[0], 0x00, "error frame length prefix starts with 0x00");
let mut len_rest = [0u8; 3];
recv.read_exact(&mut len_rest).await.expect("read len rest");
let len = u32::from_be_bytes([first[0], len_rest[0], len_rest[1], len_rest[2]]) as usize;
let mut body = vec![0u8; len];
recv.read_exact(&mut body).await.expect("read error body");
let v: serde_json::Value = serde_json::from_slice(&body).expect("parse error frame");
assert_eq!(v["error"], "allocate_failed");
assert!(v["message"].as_str().is_some_and(|m| !m.is_empty()));
.expect("open_channel timed out");
match result {
Err(ChannelOpenError::CallFailed { error }) => {
assert_eq!(error.code, "channel:open_failed");
let details = error.details.expect("details carry the reason");
assert_eq!(details["reason"], "dial_failed");
assert!(
details["message"]
.as_str()
.is_some_and(|m| m.contains("out of ptys")),
"message carries the backend allocation failure detail, got {details}"
);
}
Ok(_) => panic!("expected channel:open_failed, got Ok(channel)"),
Err(other) => panic!("expected CallFailed(channel:open_failed), got {other:?}"),
}
// The SSH contract: no data channel survives a failed
// establishment (channel 0 is the call channel), and no
// in-band frame was ever written (the channel never existed).
assert!(
client.manager().channel_ids().iter().all(|&id| id == 0),
"no channel survives a failed allocation"
);
}
}