feat: channels-path establisher migration (alkcall 0.5.0 / ADR-049) — bump to 0.2.0
Adopt alkcall 0.5.0's channel-open establishment phase (ADR-049 — review 006 E-01 + N-1) and migrate the channels-path semantic failures per its §5 sequencing (alktty ADR-010). - `register_openable` registers `channels/tty/sub` with an establisher (`register_openable_with_establisher`): full `NegotiateRequest` parse of schema-valid `input`, `carriage == "raw"`, non-empty `cmd`, backend lookup, and the ADR-050 ownership check run before the open reply; rejections are `channel:open_failed` with `details.reason` (`unknown_resource` / `handler_error` / `timeout`) — no phantom channel (the SSH contract holds consumer-visibly) - `backend.allocate` deliberately stays in the pump handler: `Establishment` is payloadless so the `TtyHandle` cannot cross the establisher→handler boundary, and re-allocating would violate ADR-005's kill-on-Drop contract — `allocate_failed` remains the one in-band failure class on the channels path (pinned by test) - `TtySessionError::ChannelsOpen` carries alkcall's typed `ChannelOpenError` (`#[from]`) instead of a flattened `String` — the N-1 fix at alktty's layer (breaking) - channels-path semantic failures change shape from `NegotiationRejected` in-band frames to `channel:open_failed` call errors (breaking); the direct-ALPN path is unchanged - `tty_open_spec()` gains a `description` (review 006 E-02) and an ErrorDefinition for `channel:open_failed` (ADR-016 — disclosed via services/schema) - alkcall = "0.5.0"; version 0.2.0; ADR-010 + ADR-009 amendment + tty-adapter.md + CHANGELOG Verification: cargo test (112 lib + integration), cargo test --all-features (136), clippy --all-targets -D warnings (host + wasm), fmt --check, cargo doc --no-deps clean; wasm32-unknown-unknown check confirms the default crate stays wasm-clean.
This commit is contained in:
1 parent
66c6e693bb
commit
e2fa32b3c7
11 files changed
+842
-121
No files matched your search
+79
-17
@@ -207,28 +207,26 @@ mod tests {
|
||||
drop(send);
|
||||
}
|
||||
|
||||
/// End-to-end (R4): `input` that passes the registry's (partial)
|
||||
/// schema but fails the handler's full `NegotiateRequest` parse —
|
||||
/// `cwd` typed as a number. The open op succeeds (the schema is
|
||||
/// deliberately partial), the producer-side handler writes a
|
||||
/// `0x00`-prefixed `malformed_negotiation` error frame on the
|
||||
/// channel stream, and the consumer-side post-open read sequence
|
||||
/// (`from_halves_raw`'s peek + error-frame parse) observes it — the
|
||||
/// same failure class as the post-open semantic failures, not a
|
||||
/// silent teardown. Uses `ChannelClient::open_channel` directly
|
||||
/// (the consumer's local fail-fast parse in `open_via_channels`
|
||||
/// would reject these params before the open op runs — R5's
|
||||
/// fail-fast path).
|
||||
/// End-to-end (R4, post-ADR-010): `input` that passes the registry's
|
||||
/// (partial) schema but fails the establisher's full
|
||||
/// `NegotiateRequest` parse — `cwd` typed as a number. The
|
||||
/// establisher rejects it before the reply: the open op fails with
|
||||
/// `channel:open_failed` + `details.reason == "handler_error"` (the
|
||||
/// malformed-negotiation mapping), the channel is torn down, and no
|
||||
/// `channel_id` is ever returned. Uses `ChannelClient::open_channel`
|
||||
/// directly (the consumer's local fail-fast parse in
|
||||
/// `open_via_channels` would reject these params before the open op
|
||||
/// runs — R5's fail-fast path).
|
||||
#[tokio::test]
|
||||
async fn schema_valid_but_unparseable_input_gets_malformed_negotiation_frame() {
|
||||
use tokio::io::AsyncReadExt;
|
||||
async fn schema_valid_but_unparseable_input_fails_open_with_open_failed() {
|
||||
use alkcall::channels::client::ChannelOpenError;
|
||||
|
||||
let mut backends: HashMap<String, Arc<dyn TtyBackend>> = HashMap::new();
|
||||
backends.insert("mock".to_string(), Arc::new(MockBackend::with_exit_code(0)));
|
||||
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,
|
||||
@@ -242,8 +240,72 @@ mod tests {
|
||||
),
|
||||
)
|
||||
.await
|
||||
.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"], "handler_error");
|
||||
assert!(
|
||||
details["message"]
|
||||
.as_str()
|
||||
.is_some_and(|m| m.contains("malformed negotiation")),
|
||||
"message carries the malformed-negotiation 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).
|
||||
assert!(
|
||||
client.manager().channel_ids().iter().all(|&id| id == 0),
|
||||
"no channel survives a failed establishment"
|
||||
);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[tokio::test]
|
||||
async fn allocate_failure_still_arrives_in_band_on_channels_path() {
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
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 backends: HashMap<String, Arc<dyn TtyBackend>> = HashMap::new();
|
||||
backends.insert("mock".to_string(), Arc::new(AllocFailBackend));
|
||||
let client =
|
||||
wire_client_and_server(Arc::new(backends), None, Some(tty_identity("alice"))).await;
|
||||
|
||||
let (_channel_id, _send, mut recv) = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
client.open_channel(
|
||||
OP_TTY_OPEN,
|
||||
serde_json::json!({ "carriage": "raw", "backend": "mock", "cmd": ["true"] }),
|
||||
crate::channels::TTY_ALPN,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("open_channel timed out")
|
||||
.expect("open_channel — the partial schema accepts cwd: 42");
|
||||
.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.
|
||||
@@ -263,7 +325,7 @@ mod tests {
|
||||
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"], "malformed_negotiation");
|
||||
assert_eq!(v["error"], "allocate_failed");
|
||||
assert!(v["message"].as_str().is_some_and(|m| !m.is_empty()));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user