docs: ADR-049 channel-open establishment phase; verify review 006
Verify review 006's findings against source at 88e3f5e (E-01..E-04
all confirmed; E-02 cost corrected — OperationSpec has no description
field, four touchpoints) and file three additional findings from the
same sweep (N-1 client error-type gap, N-2 write-only early-arrival
counter, N-3 pump-panic posture).
ADR-049 resolves E-01 + N-1: split-hook OpenEstablisher awaited
bounded by the open-op wrapper (restoring ADR-047 §3's "channel
plan" shape), teardown + typed channel:open_failed reply on
establishment failure, ChannelClient::open_channel typed error.
Review 006 gains the post-verification remediation plan and verdict
appendix.
Verification: cargo test (597 passed), cargo doc --no-deps clean.
This commit is contained in:
1 parent
88e3f5e9c3
commit
48ceeba55c
2 files changed
+465
-5
No files matched your search
@@ -0,0 +1,316 @@
|
||||
# ADR-049: Channel-Open Establishment Phase (`OpenEstablisher`)
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (amends ADR-047 §3 — the open-op wrapper gains an awaited
|
||||
establishment phase ahead of the spawned pump handler; resolves review
|
||||
006 E-01 and N-1)
|
||||
|
||||
## Context
|
||||
|
||||
ADR-047 §3 made openable ALPNs operations: the ALPN crate supplies an
|
||||
open handler, and the channels wrapper does the channel machinery
|
||||
(allocation, ledger, policy, spawn). The §3 decision text describes the
|
||||
handler's job as "validate params, consult ownership, prepare the
|
||||
backend, return a 'channel plan'" — an awaited preparation step the
|
||||
wrapper consults **before** replying. The implemented `OpenHandler`
|
||||
type (`Arc<dyn Fn(Value, Connection, AuthContext) -> JoinHandle<()>`)
|
||||
collapsed that preparation into a fire-and-forget spawn: the wrapper
|
||||
collects the `JoinHandle`, records it for teardown, and writes
|
||||
`{ "channel_id": <id> }` to the wire the moment the handler task is
|
||||
*spawned* (`run_open_wrapper`, `src/channels/operations.rs`). The
|
||||
establishment phase the ADR described never became a thing the wrapper
|
||||
could consult.
|
||||
|
||||
The consequence (review 006 E-01, verified at tree `88e3f5e`): the open
|
||||
op **cannot fail after allocation**. Any establishment failure inside
|
||||
the handler — params valid at the schema level but semantically
|
||||
rejected, backend lookup failure, a target dial refused for a
|
||||
`direct-tcpip`-shaped tunnel, a resource no longer available — is
|
||||
invisible to the open reply. The consumer observes: the call op
|
||||
succeeds with `{channel_id}`, the channel is adopted, and then the
|
||||
channel EOFs (the handler exits without writing; the mux pump writes
|
||||
the implicit-EOF chunk; `MpscRecvStream::poll_read` returns clean EOF
|
||||
for both the sentinel and sender-drop arms). A dial failure is
|
||||
byte-for-byte indistinguishable from a target that closed immediately
|
||||
after connecting — the two most different failure/success stories map
|
||||
to the same consumer-visible event.
|
||||
|
||||
Every established tunnel/forwarding protocol puts establishment failure
|
||||
in the open reply, not in the data stream:
|
||||
|
||||
- **SSH** (RFC 4254 §5.1): `SSH_MSG_CHANNEL_OPEN_FAILURE` is a
|
||||
first-class reply carrying a reason code
|
||||
(`ADMINISTRATIVELY_PROHIBITED` / `CONNECT_FAILED` /
|
||||
`UNKNOWN_CHANNEL_TYPE` / `RESOURCE_SHORTAGE`) plus a description
|
||||
string; the channel never exists on the opener's side afterward.
|
||||
- **SOCKS5** (RFC 1928 §6): the reply carries REP codes 0x01–0x08;
|
||||
error-then-close, never "success then in-stream error."
|
||||
- **udpgw** (tun2proxy) is the counterexample: an opaque ERR bit with
|
||||
zero reason information — the vocabulary to avoid.
|
||||
|
||||
The per-crate workaround proves the gap is load-bearing: alktty's
|
||||
channels path answers establishment failures with a length-prefixed
|
||||
JSON error frame **on the channel stream**
|
||||
(`send_negotiation_error`, `alktty/src/adapter.rs`), disambiguated from
|
||||
data by a `0x00` first-byte peek. That is a per-crate reinvention of a
|
||||
protocol-level capability every ALPN crate will need: a structured,
|
||||
typed, **establishment-failure reply to the open op**. It also forces
|
||||
the phantom-opened channel to exist in the manager — allocation, ledger,
|
||||
and policy all fire for a channel that never carries data.
|
||||
|
||||
alktunnels (the next consumer, arbitrary TCP/UDP tunnels over channels)
|
||||
hits this on day one: its producer dials the tunnel target inside the
|
||||
open op, and dial failure is the *common* case, not the edge case
|
||||
(review 006 E-01; alktunnels OQ-TN-09).
|
||||
|
||||
This is the cheapest moment to fix upstream: three downstream crates
|
||||
(alktty, alkhttp, alktunnels-in-progress), alkcall 0.4.x, no published
|
||||
consumer depends on the phantom-open shape.
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. The open-op wrapper gains an awaited establishment phase
|
||||
|
||||
`ChannelCore::register_openable` accepts an optional
|
||||
**establisher** alongside the existing `OpenHandler`:
|
||||
|
||||
```rust
|
||||
/// The establishment result. `Establishment` carries what the
|
||||
/// pump phase needs (today: nothing — reserved for a channel plan).
|
||||
/// `EstablishmentError` carries the reason code + message the
|
||||
/// wrapper puts in the open reply's `details`.
|
||||
pub type OpenEstablisher = Arc<
|
||||
dyn Fn(Value, Connection, AuthContext)
|
||||
-> BoxFuture<'static, Result<Establishment, EstablishmentError>>
|
||||
+ Send
|
||||
+ Sync,
|
||||
>;
|
||||
```
|
||||
|
||||
The establisher is **awaited by the wrapper, bounded** — before the
|
||||
reply is written, before the pump handler is spawned. The pump handler
|
||||
(the existing `OpenHandler`, unchanged) is spawned only on
|
||||
establishment success. The dial is the natural establisher step for
|
||||
tunnels; the pumps remain the spawned handler. This restores ADR-047
|
||||
§3's original "channel plan" shape: the establisher is the awaited
|
||||
preparation, the wrapper consults its result, the pumps are the
|
||||
spawned protocol.
|
||||
|
||||
**Why split-hook, not await-and-inspect** (the two candidates review
|
||||
006 proposed):
|
||||
|
||||
- The split preserves the `OpenHandler` type exactly. Await-and-inspect
|
||||
changes `OpenHandler`'s return type (`JoinHandle<()>` →
|
||||
`JoinHandle<OpenResult>`), breaking all three consumers' handlers and
|
||||
the alkhttp `OpenableAlpn` ferry for no compensating gain.
|
||||
- Establishment and pump are genuinely different lifecycles. The dial
|
||||
is synchronous with the open reply (SSH's semantics: the failure is
|
||||
the open's reply); the pumps outlive the reply. Coupling the
|
||||
establishment signal to the pump task's lifecycle (watching a
|
||||
`JoinHandle` for a first resolution) conflates them and makes the
|
||||
"established, continue" signal an out-of-band convention (a sentinel
|
||||
`Result` value, a oneshot the handler must remember to signal) —
|
||||
more protocol per crate, the thing this fix exists to remove.
|
||||
- The split is backward compatible by construction: no establisher
|
||||
registered = an always-OK establisher. Existing registrations compile
|
||||
and behave unchanged.
|
||||
|
||||
### 2. The deadline bounds the establisher, not the pumps
|
||||
|
||||
The establisher await is bounded by the dispatch deadline when the
|
||||
`OperationContext` carries one (`context.deadline`), else by a crate
|
||||
constant (`ESTABLISHMENT_TIMEOUT`, 10s default; overridable per
|
||||
registration via a `Duration` argument on the establisher-taking
|
||||
`register_openable` variant). On deadline expiry the wrapper treats it
|
||||
as establishment failure with reason `timeout`.
|
||||
|
||||
The bound applies **only** to the establisher. The spawned pump
|
||||
handler's lifetime is governed by the existing teardown machinery
|
||||
(`channel/close`, connection drop, handler exit) — unchanged.
|
||||
|
||||
Head-of-line safety is already proven: the serving loop spawns Once
|
||||
invocations as independent tasks
|
||||
(`Dispatcher::spawn_once_dispatch`), so a slow establisher on one open
|
||||
op does not block other calls on channel 0.
|
||||
|
||||
### 3. Establishment failure: teardown + typed `channel:open_failed`
|
||||
|
||||
On establishment failure (error or deadline), the wrapper:
|
||||
|
||||
1. Tears down the just-allocated channel
|
||||
(`teardown_channel` — drops the demux sender, returns the not-yet-
|
||||
installed handler task handle if any),
|
||||
2. Takes the opener-ledger entry and calls `policy.on_close(opener)`
|
||||
(the same un-increment path the allocation-failure arms already
|
||||
run — the ledger `take` is the atomic gate, ADR-047 §7),
|
||||
3. Replies with a new typed error:
|
||||
|
||||
```
|
||||
code: "channel:open_failed"
|
||||
message: human-readable establishment failure description
|
||||
retryable: false
|
||||
details: { "reason": <reason-code>, "message": <detail string> }
|
||||
```
|
||||
|
||||
The reason-code vocabulary maps 1:1 onto what an establisher can
|
||||
actually produce (per the SSH four; the survey's finding):
|
||||
|
||||
| reason | meaning |
|
||||
|---|---|
|
||||
| `dial_failed` | the backend/target could not be reached or refused |
|
||||
| `unknown_resource` | the requested resource does not exist |
|
||||
| `resource_shortage` | the backend is out of capacity (ports, fds, slots) |
|
||||
| `handler_error` | establisher-internal failure not covered above |
|
||||
| `timeout` | establishment exceeded the deadline |
|
||||
|
||||
Policy denial stays `channel:too_many_channels` (pre-allocation,
|
||||
unchanged); ACL denial stays `FORBIDDEN` (registry gate, unchanged).
|
||||
The new code is an additive wire addition (new error-code string +
|
||||
optional `details` shape); no existing consumer breaks. ALPN crates'
|
||||
open-op specs gain matching `ErrorDefinition` entries per ADR-016 so
|
||||
`services/schema` discloses the failure contract.
|
||||
|
||||
The SSH "channel never exists opener-side" property is the contract:
|
||||
the consumer's open resolves `Err` and no `channel_id` was ever
|
||||
returned. (The allocation still happened accept-side momentarily —
|
||||
that is invisible to the consumer and is what the teardown in step 1
|
||||
cleans up.)
|
||||
|
||||
### 4. `ChannelClient::open_channel` stops erasing the error (review 006 N-1)
|
||||
|
||||
`ChannelClient::open_channel` currently flattens the `CallError` into a
|
||||
`String` (`format!("open op failed: {e:?}")`), which would make the
|
||||
typed reason invisible to consumers — the E-01 fix would be unreachable
|
||||
end-to-end through the primary client path. It changes to return a
|
||||
typed error carrying the `CallError` (a new
|
||||
`ChannelOpenError { error: CallError }` or equivalent), so the
|
||||
consumer branches on `channel:open_failed` + `details.reason`.
|
||||
|
||||
This is a breaking change to a method signature introduced in this
|
||||
crate's 0.4.x — acceptable at 0.5.0 (see Consequences), and it is the
|
||||
point of the change: the reason must be consumer-usable.
|
||||
|
||||
### 5. Compatibility and migration
|
||||
|
||||
- `OpenHandler`'s type is unchanged. Existing registrations compile
|
||||
unchanged.
|
||||
- `ChannelCore::register_openable` keeps its current signature
|
||||
(no establisher = always-OK); a new
|
||||
`register_openable_with_establisher(spec, establisher, open_handler,
|
||||
registry, auth)` variant adds the hook. alkhttp's `OpenableAlpn`
|
||||
gains an optional `establisher` field (default `None`) — the ferry
|
||||
passes it through mechanically.
|
||||
- alktty migrates its **channels path** semantic failures (unknown
|
||||
backend, `carriage != "raw"`, `allocate_failed`, ownership denial —
|
||||
currently post-open error frames) into the establisher, resolving
|
||||
them as `channel:open_failed`. Its direct-ALPN path **keeps** the
|
||||
in-band error frame (two transports, two contracts; the direct path
|
||||
has no open op to fail). The `0x00`-peek disambiguation stays for
|
||||
the direct path only.
|
||||
- alkhttp is unaffected (no openable ops in the default surface; the
|
||||
`OpenableAlpn` change is additive).
|
||||
|
||||
### 6. Panicked pump handlers stay EOF-shaped (pinned as designed)
|
||||
|
||||
The wrapper's teardown task swallows the pump handler's `JoinError`
|
||||
(`let _ = raw_task.await`). A panicked pump = instant EOF, which is
|
||||
the correct consumer-visible outcome for a mid-stream handler crash
|
||||
(indistinguishable from an abrupt close — there is no error channel
|
||||
mid-stream by design; establishment errors are the only kind that
|
||||
belong in the open reply). This ADR pins that as intended; no change.
|
||||
The establisher, by contrast, runs pre-reply — its panic (a future
|
||||
that panics when polled) surfaces as the spawned Once task's panic,
|
||||
which the serving loop already tolerates (the call never resolves;
|
||||
the deadline / client timeout is the bound). Establisher
|
||||
implementations return `EstablishmentError` instead of panicking, per
|
||||
this crate's no-panic convention.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
|
||||
- Establishment failure reaches the consumer as a typed, branchable
|
||||
call error — retry policy, client UX, and error reporting become
|
||||
possible for dial-refused, unknown-resource, and shortage cases
|
||||
(previously: instant-EOF ambiguity).
|
||||
- The SSH contract ("the channel never exists opener-side") holds
|
||||
consumer-visibly: a failed open never returns a `channel_id`.
|
||||
- No phantom channels: the ledger, policy count, and manager state are
|
||||
restored atomically on failure — allocation and teardown balance.
|
||||
- alktty's per-crate in-band error vocabulary is retired on the
|
||||
channels path; every future ALPN crate (alktunnels first) gets the
|
||||
establishment reply for free.
|
||||
- ADR-047 §3's "channel plan" shape is realized: awaited preparation
|
||||
before reply, spawned pumps after.
|
||||
|
||||
**Negative:**
|
||||
|
||||
- `channel:open_failed` + the reason vocabulary is a new wire-visible
|
||||
error surface — additive, but it joins the stable error set
|
||||
consumers may branch on (per ADR-016, `details` shapes are
|
||||
discoverable via `services/schema`).
|
||||
- `ChannelClient::open_channel`'s error type changes (breaking at
|
||||
0.5.0; mechanical for consumers — the `String` was a
|
||||
debug-formatting wrapper anyway).
|
||||
- `OpenableAlpn` (alkhttp) gains a field; its two construction sites
|
||||
add `None` (mechanical).
|
||||
- The establisher await adds a bounded latency to open-op replies
|
||||
where handlers previously replied instantly (the spawn). The 10s
|
||||
default is the worst case for a hung establisher; real establishers
|
||||
(dial, lookup) complete in dial-time. Consumers already tolerate
|
||||
call-op latency; the deadline is the bound.
|
||||
|
||||
## Door type
|
||||
|
||||
**One-way (wire-visible error surface).** `channel:open_failed` and its
|
||||
`details.reason` vocabulary join the stable error set: once consumers
|
||||
branch on reason codes, changing the vocabulary requires a migration
|
||||
(the same one-way-ness ADR-016 gives typed error details). The
|
||||
establisher hook shape itself — `OpenEstablisher`, the
|
||||
`register_openable_with_establisher` variant, the
|
||||
`Establishment`/`EstablishmentError` types — is a **two-way-door
|
||||
implementation detail** within the one-way decision (the wrapper shape,
|
||||
per ADR-047 §3's own door-type note). The `OpenHandler` type is
|
||||
untouched, which is what keeps the split cheap to revise.
|
||||
|
||||
## Implementation units
|
||||
|
||||
1. **alkcall 0.5.0** — `OpenEstablisher` +
|
||||
`register_openable_with_establisher`; wrapper flow (await bounded →
|
||||
teardown-on-failure → `channel:open_failed` with details);
|
||||
`ChannelClient::open_channel` typed error (N-1); tests:
|
||||
- establisher fails after allocation → consumer's `open_channel`
|
||||
resolves `Err(channel:open_failed)` + reason details; channel
|
||||
absent from `channel_ids()` afterward;
|
||||
- establisher never completes → `timeout`-reason failure within the
|
||||
deadline, channel torn down, ledger decremented;
|
||||
- no-establisher registration behaves exactly as today (compat
|
||||
gate);
|
||||
- establisher success spawns pumps and replies `{channel_id}`
|
||||
unchanged.
|
||||
2. **alktty migration** — channels-path semantic failures move into an
|
||||
establisher; `open_via_channels_surfaces_negotiation_rejected`
|
||||
resolves via call error; the direct-ALPN error-frame path is
|
||||
retained.
|
||||
3. **alkhttp pass** — `OpenableAlpn.establisher: Option<...>` (default
|
||||
`None`), threaded through the session fork (mechanical).
|
||||
|
||||
## References
|
||||
|
||||
- Review 006 E-01 (the establishment gap — findings and prior-art
|
||||
survey), N-1 (the client error-type gap this ADR also resolves),
|
||||
E-03/E-04 (adjacent teardown/early-arrival notes, filed separately
|
||||
from this ADR's scope)
|
||||
- ADR-047 §3 (openable ALPNs are operations — the "channel plan"
|
||||
wrapper shape this ADR restores; §7 opener ledger — the teardown
|
||||
un-increment path)
|
||||
- ADR-016 (typed error schemas — the `details` vehicle)
|
||||
- ADR-040/041 (backpressure/caps — untouched; the teardown path keeps
|
||||
the ledger `take` as the atomic gate)
|
||||
- alktty ADR-009 (the open op's input is the negotiation) + review 001
|
||||
L1/L3 — the in-band mechanism retired on the channels path
|
||||
- alktunnels OQ-TN-09 (dial-failure reporting — the first consumer of
|
||||
the new error) and `docs/research/ssh-socks5-survey.md`
|
||||
§"Open-failure path" (the reason-code prior art)
|
||||
- RFC 4254 §5.1, RFC 1928 §6 — SSH/SOCKS5 open-failure semantics
|
||||
Reference in new issue
Block a user