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.
32 KiB
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:
-
alkcall's fuzz doc already assigned this to us. Its §5 "non-surfaces (deliberately)" note ends: the channels layer carries opaque
Bytesand the handler owns sub-stream framing (ADR-035) — those downstream parsers are downstream crates' fuzz targets. Ourwire.rsdatagram codec IS that named downstream target. Fuzzing it completes alkcall's fuzz scope rather than duplicating it. -
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. -
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 MVecDequeentries (≈ 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 plaincargo testis the fuzz gate. - Root
Cargo.tomlneeds the explicit[workspace]table (members = ["."],exclude = ["fuzz"]) and"fuzz/"on the publish exclude list — without it cargo auto-discoversfuzz/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!infeedis a structural invariant of the state machine — the target is how it stays true). - infallibility:
feednever errors (contract — oversize is rejected at frame time; the decode direction is infallible by design,src/wire.rsdocs). - 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
declaredpayload bytes are buffered (is_mid_datagramobservable consistency);feed(&[])never yields anything and never clears mid-datagram state. - round-trip:
feed(frame_datagram(x)) == [x]for any x; the reverse directionwrite(feed-parse(x))re-frames to the same frame for any well-framed input. - F-2 sentinel layering:
len == 0is 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_datagramerrors iff payload >MAX_DATAGRAM_LEN, echoing the sizes. - pending-queue bound (the §1.3 finding-class assertion): total
bytes retained across
buf+current+pendingafter 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-sideDatagramHalves.pendingbacklog between chunk-read andrecv_datagrampulls 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 byrecv_datagrampulls), so the amplification question is bounded by construction + assertion rather than remediated; thedatagram_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, thepositive_u32pin). - clean failure only: parse failures are
Err, never panics;parse_listen_replyreturnsTunnelError::MalformedReplyon malformed shapes. - round-trip for valid params:
from_json(to_json(p)) == pfor constructed params of every substrate. - deny-unknown posture: unknown top-level fields reject;
untagged-variant leniency (extra fields inside a
SubstrateAddrvariant) is the documented asymmetry — asserted, not "fixed". - the
listen: 0pin: zero rejects at serde level. - projection agreement:
listen_reply_fieldsyields both-Someiffparse_listen_replyisOk; it never panics and never refuses construction (the lenient view projects malformed to both-None). - establishment-reason surface:
establishment_reasonnever panics on arbitraryChannelOpenErrorshapes.
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=25fork 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
reconcilejoin with the 5 s bound. - lossless p2c copy: the pump's
p2ccount 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'slen == 0sentinel 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.pendingbacklog 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=Nworkers; 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:
- libFuzzer soft limits (always on):
-rss_limit_mb=2048-malloc_limit_mb=2048make 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. Neverulimit -vwith ASAN (it reserves terabytes of virtual address space); the ASAN-safe hard-limit recipe is-fork=1. - 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. - 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 runinsetsid+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 —
alkcallbumped 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'sTunnelSession::adoptpath — 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, stablefuzz/sharedlibrary, runner, dict, generator);datagram_codec+params_parsetargets over already-pubAPIs — no#[cfg(fuzzing)]exposure needed. One new public diagnostic:DatagramReader::retained_bytes()(the decoder-state byte count the §3.1 bound asserts against, in theis_mid_datagramdiagnostic style). Seeds committed: 27 + 69 generated (fuzz/gen_fuzz_seeds.py) + 1 campaign-found crash input promoted todatagram_codec/seed-900(see the finding below). Stable side: corpus replay green, shared crate clippy/fmt clean; main suite untouched and green;cargo fuzz buildclean. - 2026-09-28 — first campaign finding: a HARNESS-model bug, not a
crate bug. The
datagram_codeccampaign crashed in its first minute on input00 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 2bdeclares a 43-byte datagram;01 00 00 05is 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 counted00 00byte pairs anywhere in the RAW stream, but a00 00pair 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 claimed4 + lenwhen 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_reasonis a pass-through extractor, not a validated table. The params.rs doc names the pinned reason strings, but the upstream implementation (alkcallchannels/client.rsestablishment_reason) extractsdetails.reasonAS-IS whenever the code ischannel: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 onmade_up_reasonbefore any campaign ran. Invariant corrected to assert the extraction shape (Some iff code ==channel:open_failedand reason is a string). - 2026-09-28 — second harness finding (same replay pass):
is_mid_datagrammeans 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 assertedtruefor 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/0throughout. 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/0throughout. Coverage saturated at 221 edges (ft 1198) — the 2-byte-header state machine is fully enumerated, matching alkcall §7.7'schunk_headersaturation 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_codecrun (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 runreturns before its last fork-mode children fully exit; apkill -f "cargo fuzz run <target>"followed immediately by anotherpgrepof the same pattern can wedge a bash tool call on its own process match — preferpgrep -afto LIST, kill by explicit pid from the runner's echoedpid=, 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-shapeDatagramHalves.pendingamplification 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 mirrorsTunnelSession's data-plane shapes over one REAL channels stack per exec (MuxRunner → raw chunk stream → the realrun_demux_loop_for_clientdemux →ChannelManagerper 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()(theDatagramHalves.pendingcount the §3.3 bound asserts against, in theretained_bytes()diagnostic style) + a suite test pinning its rest states. Seeds: 8 committed (deterministic decode-scan hex patterns, themanager_routingprecedent — 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
reconciledraft 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_bidicompletes 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.
- the p2c-EOF pump-join wedge is the DESIGNED half-close
shape, not a wedge. The first
- 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 —ChannelExistsis 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/TooManyChannelsare no-ops, exactly the alkcallmanager_routingharness'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_idmasked arbitrary ids; collisions + anUnknownChannel-class state the model didn't track),len == 0raw routes doubling as EOF sentinels mid-model, and — the structural one —route_payloadwas used as the producer's forward write.route_payloadroutes 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, matchingmanager_routing's op-vocabulary lesson): producerOpen(open_channelallocates the id, the harness holds the send half — the pump handler's write side), session-INDEX addressing (no masking, no collisions),Adoptconsumes the producer-allocated id (the early-arrival drain is the real adopt-race path),Routewrites the producer send half (the real wire path: mux pump → transport → consumer demux → reassembly),Pull(therecv_datagrampull 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 fromlen == 0routes (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
ChannelExistsoutcome (a legal no-op; alkcall 0.8.1's insert-replace fix leaves the live entry intact). Modeled as a no-op. (6) AClearAllfollowed byRouteon the same session tripped the backlog reconciliation — the harness retired only the sessions whose ids appeared inclear_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 onClearAll. - 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/0throughout. 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: theDatagramHalves.pendingqueue 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)