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
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
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
|
||||
|
||||
The alkcall 0.5.0 adoption (ADR-049 — review 006 E-01/N-1): the
|
||||
|
||||
Generated
+3
-3
@@ -27,9 +27,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "alkcall"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bbaeb718c370b74f9136d1d59b4608249bd7d1dd2332c0259f98351d709737f"
|
||||
checksum = "a44500900b82904d2a870e444051fc041a042e42ff7190803bbc5b3973f4feeb"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
@@ -48,7 +48,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "alktty"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"alkcall",
|
||||
"async-trait",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "alktty"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.85"
|
||||
license = "MIT OR Apache-2.0"
|
||||
@@ -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.6.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`.
|
||||
|
||||
@@ -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(¶ms)`. Failure maps to
|
||||
`EstablishmentError::DialFailed` (`details.reason == "dial_failed"`
|
||||
— the target refused or the backend lacked capacity; one string
|
||||
message, no nuance — the same granularity the direct path's
|
||||
`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
|
||||
@@ -170,7 +245,8 @@ on `establishment_reason()` without unwrapping strings.
|
||||
retry), `timeout` (maybe retry), `allocate_failed` (capacity —
|
||||
still distinguishable in-band).
|
||||
- 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 +256,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 +283,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`)
|
||||
@@ -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-
|
||||
|
||||
@@ -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
|
||||
|
||||
+187
-60
@@ -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;
|
||||
@@ -148,9 +148,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 +165,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 +197,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,11 +228,15 @@ 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
|
||||
@@ -249,14 +255,23 @@ 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>>,
|
||||
@@ -311,44 +326,77 @@ fn make_tty_establisher(
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Establishment {})
|
||||
let handle = match backend.allocate(¶ms).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 {
|
||||
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());
|
||||
|
||||
@@ -382,15 +430,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;
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -468,6 +532,7 @@ mod tests {
|
||||
"cmd": ["true"],
|
||||
"cwd": 42
|
||||
}),
|
||||
None,
|
||||
channel_conn,
|
||||
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
|
||||
/// `services/list`) and declares the `channel:open_failed` error
|
||||
/// schema (ADR-016 — the establishment-failure contract is
|
||||
@@ -672,11 +793,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
@@ -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
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user