- Rewrote the remaining em dashes outside docs/reviews/ (preserved as historical record, matching the alktty precedent): tests/ (313), fuzz/ (34 plus one shell comment), tasks/ (671 plus 12 kept in fences), docs/research + sdd_process + plans (525 plus 25 in fences), docs/architecture/overview.md fence labels (13), README, CHANGELOG, AGENTS.md, fuzz/README.md (176), and the .opencode/agents prompts (110) - Rewrote fenced content too: ASCII tree and module-map label separators, Rust snippet comments, and template placeholders; wire data, JSON/BAST examples, and transcripts survive verbatim - Rewrote 6 assert/expect message literals in tests/ and 2 format strings in fuzz/shared/src; no test data changed - Added Project Convention 18: no em dashes in prose (doc comments, markdown including docs/ and tasks/, agent prompts, commit messages); fence content rule, en-dash clause rule, and the no-spaced-hyphen prohibition follow alktty's convention 15 Follows6b20924(src doc comments) andcd0afe0(docs/architecture). Verification: cargo test (141 pass); cargo clippy --all-targets -D warnings; cargo fmt --check; cargo doc --no-deps; wasm check and clippy for wasm32-unknown-unknown with -D warnings; em dash grep over the repo returns matches only in docs/reviews/
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 to 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), and 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, the 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, so 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, with every routed datagram consumable and 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, with 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, since 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, and 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, so 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, which (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, since it
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, with 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, since00 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, which is 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, so 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, with the harness surviving, persisting the artifact, and the run wrapping up. That artifact is the promoted seed-900. - Grown corpora (3018 params entries) were not merged into the committed seeds, since 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, since 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, since 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, with 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'), so 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)