feat(fuzz): session_opseq target - stateful op-sequence campaign against the session pump

Target 4 from the fuzzing plan (the alkcall-bug-class hunt): a
#[derive(Arbitrary)] op sequence drives the producer session pump
(drive_session_pre_negotiated) over a duplex pair against a fuzz-local
mock backend.

- fuzz/shared/src/session_opseq.rs: op vocabulary (client writes incl.
  invalid-type/oversize-length barrier probes, backend production/EOF,
  exit resolve/fail/drop, control messages, bounded reads, yields),
  kill-guard exit future (ADR-005 observability), delivery model, and
  the invariants: kill-on-Drop (kill_fired == !exit_resolved), exit
  chunk is last with first-issued code -1 on wait-failure (ADR-004),
  lossless per-stream FIFO + single stdout sentinel, stdin
  prefix-losslessness (post-shutdown chunks never delivered), control
  dispatch bounds, take-once allocation, teardown termination.
- Harness-model correction caught by a 60s smoke run before the
  campaign (the alkcall §7.7 pattern, third time): a failed client
  write can mean the session completed and dropped the server duplex
  half while the write was in flight - the harness now drains and
  requires the exit chunk on any write/shutdown error (premature close
  without it is a finding). No adapter code changed.
- 12 committed seeds via the deterministic generator, hand-encoded
  against the arbitrary 1.4.x derive layout; the encoding is pinned by
  a seed-decode test. Existing chunk_frame/negotiation_frame/
  control_json seed corpora are byte-identical to before.
- fuzz/.gitignore: the per-target seed negations never matched (corpus/*
  excludes the parent dir; git cannot re-include beneath an excluded
  dir). Fixed with the !corpus/*/ + corpus/*/* + !corpus/*/seed-*
  recipe - new seed files were silently ignored until now.
- Campaign: 10 min detached via run-detached.sh, exited 0, artifact
  dir empty - 42.3k execs across 33 fork jobs, 0 crash/oom/timeout,
  cov 3934 -> 4075 edges. Grown corpus excised per the corpus policy.

Verification: corpus replay green on stable (9 tests), cargo test 113
+ --all-features 156, clippy stable/wasm/fuzz-shared, fmt, doc,
publish dry-run, wasm check - all pass.
This commit is contained in:
glm-5.3-flash committed 2026-09-28 10:06:00 +00:00
1 parent e72e1fc4c7
commit fb7cfe3851
22 files changed
+1549 -11

No files matched your search

+59 -5
View File
@@ -144,7 +144,7 @@ alkcall §7.4).
| 1 | `chunk_frame` | raw `&[u8]` | `ChunkReader`/`ChunkWriter` over `Cursor` + `block_on` | no-panic; error-shape partition (`ConnectionClosed` only on truncation; `InvalidStreamType` iff `stream_type > 4`; `ChunkTooLarge` iff `length > MAX_CHUNK_LEN`; `Io` impossible on a Cursor); Ok iff header complete + payload complete; round-trip (`write_chunk` then `read_chunk` reproduces stream_type + bytes); 5-byte consumption accounting; peek interleave (`peek_stream_type` → `read_chunk_after_peek` == `read_chunk`, idempotent peek, peek state resets per chunk); never allocates for out-of-range headers |
| 2 | `negotiation_frame` | raw `&[u8]` | `NegotiationReader`/`NegotiationWriter` + `NegotiateRequest` serde parse + `error_response_bytes` | no-panic; error-shape partition (`ConnectionClosed` only on truncation; `FrameTooLarge` iff `length > MAX_CHUNK_LEN`; `Json` only when the full body was present; `Io` impossible); consumption accounting (4 + length); negotiation JSON round-trip (`NegotiateRequest` → `to_json` → frame → `from_slice` → equal struct); **cross-codec disambiguation** (see §4.1) |
| 3 | `control_json` | raw `&[u8]` | `ControlMessage::from_slice` / `to_json`, `signal_from_name` | no-panic on any bytes; clean serde errors (never silent); `to_json` → `from_slice` structural round-trip; `signal_from_name` is total (returns `Option`, never panics) |
| 4 | `session_opseq` (second wave) | `#[derive(Arbitrary)]` op sequence | the producer session pump + mock backend (`src/testing.rs`, `drive_session`) | no-panic; take-once slot semantics; duplicate/replayed session ops leave the live session intact — the alkcall-bug-class target |
| 4 | `session_opseq` | `#[derive(Arbitrary)]` op sequence | the producer session pump (`drive_session_pre_negotiated`) + a fuzz-local mock backend (`fuzz/shared/src/session_opseq.rs`; the in-crate `MockBackend` is `#[cfg(test)]`-only) | no-panic (driver, pump tasks via the session `JoinError`, harness model); **kill-on-Drop (ADR-005)** — after teardown `kill_fired == !exit_resolved`, and with no exit op issued the kill MUST have fired; **exit chunk is last (ADR-004)** — at most one ctrl_out chunk, `type == "exit"`, `code` matches the first-issued exit op (`-1` on wait-failure or dropped exit sender), nothing observed after it, server never writes stream types 0/3; lossless per-stream FIFO of produced `(len, byte)` patterns with exactly one stdout sentinel (only after the stdout-EOF op) and no stderr sentinel; stdin prefix-losslessness (post-shutdown chunks never delivered); control dispatch never over-counts and dispatches promptly pre-exit; take-once allocation; teardown terminates within the bound |
### 4.1 Cross-codec disambiguation invariant (target 2, the subtle one)
@@ -182,9 +182,10 @@ fuzz/
├── fuzz_targets/
│ ├── chunk_frame.rs thin fuzz_target! wrapper
│ ├── negotiation_frame.rs thin wrapper
│ └── control_json.rs thin wrapper
│ ├── control_json.rs thin wrapper
│ └── session_opseq.rs thin wrapper (typed SessionSequence input)
├── shared/ alktty-fuzz-shared — STABLE-toolchain library
│ └── src/{chunk_frame,negotiation_frame,control_json}.rs
│ └── src/{chunk_frame,negotiation_frame,control_json,session_opseq}.rs
├── corpus/<target>/ committed seeds
├── gen_fuzz_seeds.py deterministic seed generator
├── run-detached.sh §3 detached runner (CWD-independent)
@@ -200,11 +201,17 @@ Root `Cargo.toml` changes (the alkcall §7.7 footguns, both required):
No `#[cfg(fuzzing)]` exposure expected — `ChunkReader`/`ChunkWriter`,
`NegotiationReader`/`NegotiationWriter`, `NegotiateRequest`,
`ControlMessage`, and `error_response_bytes` are all already `pub`
`ControlMessage`, `error_response_bytes`, `drive_session_pre_negotiated`,
and the `TtyBackend`/`TtyHandle`/`TtyParams` shapes are all already `pub`
(alkcall needed none of its five either). The one private constant,
`MAX_CHUNK_LEN`, is wire-stable (ADR-001); the shared crate carries its
own copy, and the shape invariants assert the rejection boundary so a
drift is caught.
drift is caught. The `session_opseq` harness is fuzz-local for the same
reason `MockBackend`/`TestBackend` are `#[cfg(test)]`-only: a test
backend in `src/` would either leak into the public API or force the
whole mock apparatus behind `#[cfg(any(test, fuzzing))]`; the shared
crate is the fuzz-side home for it (mirrors alkcall, whose `manager_routing`
harness also lives in `fuzz/shared/`).
## 6. Corpus policy
@@ -232,6 +239,7 @@ deterministic (no randomness) so seeds are reproducible.
5. Detached campaigns (10 min per target via `run-detached.sh`), triage
anything found, record results in §8.
6. Decide on target 4 (`session_opseq`) after 1–3 are clean.
**Status: landed — see §8. All four targets are in scope and live.**
## 8. Progress log (append-only across sessions)
@@ -273,6 +281,52 @@ deterministic (no randomness) so seeds are reproducible.
§6 policy; committed seeds unchanged.
- §7 step 6 (target 4 `session_opseq`) remains open for a follow-up
session.
- **2026-09-28 (later still)** — **§7 step 6 landed: target 4
`session_opseq`. All four targets are now live.**
- `fuzz/shared/src/session_opseq.rs`: `SessionOp`/`SessionSequence`
(`#[derive(Arbitrary)]`, 12-op vocabulary — client writes incl.
barrier probes, backend production/EOF, exit resolve/fail/drop,
control messages, bounded reads, yields) + a fuzz-local
`FuzzBackend` (the in-crate `MockBackend` is `#[cfg(test)]`-only) +
the harness driving `drive_session_pre_negotiated` over a
`tokio::io::duplex` pair, with a delivery model mirroring §4's
invariants. 12 committed seeds via the deterministic generator
(hand-encoded against the `arbitrary` 1.4.x derive layout — variant
tag `(u32_le * 12) >> 32`, fields LE zero-filled, keep-going byte
gating each `Vec` element; the encoding is pinned by a decode test
asserting the exact ops each seed decodes to).
- Invariants encoded: kill-on-Drop (ADR-005), exit-chunk-is-last
(ADR-004), lossless per-stream FIFO + sentinel semantics, stdin
prefix-losslessness (post-shutdown chunks never delivered), control
dispatch bounds, take-once allocation, teardown termination.
Harness bounds: 128 ops, 4 KiB/chunk, 32 KiB produce/write caps,
unbounded backend stdin (control dispatch stays prompt).
- **Harness-model correction before the campaign** (the alkcall §7.7
pattern repeating, third time): a 60s smoke run crashed on
`ClientWrite` after the session had completed — a failed client
write can mean the *server half of the duplex dropped* because the
session finished (exit resolved → pumps join → exit chunk →
drainer exits → duplex halves drop) while the write was in flight.
The harness now treats any write/shutdown error as "the session
closed": it drains the read side and requires the exit chunk (a
close without it is a premature close — a real bug). No adapter
code changed; the finding was a harness-model gap.
- Corpus replay green on stable (9 tests, incl. the seed-decode
pin). Full checklist green: 113 + 156 all-features tests, clippy
stable/wasm + fuzz-shared clippy, fmt, doc, publish dry-run, wasm
check.
- Campaign: **10 min detached via `run-detached.sh`, exited 0 with an
empty artifact directory — no crashes, hangs, OOMs, or leaks** (all
33 fork jobs `oom/timeout/crash: 0/0/0`). 42.3k execs at ~74/s per
job (the op-seq target is stateful — each input runs a full tokio
session with bounded reads/yields, so exec/s is inherently tens, not
millions; the value is interleaving exploration). Cov grew
3934 → 4075 edges over the run, still finding coverage at budget
end. Grown corpus excised per the §6 policy; 12 committed seeds
unchanged; corpus replay green after.
- §7 fuzzing plan complete: all four targets live, corpus replay is
the standing gate, campaigns pre-release and on adapter/pump/wire
changes.
## 9. Relationship to existing tests