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:
1 parent
e72e1fc4c7
commit
fb7cfe3851
22 files changed
+1549
-11
No files matched your search
+59
-5
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user