944c300c170d2fe7d74e266ce4ebf8d66faa1f8a
10
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
944c300c17 |
feat(channels): implement the single-publish fixes — S-01..S-04 (reviews 010/011; ADR-047 am. 4, ADR-052, ADR-053, ADR-022 am.)
S-01 — the marked-pub open contract (ADR-047 amendment 4): - ChannelCore::register_pub_openable + PubOpenHandler (the put path's handler returns its result for the merged single response; the wrapper awaits inline, teardown is EOF-driven, handler_task stays None); register_openable* refuses Pub-typed specs; the wrapper entry points refuse unmarked/non-Pub shapes - the reserved input key `channel_id` (the initiator-allocated id): stripped pre-validation by resolve_sink_handler (the single source of truth, P-08), handed to the wrapper via OperationContext.metadata, a missing key rejected INVALID_INPUT; adopt parity enforced (ManagerError::InvalidAdopt) and adopt failures answer channel:open_failed / adopt_failed - zero call.published on marked ops: the first publish chunk on a marked op is rejected loudly (channel:publish_chunks_rejected) in both dispatch loops; the handler is never fed - relay put-translation replaces RelayError::PubTypedOpen: hub allocates odd on the producer leg, opens the spoke op in the background (put-acks-in-result), one-direction pump, response-mirror with the producer's reserved key stripped - ChannelClient::open_pub_channel + PubOpenReply (the initiator- allocates put open capability) S-02 — channel/control implemented (ADR-052): establisher-contributed ChannelControl (Establishment::with_control), manager-owned registry, teardown removals, channel:no_control_handle capability answer. S-03 — channel/resources/subscribe implemented (ADR-053): the ResourceEnumerators registry (shared per peer), snapshot-then-changes enumerators, scan-once aggregation, the empty seed; the always-failing one-shot error stream removed. S-04 — op/register forward-through (ADR-022 amendment): announced unmarked Sub/Pub register the real streaming/sink forwarding shapes; marked announcements refused with MARKED_SPEC_UNSUPPORTED; marked specs refuse the generic registration path and register_imported (Result-returning now). Guard (review 010 action 3): tests/stub_guard.rs greps src/ for not_implemented; the four pinned stub tests are deleted and the marked-spec registration refusals are pinned by tests. Verified: cargo test (696 + guard), clippy --all-targets -D warnings, fmt --check, fuzz corpus replay. |
||
|
|
a1f257757b |
fix(channels): duplicate adopt/open must not destroy the live channel; fuzz targets 3-5
Found by the manager_routing fuzz target (docs/research/fuzzing.md \u00a77.8): - ChannelManager::open_channel / adopt_channel used HashMap::insert(...).is_some() as a collision check \u2014 insert REPLACES the existing entry, so a duplicate adopt/open installed the new state, dropped the live channel's demux_sender (spurious EOF to its readers, subsequently routed chunks lost) and still returned Err(ChannelExists). Fixed with contains_key pre-check; map untouched on collision. Regression tests: adopt_channel_duplicate_id_leaves_ live_channel_intact, open_channel_duplicate_id_leaves_live_channel_ intact. New fuzz targets (\u00a77.4 step 3): - manager_routing: Arbitrary op sequences over ChannelManager; exact counter models (parked/dropped must equal the manager's monotonic counters), parked-bytes bound per \u00a76.2-1, clear_all ledger-vs-map semantics, drainer-byte reconciliation (lossless routing) - envelope_semantic: constructors -> serde -> write_frame/read_frame structural round-trip; event-type constants; call.error parse-back - spec_parse: OpRegisterRequest::from_json -> rebuild -> registry registration (attacker schemas compile at register, CF-003) - fuzz/shared/src/arbitrary_value.rs: bounded Arbitrary for serde_json::Value; 20 spec_parse + 4 manager_routing seeds - corpus replay for the new targets in fuzz/shared tests Verification: cargo test 684 passed (682 + 2 regression); clippy -D warnings clean (main + fuzz/shared); fmt clean (main + fuzz); cargo fuzz build clean; 20 s smoke on all three new targets clean (manager_routing 79k, envelope_semantic 141k, spec_parse 517k runs); crash input replays clean post-fix |
||
|
|
36e74cda11 |
feat(review 006 Unit 3): teardown-race log, early-arrival bound docs, count accessor (E-03, E-04, N-2)
- E-03: the open wrapper's handler-exit teardown no longer discards UnknownChannel silently — debug log + benign-race pinning comment (ledger take is the atomic gate; no double-decrement) - E-04: consumer-facing doc note on the 64-parked-chunks observable bound (channel-client.md + EARLY_ARRIVAL_CAP const doc) for tunnel-style push-first producers - N-2: ChannelManager::early_arrival_count() accessor (the observability choice over removing the write-only counter), documented monotonic, with a park/adopt-drain monotonicity test - review 006: Unit 3 marked implemented in Status and remediation plan Verification: 617 tests pass; clippy -D warnings clean (host + wasm32 check); fmt clean; doc clean |
||
|
|
a22b2b84c9 |
fix: park early-arrival chunks for un-adopted channels (open/first-data race)
The connect side adopts a channel (installs local routing state) only after the open-op response arrives, but the accept side's OpenHandler can start pumping data the moment the channel opens — the two race and the demux's lenient unknown-channel drop (REQ-CH-04) silently lost the producer's first chunks (a TTY backend's banner, a sub protocol's greeting). route_payload now parks up to 64 payloads per unknown channel_id in a bounded early-arrival buffer; adopt_channel drains them into the new receiver in order. Beyond the cap the chunk drops with the existing debug log + dropped_unknown_chunks counter (which now also counts overflow). clear_all drops parked buffers with the connection. Surfaced by alktty's consumer end-to-end test (review #001 L3): the session never resolved because the producer's first chunks (stdout sentinel + exit chunk for an immediately-resolving backend) arrived before the adopt and were dropped. REQ-CH-04 wording updated by this behavior; ADR-039 §demux loop describes the lenient drop for genuinely unknown channels, which remains the case past the cap. Verification: cargo test 597 (2 rewritten for the new semantics + route_payload_to_unknown_channel_parks_until_adopt gate); --all-features 614; clippy -D warnings clean; fmt clean; doc 0 warnings; publish --dry-run ok; standalone probe (handler-writes-first e2e over one connection) shows 0 dropped chunks with the fix vs 1 without. |
||
|
|
08e7df2aa0 |
feat: rename ALPN prefix from alknet/ to alk/ (v0.1.1)
- CHANNELS_ALPN: b"alknet/channels" → b"alk/channels" - CallAdapter::alpn(): b"alknet/call" → b"alk/call" - derive_alpn_from_op_name: alknet/ prefix → alk/ prefix - All ALPN string literals in src/ and docs/ updated - ADR-004 amended with prefix rename rationale - AGENTS.md, README.md updated - Version bumped to 0.1.1 Review: docs/reviews/003-alpn-prefix-rename.md Verification: - cargo test: 542 passed, 0 failed - cargo clippy --all-targets -- -D warnings: clean - cargo fmt --check: clean - cargo doc --no-deps: clean |
||
|
|
da12d65a03 |
fix: Unit 7 — small correctness fixes (C-19-misc, C-21, C-23, P-13)
No panic paths in library code, no misleading public fields, honest
error envelopes, latent bugs in derive_alpn_from_op_name and the mux
pump map fixed, per-discovery Box::leak removed, demux observability
counter added, resources-snapshot unbounded loop replaced with an
O(open channels) iterator.
C-21 — write_header (wire.rs) sliced out[..CHUNK_HEADER_LEN] and
panicked on short buffers; the doc comment declared the panic. Returns
Result<_, ChunkError::HeaderTooShort> instead (the variant already
existed for the parse side). write_chunk and all callers updated (they
pass [0u8; 8] today so the Result is always Ok, but the API contract
is typed, not panic-documented). Added a short-buffer test.
C-23 — dispatch_requested's Sink and Stream error arms used
String::new() as the request id (dispatch.rs), producing envelopes
with an empty id. The request_id is now cloned before the move into
dispatch and used in the error arms, so the envelope carries the real
id (matching the Once arm, which already did this correctly).
P-13 — SinkDispatch::chunk_tx was pub (dispatch.rs), exposing the
futures::mpsc::Sender type and the 64-slot buffer size as effective
public API. Made pub(crate); handle_stream and pump_sink (the only
readers) are in the same module, and the dispatch tests are in the
same module too.
C-19 (mux pump map leak + duplicate registration) — MuxRunner::run
inserted each pump's JoinHandle into self.pumps and never removed
finished ones (they accumulated for the connection's lifetime).
Duplicate register(channel_id) silently overwrote the old handle while
the old pump kept running (two pumps, one channel id). Fixed: before
insert, check for an existing entry; if it is_finished(), reap it and
proceed (leak fix); if it is still running, reject the registration by
dropping the responder without sending (the caller's receiver.await
yields RecvError, which ChannelManager maps to ChannelExists). Added
two tests: duplicate-rejected-while-running and
reap-finished-then-reregister.
C-19 (derive_alpn_from_op_name) — from_call.rs took only the first
path segment (rest.split('/').next()), so channels/custom/proto/sub
derived alknet/custom instead of the full ALPN custom/proto. The
segment.starts_with("alknet/") branch was dead (a single segment
cannot contain /), and segment == "alknet" yielded the nonsense ALPN
"alknet". Fixed: strip the known /sub or /pub suffix from rest
instead of taking the first segment, so multi-segment ALPNs survive.
The rule (ADR-047 Negative): alknet/*-prefixed segments and
multi-segment non-alknet/* ALPNs are returned as-is; single-segment
non-alknet names get the alknet/ prefix prepended. Added tests for the
multi-segment non-alknet case, the explicit alknet/ prefix case, the
/pub suffix, and the no-suffix (non-channel-open-op) case.
C-19 (Box::leak per discovery) — from_call.rs leaked a String to
'static on every leak_alpn call (bounded per unique ALPN in theory,
but leaks on every rediscovery of every marked op). Refactored
ChannelOpenSpec::alpn from &'static str to Cow<'static, str>
(spec.rs); the common case (ALPN crates register at compile time with a
&'static str literal) pays no allocation via Into<Cow>, while from_call
supplies an owned String without leaking. Removed leak_alpn. Amended
ADR-047 §2 to record the Cow<'static, str> shape (two-way-door type
detail; the wire format — a boolean channel_open marker — is
unchanged).
C-19 (REQ-CH-04 error counter) — the demux's lenient unknown-channel
drop (manager.rs) only debug!-logged; there was no counter or stats
surface. Added an AtomicU64 dropped_unknown_chunks counter to
ChannelManager, incremented on the unknown-channel drop in
route_payload, with a dropped_unknown_chunks() accessor for
observability. Added two tests: counter increments per drop, and
known-channel routing does not increment it.
C-19 (resources-snapshot unbounded loop) — operations.rs iterated
0..u32::MAX with a per-iteration mutex lock, breaking when
resources.len() >= manager.open_count(). With sparse ids (monotonic
after churn) or a channel closing mid-iteration, this could iterate
millions of times. Replaced with an iterator over ChannelManager::
channel_ids() — a new accessor that returns a point-in-time Vec<u32>
of open channel ids — so the snapshot is O(open channels). Added
channel_ids tests and behavioral tests for the resources/subscribe
handler (open channels emit their ALPNs; no channels emit an empty
set). Also added close/control handler tests (C-25 #8 coverage the
review noted was missing): close rejects channel 0, close on unknown
channel returns NOT_FOUND, control on unknown channel returns
NOT_FOUND, control missing channel_id returns INVALID_INPUT.
Verification:
- cargo test → 465 passed, 0 failed (was 449; +16 new tests)
- cargo clippy --all-targets -- -D warnings → clean
- cargo fmt --check → clean
- cargo doc --no-deps → 0 warnings
|
||
|
|
f25d0a6920 |
fix: Unit 5 — ledger decrement on all teardown paths + channel-id adoption (C-06, C-08, C-12, C-13, C-18, C-25 #4 #5)
- C-06: add ChannelLifecyclePolicy to ChannelsAdapter; demux loop decrements per-identity counts on connection drop (clear_all path). Handler-exit teardown: wrap handler tasks in run_open_wrapper to call teardown_channel + on_close on natural completion. Fix check_open leak: on_close on allocation failure in run_open_wrapper. - C-08: odd/even ID split (connect=1, accept=2, step=2) via ChannelSide enum. Add adopt_channel to ChannelManager for non-allocating side routing. Add ChannelClient::open_channel (call open op + adopt). - C-12: reject channel_id:0 in channel/close handler. - C-13: drain-before-close — await handler task (5s timeout) instead of abort, then decrement policy. - C-18: re-check max_channels on re-acquire after mux.register in open_channel (TOCTOU-safe). Tests added: policy_decremented_on_connection_drop, concurrent_opens_respect_max_channels, channel_close_rejects_channel_zero, channel_adoption_end_to_end_round_trip, odd_even_split_no_collision, adopt_channel_installs_routing, adopt_channel_duplicate_id_returns_channel_exists, open_channel_too_many_channels_rejected, connect_side_starts_at_1_accept_side_starts_at_2. Verification: 450 tests pass (was 441; +9), clippy clean, fmt clean. |
||
|
|
1f06253959 |
fix: Unit 4 — backpressure fixes (C-04, C-05, C-07, C-16, C-17, C-25 #2 #3 #6)
C-04 [critical]: route_payload is now async — uses send().await instead of try_send, so the demux stalls on a full buffer instead of dropping chunks. Lossless bounded-buffer backpressure per ADR-040 REQ-CH-05. C-05 [critical]: DEFAULT_BUFFER_CAP changed from 1,048,576 (messages) to 64 (messages). The old value counted messages, not bytes, giving a ~16 TiB per-channel bound instead of the intended 1 MiB. The new value is a reasonable message-count bound; the actual memory bound is enforced by the 16 MiB MAX_CHUNK_LEN per message. C-07 [critical]: demux loop now skips the payload bytes on ChunkError::TooLarge before continuing. The parsed length is in the error variant; the demux reads and discards that many bytes, then resyncs on the next 8-byte header. Previously it continued without skipping, causing permanent stream desync. C-16 [major]: MpscSendStream switched from tokio::sync::mpsc to futures::channel::mpsc, which exposes poll_ready for proper async backpressure in poll_write. The ~50 lines of abandoned deliberation comments are removed. The mux pump now uses futures::StreamExt::next instead of tokio recv. C-17 [major]: mux pump writes an EOF chunk when the receiver ends without a sentinel (handler dropped without shutdown). Previously the pump exited silently on recv→None, leaving the remote handler hanging until full transport close. Tests added: - C-25 #2: demux_resyncs_after_oversized_chunk - C-25 #3: backpressure_slow_reader_no_data_loss_other_channel_unaffected - C-25 #6: mux_pump_writes_eof_on_implicit_close Verification: 441 tests pass (was 439; +3), clippy clean, fmt clean, doc warnings unchanged (2 pre-existing, Unit 6 long-tail). |
||
|
|
8066d08395 |
fix: Unit 3 — register_openable + per-connection ChannelCore (C-02, C-03, C-09)
A channel-open op could not be registered or invoked (C-02):
ChannelCore::register_openable did not exist, and resolve_channel_manager
(C-03) was a stub returning None — the ADR-047 §4 dynamic-resolution
shape (downcast context.env to &dyn ChannelOperationEnv) was unworkable
as written: context.env is a PeerCompositeEnv, not a single concrete
type that can be downcast to a channels-backed env.
The fix is per-connection registration (ADR-047 §4 amendment,
2026-08-13): a ChannelCore is constructed per channels connection (in
the install_channel_zero hook, which already runs per-connection and
already receives the ChannelManager), and register_openable is called on
that connection's overlay OperationRegistry (Layer 2 per ADR-019). The
wrapper closes over the per-connection ChannelCore and uses
ChannelCore::manager() directly — no context.env downcast. This
preserves every invariant ADR-047 §4 was written to protect (layering,
per-connection resolution) without adding as_any() to OperationEnv
(which would close the session/connection overlay patterns from
ADR-024, AGENTS.md §6).
Changes:
- ChannelCore::register_openable wraps the ALPN's OpenHandler with the
ACL→check_open→open_channel→spawn→respond flow (ADR-047 §3). Branches
on spec.op_type: Query/Mutation→Once, Sub→Stream (emits { channel_id }
and completes; data plane on the channel's BiStream), Pub→Sink (stub:
channel:pub_open_not_implemented — requires the channel-adoption path,
C-08/Unit 5). The OpenHandler receives (input, Connection, AuthContext)
and spawns the ALPN's protocol on the channel's BiStream, returning a
JoinHandle for teardown.
- ChannelManager::set_handler_task installs the spawned OpenHandler's
JoinHandle after open_channel (which allocates the channel first to
get the BiStream halves, then the handler is spawned, then the task is
recorded for abort on channel/close / connection drop).
- channel:too_many_channels / channel:allocation_failed error codes
mapped to CallError with details (channel:forbidden is the ACL's
FORBIDDEN, already handled by the registry before the wrapper).
- resolve_channel_manager stub removed (C-03); ChannelOperationEnv trait
and ChannelsSessionEnv retained as a two-way-door implementation detail
for future per-connection routing (not on the open-op path). The
tautology filler test (C-22 env.rs) removed.
- ADR-047 §4 amendment records the per-connection-registration decision
(two-way door: the ADR's door-type section explicitly marks the wrapper
shape as a two-way-door implementation detail; the one-way decisions
— per-ALPN op names, channel_open marker, removal of channel/open —
are unchanged).
Acceptance gate (C-02/C-03): one end-to-end test wires ChannelClient ↔
ChannelsAdapter over a real tokio::io::duplex carrying the channels
8-byte chunk header wire format. The accept side's install_channel_zero
hook builds a per-connection ChannelCore, registers a no-op open op
(channels/tty/sub) via register_openable, and runs the dispatch loop.
The client calls call_open_op("channels/tty/sub") on channel 0; the
wrapper does check_open→open_channel→spawn→respond. Asserts the
response carries a non-zero channel_id and that the per-identity quota
was reserved (policy count for the caller incremented to 1).
Verification: 438 tests pass (was 437; +1 e2e), clippy clean, fmt clean,
doc warnings 2 (was 4; fixed the 2 register_openable broken-link
warnings — C-09; the remaining default_policy and env module/macro
warnings are Unit 6 long-tail items).
|
||
|
|
f305f8c0a5 |
feat: implement channels protocol + ADR-047 (openable ALPNs are operations)
ADR-047: the unifying decision that dissolves into per-ALPN ops (, ) with a marker on . Each openable ALPN registers its own ops with their own , , , and the marker. The field is replaced by (Sub/Pub). The generic ops (channel/close, channel/control, channel/resources/subscribe) stay, keyed by channel_id. Resolves Gaps A-G from the research findings (Gap B broker named out-of-scope for alkcall; Gap C relay wrapper is consumer concern; Gap D connection-owner allocates; Gap E extension trait; Gap F boolean marker on wire; Gap G ACL/ownership complementary). ADR-037 amended: dissolves; removed; generic ops stay; preview dropped from resources/subscribe. Spec docs updated: channel-operations.md (unified model, opener ledger, ACL flow), operation-registry.md (channel_open marker, ChannelOpenSpec), README.md (ADR-047), open-questions.md (OQ-31..38 resolved). Source changes: - spec.rs: ChannelOpenSpec struct, channel_open field on OperationSpec, with_channel_open builder, 3 tests - discovery.rs: spec_to_json emits channel_open boolean, operation_spec_schema includes channel_open, 2 tests - from_call.rs: rebuild_spec_for parses channel_open marker, derive_alpn_from_op_name helper, 6 tests Channels module (src/channels/, 10 files, ~2400 lines): - wire.rs: 8-byte chunk header (ChunkHeader, parse/write_header, read_header/write_chunk/write_eof async helpers), 12 tests - reassembly.rs: MpscRecvStream (tokio::mpsc::Receiver<Bytes> → AsyncRead), MpscSendStream (AsyncWrite → tokio::mpsc::Sender<Bytes>), REQ-CH-01 shutdown sentinel, REQ-CH-02 sender-drop EOF, 10 tests - mux.rs: MuxHandle (clone-able, register(channel_id)), MuxRunner (per-channel pump tasks, exits when handles drop), OpenerLedger (ADR-047 §7), 4 tests - manager.rs: ChannelManager (channel map, open_channel, install_channel_zero, route_payload, teardown_channel, clear_all), 11 tests - source.rs: ChannelBidiStreamSource (yield-once accept_bi), channel_source helper, 4 tests - adapter.rs: ChannelsAdapter (ProtocolHandler for alknet/channels, demux loop, install_channel_zero hook), 1 test - operations.rs: ChannelOperations (registers channel/close, channel/control, channel/resources/subscribe), ChannelCore (check_open/on_close wrappers), 4 tests - policy.rs: ChannelLifecyclePolicy trait, NoCap, PerIdentityChannelPolicy (default 256, per_identity_caps override), default_policy, 8 tests - env.rs: ChannelOperationEnv extension trait (ADR-047 §4), ChannelsSessionEnv impl, 2 tests - client.rs: ChannelClient (from_connection, call_open_op, take_call_connection), 1 test Verification: 432 tests pass (66 new channels + 10 marker + 356 existing), clippy clean, fmt clean, cargo doc generates. Cargo.toml: +bytes dependency. |