Files
alktunnels/docs/research/fuzzing.md
T
glm-5.3-flash e3c6f6c9f8 fuzz: wave 2 — session_pumps stateful two-pump target
The wave-2 stateful target from docs/research/fuzzing.md 3.3: op
sequences (arbitrary SessionSequence) over one live channels stack
per exec - MuxRunner -> raw chunk stream -> the real
run_demux_loop_for_client demux -> ChannelManager per side (producer
Connect / consumer Accept over one duplex transport, the reverse
POC's topology). The harness mirrors TunnelSession's data-plane
shapes: datagram planes (codec reader + pending, the
DatagramHalves mirror) and stream planes (pump_bidi against
in-process peer halves, the pump_against mirror).

Invariants (3.3): no-panic/no-wedge (5s reconcile join); two-pump
shutdown-on-completion; lossless p2c copy count; EOF-sentinel
teardown semantics (drain-before-EOF, map empties); FIFO datagram
reconciliation through the real demux path (stateful framing walk,
the wave-1 walk carried across ops); the 1.3 DatagramHalves.pending
backlog bound (<= routed-but-unpulled remainder); decoder-state
retained-bytes bound re-asserted per op.

Harness-model findings (all six wave findings are harness defects,
not crate bugs - the fuzzer-as-harness-checker pattern):
- duplicate adopt is a legal ChannelExists no-op (documented table)
- the p2c-EOF pump join is the DESIGNED half-close shape; the pump
  completes once the c2p source EOFs too (teardown), not before
- route_payload routes into the LOCAL manager (demux direction);
  the producer pump writes the channel send half - the first
  campaign's bytes never reached the consumer; rewritten to the
  real protocol shape (Open allocates, session-index addressing,
  stateful framing model, Pull op, EOF sentinels only from
  teardown/sender-drop)
- clear_all's return is the opener-ledger records, NOT the reaped
  ids (adopted channels carry no ledger entry - the alkcall 7.7
  lesson re-learned); the map-empties assert is the ground truth

Campaign (10-min detached, 6 runner): 228,676 execs, 32 fork jobs,
oom/timeout/crash 0/0/0, exit 0. Coverage 3427 edges / 17.5k
features still expanding at budget end. No crate bugs found - the
two-pump contract, EOF-sentinel semantics, and the pending backlog
bound are demonstrated properties.

Crate addition: TunnelSession::datagram_backlog() (the
DatagramHalves.pending diagnostic the 3.3 bound asserts against, in
the retained_bytes() style) + a suite test pinning its rest states.

Verification: main suite 136 passed; corpus replay (8 committed
seeds + 2 semantic fixtures) green; clippy -D warnings + fmt clean
(main, shared, wasm); wasm check clean; cargo fuzz build clean.
2026-09-28 10:12:09 +00:00

32 KiB
Raw Blame History

Fuzzing alktunnels: Proposal and Plan

Status: adopted — wave-1 complete (datagram_codec + params_parse implemented, seeds committed, two 10-min campaigns clean, two harness-model findings fixed — see §7); wave-2 (session_pumps) implemented and campaigning — see §3.3/§7. Campaigns run via the detached runner (§6) — never as a foreground child of an agent session (§5). Date: 2026-09-28 Upstream precedent: /workspace/@alkdev/alkcall/docs/research/fuzzing.md (0.8.0 — 0.8.1 campaign; one real finding: duplicate adopt/open destroying the live channel, fixed in 0.8.1). Tooling, corpus policy, runner, and the no-hosted-CI posture are inherited from that doc wholesale — this doc does not re-litigate them; it scopes them to this crate.


1. Should this crate be fuzzed?

Yes — but scoped smaller than alkcall's. The decision follows from three lines, all pointing the same way:

  1. alkcall's fuzz doc already assigned this to us. Its §5 "non-surfaces (deliberately)" note ends: the channels layer carries opaque Bytes and the handler owns sub-stream framing (ADR-035) — those downstream parsers are downstream crates' fuzz targets. Our wire.rs datagram codec IS that named downstream target. Fuzzing it completes alkcall's fuzz scope rather than duplicating it.

  2. We own one hand-rolled parse surface of exactly the covered class. DatagramReader::feed (src/wire.rs:100) is an incremental length-prefix state machine reassembling [len: u16 BE][payload] frames across arbitrary chunk boundaries — length arithmetic and boundary handling, the dominant rust-fuzz trophy-case classes. It is sync, pure, allocation-light, and infallible by contract (the decode direction cannot fail; oversize is rejected at frame time) — a fuzz target is how that contract is demonstrated rather than believed.

  3. The stateful DoS class applies to us directly. One pre-fuzz code-review candidate, the analog of alkcall §6.2-1 (the early-arrival key-space): DatagramHalves.pending (src/consumer.rs:78) queues every datagram decoded from a chunk beyond the one returned to the caller. A single 16 MiB chunk of [00 00] pairs = ~8.4 M empty datagrams → ~8.4 M VecDeque entries (≈ 400+ MiB) from ONE peer chunk — a ~25–50× memory amplification riding a bounded channel buffer. Example-based tests structurally cannot find this; a byte-sequence target with a pending-bytes assertion will.

The honest caveat (mirroring alkcall §1): the highest-value alkcall targets — envelope framing, chunk header, demux, manager — are upstream and now fuzzed (0.8.1). Our params parsing is serde + registry-side jsonschema (upstream's spec_parse target covers attacker-shaped schemas compiling at registration). Expected yield here: datagram-codec pathologies, the pending-queue bound (§1.3), serde/untagged-enum edge cases in params, and lifecycle invariants in the two-pump shape. That is worthwhile, not five-targets-worth of new machinery on day one — hence the two-wave scope in §4. If the first campaigns find nothing, that is the successful-negative-result posture alkcall's §1 codified: "we think the codec is robust" becomes a demonstrated property, and the targets become regression protection for a wire-stable format (ADR-003).

2. Toolchain (inherited, not re-decided)

Identical to alkcall §4: cargo-fuzz 0.13.2 + arbitrary 1.4.x, nightly confined to the fuzz/ subtree via fuzz/rust-toolchain.toml, the crate itself stays stable at MSRV 1.88. AFL optional/deferred; bolero deferred (same async-friction argument); libafl/honggfuzz/OSS-Fuzz not adopted. Sanitizer: --sanitizer=address with debug assertions (cargo-fuzz defaults) — no MSAN (no unsafe in this crate).

Layout (the alkcall pattern, load-bearing):

fuzz/
├── Cargo.toml            alktunnels-fuzz (nightly-only bins; own [workspace])
├── rust-toolchain.toml   pins nightly for this subtree only
├── fuzz_targets/         thin fuzz_target! wrappers (3-line each)
│   ├── datagram_codec.rs
│   ├── params_parse.rs
│   └── session_pumps.rs
├── shared/               alktunnels-fuzz-shared — STABLE-toolchain library
│   └── src/…             invariant logic + corpus replay (§3)
├── corpus/<target>/      committed hand-made seeds (§3)
├── gen_fuzz_seeds.py     deterministic seed generator (quiche pattern)
├── run-detached.sh       §6 detached runner (CWD-independent)
└── README.md
  • Invariant logic lives in fuzz/shared/, not inline in the target binaries — the stable-toolchain corpus replay (cargo test --manifest-path fuzz/shared/Cargo.toml) runs the SAME invariant functions the fuzzer runs, on stable, in the release verification checklist. This is CI tier-3's deliverable (alkcall §7.2) minus automation; per alkcall §7.9 this repo has no hosted CI and will not get one (same gitea posture) — corpus replay as plain cargo test is the fuzz gate.
  • Root Cargo.toml needs the explicit [workspace] table (members = ["."], exclude = ["fuzz"]) and "fuzz/" on the publish exclude list — without it cargo auto-discovers fuzz/shared/ into the main workspace and the MSRV toolchain tries to build the nightly-consumed dev-deps (the exact failure alkcall hit).
  • Corpus policy: commit hand-made seeds, gitignore grown corpora (quinn/h2 model). Seeds per target: valid frames, truncation at every short prefix length, len = 0 / len = MAX / len = MAX+1, empty datagrams, batching (two frames per chunk), splitting (one frame per many chunks), and for params: every substrate value, unknown substrate, unknown/missing fields, listen: 0, malformed reply shapes, untagged cross-shape targets, deep/invalid JSON.

3. Invariants by target

3.1 datagram_codec (wave 1)

Input: raw &[u8] (and in the shared logic, chunk-slice parameter arbitrary). Drives DatagramReader::feed + frame_datagram (src/wire.rs). Invariants:

  • no-panic on any byte sequence (the baseline; the one unreachable! in feed is a structural invariant of the state machine — the target is how it stays true).
  • infallibility: feed never errors (contract — oversize is rejected at frame time; the decode direction is infallible by design, src/wire.rs docs).
  • datagram-size bound: every yielded datagram is <= MAX_DATAGRAM_LEN (u16 frame field makes this structural — the target pins it against future refactors).
  • no partial yields: a datagram completes only when exactly declared payload bytes are buffered (is_mid_datagram observable consistency); feed(&[]) never yields anything and never clears mid-datagram state.
  • round-trip: feed(frame_datagram(x)) == [x] for any x; the reverse direction write(feed-parse(x)) re-frames to the same frame for any well-framed input.
  • F-2 sentinel layering: len == 0 is a legal empty datagram, never EOF; the codec never emits zero-length datagrams for non-empty frames and never treats an empty datagram as stream end.
  • consumption accounting: re-framing all consumed bytes and feeding the byte-level remainder yields the same datagram stream (splitting a feed at any boundary is transparent).
  • oversize frame rejection: frame_datagram errors iff payload > MAX_DATAGRAM_LEN, echoing the sizes.
  • pending-queue bound (the §1.3 finding-class assertion): total bytes retained across buf + current + pending after a feed is bounded by the fed chunk size plus the one in-flight frame — an adversarial peer chunk cannot force unbounded queue growth in the decoder state. (NOTE: this is the decoder-state bound; the consumer-side DatagramHalves.pending backlog between chunk-read and recv_datagram pulls is a session-shape question tracked in §4/§8 — the wave-2 stateful target, or a code fix if the bound is judged unacceptable. The fuzzer documents the amplification factor; the remediation decision is ours. Wave-2 resolution (§3.3): the session-shape backlog is now an asserted invariant — the harness's per-op drain holds the pending queue to the routed-batch remainder (≤ one chunk's worth of batched datagrams, drained by recv_datagram pulls), so the amplification question is bounded by construction + assertion rather than remediated; the datagram_backlog() diagnostic exposes the live count.)

3.2 params_parse (wave 1)

Input: raw bytes → serde_json (with alkcall's bounded arbitrary_value approach — depth/width-capped Value generation) → TunnelParams / TunnelDirectParams / TunnelForwardedParams / SubstrateAddr deserialization + parse_listen_reply / listen_reply_fields (src/params.rs). Invariants:

  • no-panic on any JSON-shaped input (untagged-enum serde dispatch, deny_unknown_fields, the positive_u32 pin).
  • clean failure only: parse failures are Err, never panics; parse_listen_reply returns TunnelError::MalformedReply on malformed shapes.
  • round-trip for valid params: from_json(to_json(p)) == p for constructed params of every substrate.
  • deny-unknown posture: unknown top-level fields reject; untagged-variant leniency (extra fields inside a SubstrateAddr variant) is the documented asymmetry — asserted, not "fixed".
  • the listen: 0 pin: zero rejects at serde level.
  • projection agreement: listen_reply_fields yields both-Some iff parse_listen_reply is Ok; it never panics and never refuses construction (the lenient view projects malformed to both-None).
  • establishment-reason surface: establishment_reason never panics on arbitrary ChannelOpenError shapes.

3.3 session_pumps (wave 2)

Input: op sequences (#[derive(Arbitrary)] SessionSequence — the manager_routing pattern) over one live channels stack per exec: MuxRunner → raw chunk stream → the real run_demux_loop_for_client demux → ChannelManager per side (producer Connect / consumer Accept over one duplex transport — the reverse POC's topology). Ops: Route (producer→consumer framed datagrams / raw stream bytes via route_payload), AdoptDatagram / AdoptStream (the TunnelSession data-plane mirrors — codec reader + pending, and pump_bidi against in-process peer halves), Pump (peer→channel; len == 0 drops the p2c source — the EOF path), Teardown, ClearAll. Invariants:

  • no-panic / no-wedge on any op sequence (the baseline; the harness's 5 s reconcile bound turns a wedged pump into a finding, and §5's -timeout=25 fork containment is the outer net).
  • two-pump shutdown-on-completion (convention 8): after the p2c source drops, the pump's channel-side shutdown must propagate and the pump must complete once the teardown EOFs the c2p source — pinned by the reconcile join with the 5 s bound.
  • lossless p2c copy: the pump's p2c count equals exactly what the harness pushed into the peer half (copy never truncates).
  • EOF-sentinel teardown semantics: after a sentinel (teardown / clear_all), the reassembled stream drains its queue before reporting EOF — every routed datagram consumable, nothing lost; the consumer map empties; the codec's len == 0 sentinel layering holds at the session layer (a routed empty datagram is data; the channels EOF is a separate mechanism).
  • FIFO reconciliation: every complete frame routed through the real demux path arrives as exactly one datagram of the declared length, in order (the model walks the framing as the codec does — the wave-1 F-2 walk, reused at the session layer).
  • pending-queue bound (the §1.3 finding-class assertion): the session-shape DatagramHalves.pending backlog never exceeds the unrouted remainder — i.e. the backlog holds only datagrams decoded from routed chunks beyond the ones consumed; with the harness's per-op drain the queue returns to zero after every op, so an amplification factor > 1× chunk size would trip the assert. (The wave-1 decoder-state bound re-asserted per op alongside.)
  • monotonicity: the manager's early-arrival / dropped counters survive clear_all (the alkcall §7.7 pattern), and the consumer map empties on the transport-EOF path.

Bounds: 128 ops, ≤ 512-byte payloads (generator-clamped; the codec's MAX_DATAGRAM_LEN stays the wire bound). Even-id masking (ADR-047 §5 — the consumer is the Accept side; the producer allocates even).

4. Scope: two waves

Wave 1 (complete): the two targets above. Both drive already- pub APIs — no #[cfg(fuzzing)] exposure needed (matching alkcall's experience where none of five targets needed it).

Wave 2 (this session): session_pumps, the stateful op-sequence target over the two-pump tunnel shape in the alkcall manager_routing style (#[derive(Arbitrary)] op enum; live mux + demux runners; the alkcall harness lessons applied: even-id masking, drained-state asserts). Drives: the TunnelSession data-plane shapes (the datagram codec plane + the pump_bidi stream plane) over a REAL channels stack — the two-pump shutdown-on-completion contract (AGENTS.md convention 8), the EOF-sentinel teardown semantics, and the §1.3 backlog bound as byte-accounting invariants (§3.3). The forwarded_routing correlation target stays deferred until the assembly-side correlation registry (ADR-008 §3, assembly bookkeeping) grows into this crate.

Sequencing mirrors alkcall §7.4: init + wave-1 targets → 10–30 min campaigns per target via the detached runner → fix anything found → wave 2 (session_pumps) → further targets when justified.

5. Operational isolation: campaigns never share fate with the agent session

This is an environment constraint, not a nice-to-have (upstream doc §7.6, adopted verbatim as policy here). Agent sessions (opencode) run fuzzer builds/campaigns through a bash tool that spawns the fuzzer as a child of the session's own process tree and cgroup. A fuzz run is precisely the workload shape that kills its own host:

  • OOM-killer shared fate: the target's worst case IS unbounded allocation. If libFuzzer's soft RSS guard loses the race with the kernel, the OOM killer fires — and it picks the largest-RSS process in the cgroup, which can be the opencode server hosting the session. That is recoverable (sessions are recorded in a DB) but hugely derailing — the session's live environment access dies mid-task. An OOM in a fuzz target must cost the fuzzer, never the session.
  • Fork bombs / CPU saturation: -fork=N workers; a runaway campaign starves the session host.
  • Wedged targets: a deadlock in an async harness would hang a foreground bash call forever.

Three layers of defense, cheapest first — all mandatory:

  1. libFuzzer soft limits (always on): -rss_limit_mb=2048 -malloc_limit_mb=2048 make libFuzzer exit cleanly (artifact recorded) before the kernel gets involved. Note the RSS limit is soft (polls /proc/self/statm); a fast huge allocation can beat it — that is what layers 2–3 are for. Never ulimit -v with ASAN (it reserves terabytes of virtual address space); the ASAN-safe hard-limit recipe is -fork=1.
  2. Fork mode as blast-radius containment (default): -fork=1 (or a small number) runs each input in a short-lived child — a crash/timeout/OOM/leak kills only that child; the harness survives, records the artifact, keeps fuzzing. Also the deadlock answer: the per-input timeout kills the child, not the session's bash call.
  3. Process detachment from the session tree (MANDATORY for agent-run campaigns): never run fuzzing as a foreground child of the session. The runner script wraps cargo fuzz run in setsid + nohup + full redirection to a log file: (a) removes the fuzzer from the session's controlling terminal and signal relation, (b) survives the session ending, (c) gives the agent a pollable log + artifact directory instead of a blocking call. Combined with fork mode, an OOM-class finding costs one child process.

The runner (fuzz/run-detached.sh, CWD-independent — resolves its own repo root):

#!/usr/bin/env bash
# Detached fuzzing runner for agent sessions: the campaign never runs
# as a foreground child of the session (an OOM in a target must cost
# the fuzzer, never the agent host), and survives the session ending.
set -euo pipefail
target="${1:?usage: run-detached.sh <target> [extra libfuzzer args...]}"
shift
runtime="${FUZZ_RUNTIME_SECS:-600}"
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
log_dir="$root/fuzz/artifacts"
mkdir -p "$log_dir"
log="$log_dir/${target}-$(date -u +%Y%m%d-%H%M%S).log"
cd "$root"
setsid nohup cargo fuzz run "$target" -- \
  -fork=1 -rss_limit_mb=2048 -malloc_limit_mb=2048 -timeout=25 \
  -max_total_time="$runtime" "$@" \
  >"$log" 2>&1 < /dev/null &
echo "pid=$! log=$log"

Agent polling protocol (never wait on the detached process from a tool call — poll instead):

  • tail -n 50 fuzz/artifacts/<target>-*.log — campaign progress.
  • ls fuzz/artifacts/ — crash-* / oom-* / timeout-* / leak-* artifacts are the findings.
  • pgrep -f "cargo fuzz run <target>" — still running?
  • One detached campaign per target at a time; log filenames carry the UTC timestamp.

Flags worth pinning beyond the runner defaults: -max_len=65536 on the codec target; -use_value_profile=1; a JSON token -dict for the params target (alkcall's json.dict transfers as-is).

6. No hosted CI

Per alkcall §7.9 — the standing policy applies to this repo identically: no hosted CI will exist; do not add Actions/Gitea-Actions workflow files. Corpus replay is the fuzz gate as plain cargo test: cargo test --manifest-path fuzz/shared/Cargo.toml replays every committed seed through the invariant functions — add it to the release verification checklist (AGENTS.md). Campaigns run manually via the detached runner (§6) — e.g. before a release, or after touching src/wire.rs, the consumer data planes, or src/params.rs.

7. Implementation log

  • 2026-09-28 — proposal adopted (this doc). Wave-1 targets datagram_codec + params_parse; wave-2 deferred (§4).
  • 2026-09-28 — alkcall bumped 0.8.0 → 0.8.1. Bug-fix release, no API/wire change; the fix (duplicate adopt/open no longer destroying the live channel) is directly relevant to this crate's TunnelSession::adopt path — the changelog's "open-op response replayed / id re-announced" scenario is our reverse path. Verification: full suite (135 passed), clippy, fmt, wasm check (§8 commands).
  • 2026-09-28 — wave-1 implemented. fuzz/ workspace laid out per §2 (nightly-pinned subtree, stable fuzz/shared library, runner, dict, generator); datagram_codec + params_parse targets over already-pub APIs — no #[cfg(fuzzing)] exposure needed. One new public diagnostic: DatagramReader::retained_bytes() (the decoder-state byte count the §3.1 bound asserts against, in the is_mid_datagram diagnostic style). Seeds committed: 27 + 69 generated (fuzz/gen_fuzz_seeds.py) + 1 campaign-found crash input promoted to datagram_codec/seed-900 (see the finding below). Stable side: corpus replay green, shared crate clippy/fmt clean; main suite untouched and green; cargo fuzz build clean.
  • 2026-09-28 — first campaign finding: a HARNESS-model bug, not a crate bug. The datagram_codec campaign crashed in its first minute on input 00 2b 01 00 00 05 (fuzz/artifacts/datagram_codec/ crash-e9ba284e…, exit 77, fork-mode containment worked as designed — the harness parent survived and persisted the artifact). Analysis: the codec is CORRECT — 00 2b declares a 43-byte datagram; 01 00 00 05 is its in-flight payload; nothing is yielded. The invariant that tripped ("an empty datagram must come only from a [00 00] length header") was mis-modeled: it counted 00 00 byte pairs anywhere in the RAW stream, but a 00 00 pair sitting INSIDE a frame's payload region is payload, not a length header — exactly the same class as alkcall §7.7's probe (the harness claimed 4 + len when the prefix counts only the body). Fix: the count now walks the framing as the decoder does (complete frames from the start; the walk stops where the decoder would be stuck mid-frame). The crash input is committed as seed-900 and replays clean; the campaign relaunched clean.
  • 2026-09-28 — second harness finding (caught by corpus replay, pre-campaign): establishment_reason is a pass-through extractor, not a validated table. The params.rs doc names the pinned reason strings, but the upstream implementation (alkcall channels/client.rs establishment_reason) extracts details.reason AS-IS whenever the code is channel:open_failed — a made-up reason is surfaced as-is by design, and validation is the consumer's job. The harness first asserted a closed set of reason strings and corpus replay tripped it on made_up_reason before any campaign ran. Invariant corrected to assert the extraction shape (Some iff code == channel:open_failed and reason is a string).
  • 2026-09-28 — second harness finding (same replay pass): is_mid_datagram means a partial DATAGRAM, not buffered header bytes. A dangling 1-byte length prefix at input end is buffered header state (no frame in flight, is_mid_datagram == false); the harness first asserted true for that case. Corrected: mid-datagram iff the framing walk leaves a remainder ≥ 2 bytes (a parsed-but-unfinished payload), not != 0.
  • 2026-09-28 — wave-1 campaigns run (10-min detached runs, §6 runner; -fork=1 -rss_limit_mb=2048 -malloc_limit_mb=2048 -timeout=25; codec with -max_len=65536, params with -dict=json.dict -use_value_profile=1). Both exited 0.
    • params_parse — 3.14M execs (~5.3k exec/s), 33 fork jobs, oom/timeout/crash: 0/0/0 throughout. Coverage 2049 edges / 15.6k features; in-memory corpus 69 committed seeds → 3018 entries — the serde/untagged-enum shape space was still expanding at budget end, so longer campaigns keep paying here.
    • datagram_codec (post-harness-fix) — 827k execs, 33 fork jobs, oom/timeout/crash: 0/0/0 throughout. Coverage saturated at 221 edges (ft 1198) — the 2-byte-header state machine is fully enumerated, matching alkcall §7.7's chunk_header saturation profile. Exec/s reads low (~400) because each exec replays the input through a ~35-feed 3-byte-sliced sequence alongside the whole-input feed; the state space is small and exhausted early.
    • The pre-fix datagram_codec run (the §7 finding above) crashed at job 4 and exited 77; fork-mode containment behaved as designed — the harness survived, persisted the artifact, and the run wrapped up. That artifact is the promoted seed-900.
    • Grown corpora (3018 params entries) were not merged into the committed seeds — per the §2 policy, grown corpora stay gitignored.
    • Operational note for future sessions: cargo fuzz run returns before its last fork-mode children fully exit; a pkill -f "cargo fuzz run <target>" followed immediately by another pgrep of the same pattern can wedge a bash tool call on its own process match — prefer pgrep -af to LIST, kill by explicit pid from the runner's echoed pid=, and never pattern-kill-and-recheck in one call.
  • NO crate bugs found by wave 1. The two findings this session were both harness-model defects (§7 above), each invisible to example-based review and caught by fuzzing/replay — the alkcall §7.7 "fuzzer as a harness-checker" pattern, confirmed. The §1.3 decoder-state bound held everywhere: retained_bytes() never exceeded fed + one frame on any adversarial input. The session-shape DatagramHalves.pending amplification remains a documented §1.3/§4 note (wave-2's motivation), not a campaign finding — the wave-1 targets assert the decoder, not the session pump.
  • 2026-09-28 — wave-2 implemented: session_pumps (the stateful two-pump/EOF-sentinel target, §3.3). The harness mirrors TunnelSession's data-plane shapes over one REAL channels stack per exec (MuxRunner → raw chunk stream → the real run_demux_loop_for_client demux → ChannelManager per side over one duplex transport; producer Connect / consumer Accept — the reverse POC's topology). Ops: Route/AdoptDatagram/AdoptStream/ Pump/Teardown/ClearAll; invariants per §3.3. One diagnostic addition to the crate: TunnelSession::datagram_backlog() (the DatagramHalves.pending count the §3.3 bound asserts against, in the retained_bytes() diagnostic style) + a suite test pinning its rest states. Seeds: 8 committed (deterministic decode-scan hex patterns, the manager_routing precedent — arbitrary ships no encoder) + 2 in-source semantic fixtures (the §1.3 batched- backlog probe; the W4 half-close shape). Two harness-model findings caught by corpus replay pre-campaign:
    • the p2c-EOF pump-join wedge is the DESIGNED half-close shape, not a wedge. The first reconcile draft awaited the pump after dropping only the p2c source — but the c2p pump's source (the channel read half) EOFs only via teardown, so the join legitimately blocked (the 5 s bound tripped). pump_bidi completes when BOTH pumps finish; the half-close contract is that the p2c completion shuts the channel side down (the observable), and the pump completes once the c2p source EOFs too. The harness now models exactly that: drop p2c source → teardown the entry → join with the 5 s bound. (The alkcall §7.7 harness-checker pattern again — the fuzzer harness's first campaign-found crash in wave 1 and this replay catch are both harness-model defects, not crate bugs.)
    • the stream-plane c2p accounting is asynchronous — the pump writes channel→peer into the peer duplex while a far-end reader task counts; the reconcile join (after the pump completes) is when the count is final. First draft read it early. Fixed by reading after the reader task's join.
  • 2026-09-28 — third harness finding (campaign-found, fork-mode containment as designed): duplicate adopt is a LEGAL manager outcome, not a finding. The first campaign crashed at ~49 s on AdoptDatagram { channel_id: 0 } after the id had already been adopted — ChannelExists is the manager's documented id-collision error, and my adopt arms treated every error as a finding. Fix: both adopt arms model the documented error table (ChannelExists / TooManyChannels are no-ops, exactly the alkcall manager_routing harness's treatment). The crash input is committed as seed-900 and replays clean; the campaign relaunched clean.
  • 2026-09-28 — fourth harness finding (second campaign): the harness modeled the producer→consumer path through the WRONG primitive. The second campaign crashed at ~5 s on Route: raw-id addressing (even_id masked arbitrary ids; collisions + an UnknownChannel-class state the model didn't track), len == 0 raw routes doubling as EOF sentinels mid-model, and — the structural one — route_payload was used as the producer's forward write. route_payload routes into the LOCAL manager's channel entry (the demux direction); the producer pump handler writes the channel's SEND half (the mux pump frames onto the transport). Bytes never reached the consumer. Rewrite (the real protocol shape, matching manager_routing's op-vocabulary lesson): producer Open (open_channel allocates the id, the harness holds the send half — the pump handler's write side), session-INDEX addressing (no masking, no collisions), Adopt consumes the producer-allocated id (the early-arrival drain is the real adopt-race path), Route writes the producer send half (the real wire path: mux pump → transport → consumer demux → reassembly), Pull (the recv_datagram pull pops the backlog + the model), and a STATEFUL framing model (partial frames persist across ops — the wave-1 walk carried across ops). EOF sentinels now arise only from teardown/sender-drop (the real mechanisms), never from len == 0 routes (F-2: a routed empty datagram is DATA). All committed session_pumps seeds regenerated by the rewrite (the old hex patterns decode against the new op enum); the semantic fixtures re-pinned. Verification of the fix: the campaign's 0-exec crash went away; batched frames now reconcile FIFO through the real two-manager stack.
  • 2026-09-28 — fifth + sixth harness findings (third campaign's first minutes): the two ClearAll modeling defects. (5) A double-adopt of a live session panicked the harness's own guard — but a re-adopt of a LIVE id is the manager's documented ChannelExists outcome (a legal no-op; alkcall 0.8.1's insert-replace fix leaves the live entry intact). Modeled as a no-op. (6) A ClearAll followed by Route on the same session tripped the backlog reconciliation — the harness retired only the sessions whose ids appeared in clear_all's RETURN value, but that value is the OPENER-LEDGER records (ADR-047 §7): adopted channels carry no ledger entry, so the set is empty while the map itself empties. The alkcall §7.7 lesson ("drained.len() == open_count() is not an invariant"), learned again at this crate's layer — the map-empties assert is the ground truth; the harness now retires EVERY session on ClearAll.
  • 2026-09-28 — wave-2 campaign run (10-min detached run, §6 runner; -fork=1 -rss_limit_mb=2048 -malloc_limit_mb=2048 -timeout=25 -max_len=4096 -use_value_profile=1): exited 0. 228,676 execs (~380 exec/s; each exec replays up to 128 ops on a live two-manager stack with real mux/demux tasks), 32 fork jobs, oom/timeout/crash: 0/0/0 throughout. Coverage 3427 edges / 17,529 features / 1184 in-memory corpus entries and STILL EXPANDING at budget end (the op-interleaving space is much larger than the parser targets') — longer campaigns keep paying. The two-pump contract held across every adversarial sequence: no wedged pump (the 5 s reconcile join), no lost datagram (the FIFO/backlog reconciliation), no premature EOF (the sentinel layering). The §1.3 backlog bound held everywhere: the DatagramHalves.pending queue never exceeded the routed-but-unpulled remainder.
  • NO crate bugs found by wave 2. All six findings across waves 1–2 were harness-model defects (four this session), each invisible to example-based review and caught by fuzzing/replay — the alkcall §7.7 "fuzzer as a harness-checker" pattern, re-confirmed. The stateful thesis is validated from the harness side: the harness defects themselves (double-adopt semantics, the clear_all ledger/map distinction, the route_payload direction) are exactly the interleaving-shape mistakes the alkcall §7.8 campaigns caught in their own drivers.
  • (progress appended as implementation proceeds — see git log)

8. Verification commands

cargo test                                    # main suite (stable)
cargo test --manifest-path fuzz/shared/Cargo.toml   # corpus replay (fuzz gate)
cargo fuzz build                              # in fuzz/ (nightly, ASAN)
fuzz/run-detached.sh <target>                 # campaigns (never foreground)
cargo clippy --all-targets -- -D warnings
cargo fmt --check
cargo clippy --manifest-path fuzz/shared/Cargo.toml --all-targets -- -D warnings
cargo fmt --manifest-path fuzz/shared/Cargo.toml --check
cargo check --target wasm32-unknown-unknown   # default crate stays wasm-clean

Campaign flags per target: datagram_codec -max_len=65536; params_parse -dict=json.dict -use_value_profile=1; session_pumps -max_len=4096 -use_value_profile=1.

9. References

  • Upstream campaign + operational model (authoritative for tooling, runner, corpus policy, no-CI posture): /workspace/@alkdev/alkcall/docs/research/fuzzing.md
  • alkcall 0.8.1 changelog — the finding our adopt path is exposed to
  • This crate: ADR-003 (codec framing), ADR-002 (ALPN), ADR-007/008 (direct/forwarded ops), docs/architecture/wire.md
  • External: RUSTSEC-2026-0037 (quinn-proto — the maintainers' fuzzing-coverage statement), rust-fuzz trophy case, cargo-fuzz Rust Fuzz Book (all linked from the upstream doc's §9)