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:
glm-5.3-flash committed 2026-09-07 09:53:57 +00:00
1 parent 3352a02dd5
commit af9dbaedea
10 files changed
+434 -147

No files matched your search

+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"
);
}
}