PLAN-bench phase 05: write tests¶
Prompt¶
Before responding to questions or discussion points in this
document, explore the instar codebase thoroughly. Read relevant
source files, understand existing patterns (the commit guest op's
allocate-on-write composition, the snapshot crate's allocator and
refcount mutators, bitmap's RW-input staging idiom, the bench op
and host CLI as they stand after phase 4), and ground your
answers in what the code actually does today. The master plan's
"Findings: allocating-write reuse for -w on qcow2 (OQ4, step
1d)" section is the authoritative reuse map — read it in full;
every file:line reference below comes from it or from the
phase-3/4 surveys. Do not speculate when you could read instead;
flag uncertainty explicitly.
Phase plans live alongside the master plan
(PLAN-bench.md) in docs/plans/. This is the
fifth of eight phases; phases 1-4 landed the schedule crate, the
ABI, the guest read op, and the host CLI (read benchmarks work
end-to-end on all five formats with header byte-parity).
I prefer one commit per logical change, and at minimum one commit per phase. Each commit should be self-contained: it should build, pass tests, and have a clear commit message explaining what changed and why.
Situation¶
This phase makes -w real for raw and qcow2:
--pattern/--flush-interval/--no-drain semantics, the
RW-input attach with fsync_input flush wiring (OQ5, resolved in
phase 2), allocating qcow2 writes per the OQ4 reuse verdict, and
refusals for vmdk/vhd/vhdx write tests. qemu's verified write
behaviour (master plan): ordinary format-layer writes filling the
buffer with the pattern byte — and because qemu's per-slot iovecs
are dead code (every in-flight request writes the same
qiov[0]), qemu never writes request-distinguishable data
either, so after identical -w runs the instar and qemu images
are byte-comparable. Phase 6 exploits that.
Grounding:
- The reuse map (OQ4/1d, verified then): the commit guest op
(
src/operations/commit/src/main.rs:711-785) is the working allocate-on-write template (allocate+zero L2 when the L1 slot is empty, allocate a data cluster when the L2 slot is empty, write data, set bothOFLAG_COPIEDflags), with metadata flush ordering at:787-834. The pure primitives live incrates/snapshot(alloc_contiguous_clusters_in_refblocks+AllocCursorsrc/crates/snapshot/src/qcow2.rs:299-435, refcount RMW accessors:51/:152/:239, COPIED rewriters:450-532) andcrates/commit(L2/refcount disk-offset mathsrc/crates/commit/src/qcow2.rs:296-321). bitmap (src/operations/bitmap/src/main.rs) is the RW-input-slot-0 +fsync_input(0)precedent. Commit'swrite_output_byte_range(src/operations/commit/src/main.rs:175) is the sub-sector RMW idiom (read covering sectors, patch, write back) — bench needs the input-device analog. - Net-new pieces the findings enumerated: the per-offset driver; the overwrite-in-place fast path (allocated + COPIED ⇒ data write only); staging the target's own refcount table/blocks (commit stages the backing's — directly analogous host pre-read plumbing); sub-cluster fill-then-patch for freshly allocated clusters; and the snapshot gate.
- The write envelope (union of the sister mutators, plus one):
refcount_bits == 16only; no compression, no extended-L2, no external data file, no LUKS, not dirty/corrupt; **and `nb_snapshots0
refused** — commit blind-overwrites without a COPIED check because it assumes unique ownership; bench must not corrupt snapshot-shared clusters. Allocation never grows the refcount table (RefcountExhausted` → clean refusal; one 16-bit refblock at 64 KiB clusters covers 2 GiB of address space, so this bites rarely and is a documented caveat). - Phase 3/4 state: the guest op refuses
FLAG_WRITEwithERROR_BAD_CONFIG(placeholder branch, commented); the host refuses-winvalidate_bench_args(bench: write tests (-w) are not yet supported); the flush line renderer andflushes-issuedJSON field are already wired;flush_after_completion/total_flushessit unit-tested incrates/bench; the read path attaches the whole chain read-only.fsync_input(u32)(call-table slot, VERSION 17) fdatasyncs an RW input slot; the host stub returns false for RO slots. - The read dispatch stays available in write mode —
read_chain_virtual_range/read_chain_virtual_clusterover the sameChainStates— which is what makes backing-chain COW cheap (below).
Mission¶
1. Semantics: what a bench write is¶
For each scheduled offset, write bufsize bytes of the pattern
byte at that virtual offset through the format layer, exactly as
a guest OS write would land:
- raw: patch
[offset, offset+bufsize)in the flat file. With the 65536-byte transport sector, arbitrary offsets need sub-sector RMW: read the covering sectors, patch the window, write back (awrite_input_byte_rangehelper mirroring commit'swrite_output_byte_range, targeting RW input slot 0). - qcow2: per touched cluster (a request may straddle a cluster boundary — split at cluster granularity like the read path does):
- Overwrite fast path: L2 entry allocated +
OFLAG_COPIED⇒ patch the data cluster in place (sub-sector RMW against the host cluster offset). No metadata changes. - Allocating path: allocate a data cluster
(
alloc_contiguous_clusters_in_refblocksover the staged refblocks); fill it with the cluster's current virtual content via the chain read (read_chain_virtual_clusterat this device with the entry still unallocated — falls through to the backing chain, or yields zeros when there is none: one uniform rule that makes overlay COW correct and covers the zero-fill fresh-cluster case); patch the pattern window; write the full data cluster; update the L2 entry (host offset + COPIED); if the L1 slot had no L2 table, allocate + zero one first and update the L1 entry (+COPIED). - Writes through the backing chain never touch parent images: all allocation and data lands in the top image only; parents stay attached read-only.
2. Metadata write-back architecture (the crash-ordering decision)¶
Decision: write-through L2/L1, staged refcounts, refcounts last.
- Data cluster writes go straight to the file (virtio RW input).
- L2 (and L1) updates are written through immediately after the data they point to — the update is a covering-sector RMW of the table cluster on disk. Ordering per update: data cluster first, then the L2 entry that makes it reachable, then (only when a new L2 was allocated) the L1 entry. No fsync between — matching qemu's promise level, which orders metadata through its cache without syncing mid-run.
- Refcount updates accumulate in staged refblocks (the
bitmap/snapshot staging idiom, host-preloaded like commit
pre-reads the backing's), written back at every flush point and
once after the loop. Refcounts-last means a crash mid-bench
leaves leaked clusters (allocated data reachable via L2 but
under-refcounted →
qemu-img checkreports leaks, repairable), never a dangling L2 pointer — the same benign artifact class a qemu crash mid-bench produces.
Why not full staging of L2s: the request schedule can scatter
75000 requests across arbitrarily many L2 tables (large step over
a large image), so any staged-L2 budget is a refusal waiting to
happen; write-through has no cap and per-update ordering falls
out naturally. Why not write-through refcounts: they are the
high-frequency RMW target (every allocation), staging them is
bounded (REFBLOCKS_LIMIT-style, exactly how snapshot/bitmap
bound theirs), and deferring them is what makes the
crash-artifact benign. The 5b implementer must diff this order
against commit's proven :787-834 sequence and justify any
deviation in the review.
Flush points (flush_after_completion says flush after
completion k): write back dirty staged refblocks, then
fsync_input(0), count it in flushes_issued. --no-drain is
an accepted no-op (serial execution: the queue is always drained)
— still issue the flush. After the loop (interval 0 or not): a
final refblock write-back (no fsync unless the cadence issued
one at k == count) before send_bench_result — the result
closes the timing bracket, so end-of-run metadata write-back is
inside the measured window. This is a documented measurement
caveat: qemu amortises metadata through its cache during the
run; instar concentrates refcount write-back at flush points and
run end. The bracket placement contract itself is unchanged.
3. Gates and errors¶
Guest-side (qcow2 header is parsed there), checked before
send_bench_start — all pre-bracket, result-without-marker:
- The envelope:
refcount_bits != 16, compression feature/any compressed cluster encountered (entry-level check during the run as well — hitting a compressed L2 entry in write mode isERROR_WRITE_UNSUPPORTEDmid-run), extended-L2, external data file, LUKS, dirty/corrupt incompatible bits,nb_snapshots > 0. NewBenchResultcodes (appended, stable): 7ERROR_WRITE_UNSUPPORTED(error_detail= a small gate-id enum documented in shared), 8ERROR_ALLOC_EXHAUSTED(RefcountExhausted— host renders "image too large for in-place bench write" per the findings). - Host-side:
-wwith discovered format ∉ {raw, qcow2} →bench: write tests are not yet supported for <fmt>(divergence registry). The phase-4-wrefusal is removed;validate_bench_args's cross-option rules (flush-requires- write, interval ≥ depth) now become reachable end-to-end. - Host attach for
-w: top image RW input slot 0, parents (if any) RO — a mixed-mode variant of the chain attach (commit already opens an RW input alongside other devices; mirror its open flags). Read tests keep the all-RO attach.fsync_inputrequires the RW slot, so the host stub's RO-slotfalseis the guard that the wiring is right.
4. What phase 5 does NOT do¶
No vmdk/vhd/vhdx writes (refused, Future work). No COW-granular
subclusters (extended-L2 is gated out). No refcount-table growth.
No new call-table slots, no VERSION bump (fsync_input and the
existing senders suffice — OQ5 as resolved in phase 2). No
guest-side "self-check" pass: post-write verification is
host-side in 5c/phase 6 (qemu-img check clean, pattern
read-back, qcow2 disk-size growth), where it is cross-validated
against qemu rather than against ourselves.
Steps¶
| Step | Effort | Model | Isolation | Brief for sub-agent |
|---|---|---|---|---|
| 5a | high | opus | none | Raw write path end-to-end: guest write_input_byte_range (sub-sector RMW on RW input slot 0, mirroring commit's output-side helper), the FLAG_WRITE branch for raw (per-offset pattern writes + flush_after_completion cadence + fsync_input(0) + flushes_issued), host -w enablement (drop the 4a refusal, RW-top attach, FLAG_WRITE + pattern into BenchConfig, vmdk/vhd/vhdx refusal message), flush-line rendering now reachable. Smoke: -w --pattern 65 -c 100 on a raw image — pattern verifiable via cmp/xxd, --flush-interval count matches total_flushes, fsyncs actually reach the file (strace or /proc counters), byte-compare against a qemu -w run with identical args. Commit 1. |
| 5b | high | opus | worktree | qcow2 allocating writes per Mission §1-§3: the per-cluster driver (overwrite fast path; allocating path with chain-read COW fill), write-through L2/L1 ordering, staged refblocks + flush-point/end write-back, the full gate set + the two new error codes + host rendering. MUST diff the resulting write-back order against commit's :787-834 and state the comparison explicitly in the report; MUST verify the COW fill against a qemu -w run on an overlay — the oracle is the virtual view (qemu-img compare full-image equality) plus qemu-img check clean on ours; physical byte-identity of the two overlays is NOT required (allocator placement may legitimately differ) but record whether it holds anyway. Worktree isolation: this is in-place-mutation code on the most corruption-prone path. Gates: build/size/lint/test-rust plus its own smoke (fresh qcow2 grows, check clean, pattern lands, overlay COW correct). Commit 2. |
| 5c | medium | sonnet | none | The recorded verification sweep + bookkeeping: matrix over {raw, fresh qcow2, populated qcow2, overlay} × {-w defaults, --pattern 65, --flush-interval 50 -d 1, --no-drain} — for each: instar vs qemu image byte-compare (or qemu-img compare where physical layouts legitimately differ — record which), qemu-img check clean, disk-size growth on fresh qcow2, flushes-issued == total_flushes, gate refusals (snapshot-bearing image, refcount_bits=1, compressed image, vmdk/vhd/vhdx), --output json fields. Append ## Captured write verification (step 5c) to this plan; master plan row → Complete; index update; pre-commit. Stop-and-report on any content mismatch. Commit 3. |
Captured write verification (step 5c)¶
Verified against local qemu-img 10.0.8 (Debian 1:10.0.8+ds-0+deb13u1+b2)
on instar built from 2a3d3af (make instar, binary at
src/target/release/instar). All work done in a scratch directory outside
the repo; every run used a fresh pristine copy of the relevant base image
(one copy for instar, one for qemu-img, never shared). No stop-and-report
events fired: zero content mismatches, zero check errors/leaks, zero
refusals that touched an image, zero stdout parity breaks.
Base images¶
- raw: 10 MiB flat file, zeroed except an MBR
55 AAsignature at bytes 510-511. - fresh qcow2:
qemu-img create -f qcow2 fresh.qcow2 64M(no data written). - populated qcow2: same 64 MiB qcow2, then
qemu-io -c "write -P 0xbb 0 8M"(first 8 MiB written). - overlay: a
backing.qcow2identical to the populated image (64 MiB, first 8 MiB filled with0xbb), with a qcow2 overlay pointed at it via-b/-F qcow2(absolute backing path so a copy in any directory still resolves — instar's defaultSecurityConfigrestricts backing references to the top image's own directory, so the shared backing file was copied into the working directory used for the sweep).
Argument sets¶
- (a)
-w -c 100 --pattern 65 -f <fmt>(pattern0x41, no flush,-ddefaulted to 64) - (b)
-w -c 100 --pattern 65 --flush-interval 50 -d 1 -f <fmt> - (c)
-w -c 100 --pattern 65 --no-drain --flush-interval 50 -d 1 -f <fmt> - (d)
-w -c 200 -s 131072 -o 32768 --pattern 66 -f <fmt>(pattern0x42, 128 KiB buffer over a 64 KiB cluster — every request straddles two clusters; offset 32768 is mid-cluster, so the first touched cluster's low half is outside the patch window)
Matrix verdicts (16 runs)¶
All exit codes 0 except the one documented divergence below (raw × (d), qemu-img side). Stdout header lines byte-identical between instar and qemu-img on every run that completed; the "Sending flush every 50 requests" line is present on both sides for (b) and (c) and absent from both for (a) and (d).
| Image | Argset | Exit (instar/qemu) | Content oracle | check |
Disk-size growth (instar → qemu) |
|---|---|---|---|---|---|
| raw | a | 0 / 0 | cmp byte-identical |
n/a (raw) | fixed size, no growth (10485760 → 10485760 both sides) |
| raw | b | 0 / 0 | cmp byte-identical |
n/a | no growth |
| raw | c | 0 / 0 | cmp byte-identical |
n/a | no growth |
| raw | d | 0 / 1 | not comparable — see divergence note below | n/a | no growth (file stays 10485760; instar wrote the full wrapped schedule in place) |
| fresh qcow2 | a | 0 / 0 | qemu-img compare: identical |
clean, no leaks | grows: 200704 → 786432 (instar) / 200704 → 729088 (qemu) |
| fresh qcow2 | b | 0 / 0 | qemu-img compare: identical |
clean | grows: 200704 → 786432 / 729088 |
| fresh qcow2 | c | 0 / 0 | qemu-img compare: identical |
clean | grows: 200704 → 786432 / 729088 |
| fresh qcow2 | d | 0 / 0 | qemu-img compare: identical |
clean | grows: 200704 → 26607616 / 26550272 |
| populated qcow2 | a | 0 / 0 | qemu-img compare: identical (physical bytes also identical — fast overwrite path) |
clean | no growth: 8716288 → 8716288 both sides |
| populated qcow2 | b | 0 / 0 | identical, physical bytes identical | clean | no growth |
| populated qcow2 | c | 0 / 0 | identical, physical bytes identical | clean | no growth |
| populated qcow2 | d | 0 / 0 | qemu-img compare: identical (physical bytes differ — (d)'s range runs past the pre-populated 8 MiB, so it takes the allocating path there) |
clean | grows: 8716288 → 26607616 / 26611712 (expected — (d) writes well past the 8 MiB pre-populated region) |
| overlay | a | 0 / 0 | qemu-img compare: identical |
clean | grows: 200704 → 786432 / 724992 |
| overlay | b | 0 / 0 | identical | clean | grows: 200704 → 786432 / 724992 |
| overlay | c | 0 / 0 | identical | clean | grows: 200704 → 786432 / 724992 |
| overlay | d | 0 / 0 | identical | clean | grows: 200704 → 26607616 / 26550272 |
Physical-byte match (informative, not required): identical on raw (all
formats there are physically byte-comparable by construction) and on
populated for (a)/(b)/(c) (both instar and qemu take the fast
overwrite-in-place path over already-allocated clusters, so allocator
placement doesn't enter it). Physical bytes differ on every fresh,
overlay, and populated-(d) row — expected, since instar's and qemu's
cluster allocators legitimately place new clusters differently; the virtual
view (qemu-img compare) is the required oracle there and matched on every
row.
raw × (d) divergence (not a stop-and-report event): qemu-img bench
10.0.8 fails partway through this row with Failed request: Input/output
error (byte-count analysis: it completes ~19 of 200 requests, writing
2,543,616 bytes of pattern 0x42, before hitting its own EOF-overrun bug).
This is the pre-existing qemu 10.0.8 wrap-rule bug that crates/bench
already documents and deliberately does not reproduce (master plan OQ7,
src/crates/bench/src/lib.rs wrap-rule doc comment): a 10 MiB raw image
with a 128 KiB buffer stepping from offset 32768 for 200 requests overruns
EOF under the naive "advance and let it fail" rule 10.0.8 still ships, and
qemu-img bails with an I/O error instead of wrapping. instar adopts the
fixed master wrap rule and completes all 200 requests, wrapping every
overrunning offset back into [0, image_size): the resulting file is
10,354,688 bytes of pattern 0x42 out of 10,485,760 total (the remainder
untouched — the wrap modulus leaves a small unwritten remainder, plus the
2-byte MBR signature which a wrapped write happened not to cover), file
size unchanged at 10,485,760 throughout. Because the two tools genuinely
diverge in behaviour here (one succeeds, one errors), a cmp between the
two output files is not a meaningful oracle for this row; instar's own
output was independently verified (pattern-byte census, no unexpected
values, file size unchanged) and is correct per its documented wrap
contract. This is the write-path analogue of the read-path OQ7 finding
recorded in phase 1 — worth folding into KNOWN_BENCH_DIVERGENCES in
phase 6/8.
--output json flush-count verification¶
Ran --output json on every image with two argument sets: one with no
flush interval (expect flushes-issued 0) and one with
--flush-interval 50 at -c 100 (expect flushes-issued 2, matching
crates/bench::total_flushes(100, 50) == 2). All eight runs matched
exactly:
| Image | flush-interval 0 | flush-interval 50, count 100 |
|---|---|---|
| raw | flushes-issued: 0 |
flushes-issued: 2 |
| fresh qcow2 | flushes-issued: 0 |
flushes-issued: 2 |
| populated qcow2 | flushes-issued: 0 |
flushes-issued: 2 |
| overlay | flushes-issued: 0 |
flushes-issued: 2 |
Refusal rows (instar only; image untouched, confirmed by sha256)¶
| Case | Refused with | sha256 unchanged |
|---|---|---|
qcow2 with internal snapshot (qemu-img snapshot -c snap1) |
bench: write tests are not supported for this image (internal snapshots) |
yes |
refcount_bits=1 qcow2 |
bench: write tests are not supported for this image (refcount_bits != 16) |
yes |
Compressed qcow2 (verified via qemu-img check: 100% compressed clusters after qemu-img convert -c of compressible data) |
bench: write tests are not supported for this image (compression) |
yes |
vmdk (qemu-img convert -O vmdk) |
bench: write tests are not yet supported for vmdk |
yes |
vhdx (qemu-img convert -O vhdx) |
bench: write tests are not yet supported for vhdx |
yes |
vhd (qemu-img convert -O vpc -o subformat=dynamic; instar's discovered format name is vpc) |
bench: write tests are not yet supported for vpc |
yes |
LUKS-encrypted qcow2 (-o encrypt.format=luks,encrypt.key-secret=...) — not skipped, quick to build |
bench: write tests are not supported for this image (encryption) |
yes |
Regression guard: --flush-interval 50 without -w on a plain raw image
still produces the qemu cross-option message verbatim —
--flush-interval is only available in write tests — byte-identical to
qemu-img bench --flush-interval 50 -f raw on the same image, exit 1,
image untouched (sha256 unchanged).
Overlay COW spot-proof¶
Repeated on the pristine matrix overlay (not a throwaway copy) after run
(d): request 0 of (d) writes bytes [32768, 163840), which starts
mid-way through cluster 0 ([0, 65536), cluster size 65536). Byte 20000 —
inside the touched cluster, outside the patched window — reads 0xbb
(matching the backing file's content at the same offset) via
qemu-io -c "read -v 20000 16" overlay.qcow2; byte 32768 (start of the
patch window) reads 0x42 (pattern 66). This confirms the allocating
path's chain-read COW fill preserved the rest of the newly-allocated
cluster's virtual content exactly, patching only the intended window.
Measurement observations (informative only, no assertions)¶
Elapsed times (host Instant bracket) were consistently higher for
instar than for qemu-img across every row, most pronounced on the
allocating/(d) rows:
| Image | Argset | instar elapsed | qemu elapsed |
|---|---|---|---|
| raw | a | 0.007s | 0.001s |
| raw | b/c | 0.019s | 0.010-0.011s |
| raw | d | 0.037s | n/a (errored) |
| fresh qcow2 | a | 0.017s | 0.012s |
| fresh qcow2 | b/c | 0.024s | 0.019-0.020s |
| fresh qcow2 | d | 0.137s | 0.024s |
| populated qcow2 | a | 0.017s | 0.001s |
| populated qcow2 | b/c | 0.029s | 0.012s |
| populated qcow2 | d | 0.116s | 0.012s |
| overlay | a | 0.015s | 0.012s |
| overlay | b/c | 0.025s | 0.017-0.018s |
| overlay | d | 0.145s | 0.022s |
The gap widens most on (d) (cluster-straddling, allocating writes), which is consistent with Mission §2's documented measurement caveat: instar concentrates staged-refblock write-back at flush points and at run end, inside the measured bracket, whereas qemu amortises metadata writes through its page cache during the run. No assertion is made about acceptable overhead magnitude — this is the sandbox-overhead data point the master plan's announcement-email motivation calls for, left for phase 6/8 to interpret.