PLAN-bench phase 02: guest ABI (config, result, timing marker)¶
Prompt¶
Before responding to questions or discussion points in this
document, explore the instar codebase thoroughly. Read relevant
source files, understand existing patterns (the shared ABI crate
src/shared/src/lib.rs, the call-table append discipline, the
guest-protocol proto/builder pairing, the core serial senders,
the mock call tables in src/fuzz and the qcow2 test suite), and
ground your answers in what the code actually does today. Do not
speculate about the codebase when you could read it instead. The
canonical recipe for this phase is the bitmap ABI addition
(commit 547104b, VERSION 18→19) — read that commit's diff
before writing any code. Flag any uncertainty explicitly.
Phase plans for the parent master plan live alongside it in
docs/plans/ and are named PLAN-bench-phase-NN-<descriptive>.md.
The master plan is PLAN-bench.md. This is the
second of eight phases; phase 1
(PLAN-bench-phase-01-crate.md)
landed the pure crates/bench schedule crate.
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 lands the host↔guest ABI for bench: the
BenchConfig / BenchResult structs in src/shared, two new
call-table senders (send_bench_start, send_bench_result)
bumping CallTable::VERSION 19→20, the protobuf wire messages,
the core serial shims, and the compile-forced ripples (mock call
tables, vmm message rendering). It produces no behaviour change —
nothing calls the new senders until phase 3 (guest op) and
nothing harvests the messages until phase 4 (host CLI) — but
after this phase every later phase is purely additive on its own
side of the boundary.
Grounding (verified against the current tree on the bench
branch and the survey performed for this plan):
- The shared ABI crate is the single source of truth.
src/sharedis a path-dependency of the host (src/vmm), the guest core (src/core), and every guest op.CallTableis a#[repr(C)]table ofunsafe extern "C" fnpointers atsrc/shared/src/lib.rs:704-990; core writes it toCALL_TABLE_ADDR, ops read it viaget_call_table()and gate onvalidate_call_table!(src/shared/src/lib.rs:4219-4230). The last entry issend_bitmap_result: unsafe extern "C" fn(*const BitmapResult)at line 989. New entries are always appended at the end for offset back-compat;pub const VERSION: u32 = 19at line 1318 carries a doc-comment history (lines 1309-1317) that each bump extends. A unit test pins the constant:assert_eq!(CallTable::VERSION, 19)atsrc/shared/src/lib.rs:5608. - The canonical recipe is bitmap's VERSION 18→19 addition
(commit
547104b). Its touch list, which this phase replicates with two senders instead of one: shared struct + call-table field + VERSION bump + version-test update; proto message + oneof arm; guest-protocol builder; core serial sender +ct_*thunk + call-table-literal field; vmmformat_messagearm; mock-call-table stubs. - Config struct idiom (
BitmapConfig,src/shared/src/lib.rs:3681-3775):#[repr(C)] #[derive(Clone, Copy)], aMAGICu32,FLAG_*bit constants withFLAG_VERBOSE = 1 << 31, anis_valid()checking magic, a_reservedtail padding to a round size, and a compile-timeconst _: () = assert!(size_of::<...>() <= OPERATION_CONFIG_MAX_SIZE). The host writes the struct field-by-field toOPERATION_CONFIG_ADDR = 0x81000(src/shared/src/lib.rs:331; 4 KiB max); the guest op casts the fixed address. Result struct idiom (BitmapResult,src/shared/src/lib.rs:3785-3807): magic,error: u32withERROR_*constants rendered host-side, numeric fields only,_reservedpadding to 64 bytes. - Wire protocol: protobuf
GuestMessagewith aoneof payload; the tag roster ends atbitmap_result = 20(crates/guest-protocol/proto/guest.proto:448). Builders live incrates/guest-protocol/src/lib.rs(e.g.bitmap_result_messageat line 750). Core serial senders (src/core/src/serial.rs, e.g.send_bitmap_resultat 630) are deliberately numeric-passthrough — no string or lookup tables — because core.bin is near its 72 KiB ceiling. Thunks (ct_send_bitmap_result,src/core/src/main.rs:719) null-check and delegate; the call-table literal (src/core/src/main.rs:266-305) registers them. - Compile-forced ripples. Adding a
CallTablefield breaks every call-table literal: the fuzz mock (src/fuzz/src/lib.rs:96, stub fns near line 314, plus the version assertion near line 390) and the qcow2 test mock (src/crates/qcow2/src/lib.rs:4804). Adding proto oneof variants breaks the vmm'sformat_message(src/vmm/src/main.rs:824-1000) — its payload match is exhaustive overSome(...)variants (verified: only the level match andNonehave catch-alls), so the two new render arms land in this phase even though the harvest loop is phase 4. - Mid-run marker precedent is strong. map streams
send_map_extentbefore a terminalsend_map_result(src/shared/src/lib.rs:935,943); snapshot streamssend_snapshot_entrybeforesend_snapshot_result(952, 962);send_progress(756) is emitted mid-operation by the long ops. Asend_bench_startmarker followed by a terminalsend_bench_resultmirrors this shape exactly. - core.bin budget.
scripts/check-binary-sizes.shmeasures the.bss-inclusive ELF memory extent against the core region[0x10000, 0x22000)= 72 KiB, with an 85% early-warning. Last measured (bitmap's addition, commit547104b): ~66.7 KiB, ~5.3 KiB headroom — already past the warning threshold, so the warning line is expected; the hard gate is 72 KiB. Each sender costs a thunk, a serial builder, and a proto message type in core. This phase re-measures after the addition (step 2c). The script's hardcoded op list does not gainbenchin this phase —bench.binfirst exists in phase 3. - Flush needs no new I/O ABI.
fsync_input(VERSION 17,src/shared/src/lib.rs:975) fdatasyncs an RW-attached input slot; bitmap already uses the RW-input-slot-0 +fsync_input(0)idiom (src/operations/bitmap/src/main.rs:1272etc.). Bench write tests will attach the image RW-input and reuse it verbatim.
Mission¶
1. Resolve Open question 3: dedicated start-marker slot¶
Decision: a dedicated send_bench_start call-table slot, with
the sentinel-progress fallback held in reserve. Rationale:
- The timing bracket is the load-bearing element of the whole
subcommand — the number bench prints is defined as "host
wall-clock between BenchStart receipt and BenchResult receipt".
A dedicated, typed, self-documenting message is worth one table
slot; overloading
send_progresswith a sentinel value would make the single most important event in the protocol implicit. - It is the cheapest possible sender: no arguments, an empty proto message (the arrival time is the payload), a two-line thunk, a three-line serial builder. If any pair of senders fits the remaining headroom, this pair does.
- The streaming-before-terminator shape (map, snapshot) is
established; verbose runs render a clean
bench startline for free.
Fallback, only if step 2c measures core.bin over the 72 KiB
hard gate: drop the send_bench_start slot and field (before
anything ships there is no compat cost), emit a reserved
send_progress(BENCH_START_SENTINEL) instead, and record the
decision here. Do not pre-emptively take the fallback; measure
first.
2. Resolve Open question 5: flush wiring¶
Decision: no new I/O ABI. Bench attaches the image as input
device slot 0 — read-only for read tests, RW (bitmap-style) for
-w — and --flush-interval uses the existing fsync_input(0).
read_input_sector / write_input_sector / fsync_input cover
everything phases 3 and 5 need. This phase adds only the two
result-path senders.
3. BenchConfig in src/shared¶
#[repr(C)] #[derive(Clone, Copy)], immediately after
BitmapConfig's block, following its idiom exactly. Bench's
config is pure CLI parameters — deliberately no host-probed
image cross-checks (unlike BitmapConfig): bench runs on all
five input formats and the guest derives image_size from its
own format parse (phase 3), so there is nothing the host can
probe without host-side parsing that the security model exists to
avoid. Layout (56 payload bytes + reserved tail = 128 total; u32
block / u64 block / u32 block ordering leaves no implicit
padding):
#[repr(C)]
#[derive(Clone, Copy)]
pub struct BenchConfig {
/// Magic (`0x424E4348` = "BNCH").
pub magic: u32,
/// Flags. See `FLAG_*`.
pub flags: u32,
/// Number of requests (1..=0x7fffffff, validated host-side).
pub count: u32,
/// Requested queue depth. Echoed in the header line and JSON
/// only; the guest ignores it — v1 execution is serial
/// (master plan Open question 1).
pub depth: u32,
/// Bytes per request (1..=BENCH_MAX_BUFSIZE).
pub bufsize: u64,
/// Raw step; `0` means "= bufsize" (`crates/bench`
/// `effective_step()` — the guest resolves it, keeping the
/// wrap arithmetic in one place).
pub step: u64,
/// Initial offset, raw and unwrapped (first request uses it
/// as-is, matching qemu).
pub offset: u64,
/// Flush every N completions; `0` = never. Nonzero only with
/// FLAG_WRITE (validated host-side).
pub flush_interval: u32,
/// Write-pattern byte in the low 8 bits.
pub pattern: u32,
/// Input format hint (`ImageFormat as u32`); the guest
/// verifies against its own parse.
pub target_format: u32,
/// Sector size for I/O (matches host sector_size).
pub sector_size: u32,
/// Reserved padding for forward compatibility (zero-init);
/// pads the total to a round 128 bytes.
pub _reserved: [u8; 72],
}
Constants: MAGIC, FLAG_WRITE: u32 = 1 << 0,
FLAG_NO_DRAIN: u32 = 1 << 1, FLAG_VERBOSE: u32 = 1 << 31
(house convention), is_valid() checking magic, and the
compile-time OPERATION_CONFIG_MAX_SIZE assert. Also assert
size_of::<BenchConfig>() == 128 the way the layout-pinning
tests for other configs do (check how BitmapConfig's 3584-byte
layout is asserted and mirror it).
Note: because nothing consumes BenchConfig until phase 3,
field additions discovered during phases 3-5 are free — but they
must go into _reserved, never reorder existing fields.
4. BenchResult in src/shared¶
Mirrors BitmapResult (numeric-only, host renders strings):
#[repr(C)]
#[derive(Clone, Copy)]
pub struct BenchResult {
/// Magic (`0x424E5253` = "BNRS").
pub magic: u32,
/// Error code. See `ERROR_*`.
pub error: u32,
/// Requests fully completed (== count on success; the count
/// reached when a mid-run request failed otherwise).
pub requests_completed: u64,
/// Flushes issued (`crates/bench` `total_flushes()` on
/// success).
pub flushes_issued: u64,
/// Error detail: the byte offset of the failing request for
/// the I/O errors, else 0.
pub error_detail: u64,
/// Reserved padding for forward compatibility (zero-init);
/// pads the total to 64 bytes.
pub _reserved: [u8; 32],
}
Initial ERROR_* roster (phases 3 and 5 append; values are
stable once assigned): ERROR_OK = 0, ERROR_BAD_CONFIG = 1
(magic/flag validation failed in the guest),
ERROR_UNSUPPORTED_FORMAT = 2, ERROR_PARSE_FAILED = 3,
ERROR_IO_READ = 4, ERROR_IO_WRITE = 5, ERROR_IO_FLUSH = 6.
5. Call-table append + VERSION 20¶
Append after send_bitmap_result (line 989), in this order:
pub send_bench_start: unsafe extern "C" fn(),
pub send_bench_result: unsafe extern "C" fn(*const BenchResult),
with doc comments in the house style ("Appended at the end of
CallTable for the same back-compat reason as ..."). The
send_bench_start doc comment must pin the timing contract,
because it is the ABI-level meaning of the message:
- The guest emits it after config validation, format
probe/open, cached-state setup, and buffer/transfer-plan
setup — immediately before submitting the first request
(mirroring qemu's
gettimeofdaybracket placement). - The host records
Instant::now()on receipt; elapsed is measured toBenchResultreceipt. The trailing flush (whenflush_intervaldivides such that the final completion flushes) is inside the bracket, per phase 1's verified cadence. BenchResultmay arrive without a precedingBenchStart(validation/probe failure before the timed loop): the host must render the error without a timing line, never assume the bracket opened.- Marker cost (a few serial-port IoOut vmexits, ~µs) is noise at bench's ms-to-s scale — recorded so nobody "optimizes" it.
Bump VERSION to 20, extend the doc history (lines 1309-1317)
with "PLAN-bench phase 2 appended send_bench_start and
send_bench_result for the bench subcommand's timing bracket",
and update the pin test at line 5608.
6. Proto messages, builders, core shims, ripples¶
crates/guest-protocol/proto/guest.proto: append
// Bench timing-bracket start marker. Deliberately empty: the
// host timestamps its arrival; there is no payload.
message BenchStartMessage {
}
message BenchResultMessage {
// Error code: 0 = ok, non-zero mirrors BenchResult::ERROR_*
uint32 error = 1;
uint64 requests_completed = 2;
uint64 flushes_issued = 3;
uint64 error_detail = 4;
}
with oneof arms bench_start = 21 and bench_result = 22
(append-only tag discipline).
- crates/guest-protocol/src/lib.rs: bench_start_message()
and bench_result_message(...) builders mirroring
bitmap_result_message (line 750). Levels: mirror the house
pattern — check what level the existing *_result builders
set (result messages) and what progress_message sets, and
use Progress-analog for the start marker, result-analog for
the result.
- src/core/src/serial.rs: send_bench_start() and
send_bench_result(&shared::BenchResult) after
send_bitmap_result (line 630) — numeric passthrough, no
string tables (the 72 KiB ceiling comment there applies
verbatim).
- src/core/src/main.rs: ct_send_bench_start /
ct_send_bench_result thunks after ct_send_bitmap_result
(line 719; null-check idiom), registered at the end of the
call-table literal (line 304).
- src/fuzz/src/lib.rs: mock stubs for both fields (defs near
line 314, literal at 96) and the version assertion near line
390.
- src/crates/qcow2/src/lib.rs:4804: test-mock stubs for both
fields.
- src/vmm/src/main.rs format_message: render arms for
Payload::BenchStart ("bench start") and
Payload::BenchResult (error, requests_completed,
flushes_issued, error_detail), matching the surrounding style
near line 935. No BenchArgs, no run_bench, no harvest
loop — that is phase 4. Scope correction found during 2a:
these arms belong to step 2a's commit, not 2b's — the proto
oneof addition alone makes the exhaustive match non-exhaustive,
so the workspace does not build without them.
7. Re-measure core.bin (Open question 10)¶
After the addition: make instar, then
scripts/check-binary-sizes.sh. Record the measured
.bss-inclusive core footprint and remaining headroom in this
plan (Captured measurements section, added by step 2c) and in
the master plan's execution-table status. If the hard 72 KiB
gate fails, take the §1 fallback and re-measure. make lint,
make test-rust, and pre-commit run --all-files must all
pass; guest-side integration behaviour is untestable until
phase 4 (the existing integration suite must stay green — this
phase changes no behaviour).
Steps¶
| Step | Effort | Model | Isolation | Brief for sub-agent |
|---|---|---|---|---|
| 2a | medium | sonnet | none | Append BenchStartMessage (empty) and BenchResultMessage to crates/guest-protocol/proto/guest.proto with oneof tags 21/22, plus bench_start_message() / bench_result_message() builders in crates/guest-protocol/src/lib.rs, mirroring the bitmap pair (proto line 323/448, builder line 750) including message-level choices copied from progress_message / the *_result builders. Also the two format_message render arms in src/vmm/src/main.rs (~line 935) — compile-forced by the oneof addition, so they cannot wait for 2b. Commit 1. |
| 2b | high | opus | none | The lockstep compile unit, following commit 547104b's recipe with the exact layouts and doc-comment contracts in Mission §3-§6: BenchConfig/BenchResult + constants + asserts in src/shared/src/lib.rs; call-table append + VERSION 19→20 + history doc + pin-test update; send_bench_start/send_bench_result in src/core/src/serial.rs; thunks + literal registration in src/core/src/main.rs; mock stubs + version assert in src/fuzz/src/lib.rs; test-mock stubs in src/crates/qcow2/src/lib.rs (the format_message arms already landed with 2a). High effort because the send_bench_start doc comment carries the timing contract, the struct layouts are pinned to the byte, and a missed mock literal or version assert breaks the workspace build in non-obvious places. make instar + make lint + make test-rust green. Commit 2. |
| 2c | low | sonnet | none | Build, run scripts/check-binary-sizes.sh, append a "Captured measurements" section to this plan (core.bin .bss-inclusive footprint before/after, headroom vs the 72 KiB gate, whether the 85% warning fired), update the master-plan execution row and docs/plans/index.md phase link/status. If the hard gate failed, stop and report — the §1 fallback is a management-session decision, not a mechanical fix. Commit 3. |
Resolved open questions¶
- OQ3 (start marker): dedicated
send_bench_startslot; sentinel-progress fallback only if the 72 KiB hard gate fails (Mission §1). - OQ5 (flush wiring): RW-input attach + existing
fsync_input; zero new I/O ABI (Mission §2). - OQ10 (ABI footprint):
BenchConfigfixed at 128 bytes (well under the 4 KiB config page),BenchResultat 64 bytes, two appended slots, VERSION 19→20; core.bin re-measured by step 2c with the fallback path predefined (Mission §7).
Captured measurements (step 2c)¶
- core.bin before this phase (bitmap's addition, commit
547104b): ~66.7 KiB .bss-inclusive extent, ~5.3 KiB headroom against the 72 KiB core region (Situation, "core.bin budget"). - core.bin after this phase:
make instarfollowed byscripts/check-binary-sizes.shon the tree with steps 2a and 2b landed produces:
i.e. a 68416-byte flat .bin, 94% of the 72 KiB region, ~4.4 KiB
headroom remaining. The growth versus the pre-phase measurement
is attributable to the two senders landed in steps 2a/2b: the
ct_send_bench_start / ct_send_bench_result thunks
(src/core/src/main.rs), the two new serial.rs builders
(send_bench_start, send_bench_result), and the two new proto
message types (BenchStartMessage, BenchResultMessage)
compiled into core's protobuf encoding path.
- The script's 85% early-warning fires — as it already was firing
before this phase, per the bitmap-era ~5.3 KiB-headroom
measurement above — and the 72 KiB hard gate passes:
scripts/check-binary-sizes.sh's final line reads "All binaries
fit within their memory regions."
- Consequence for Mission §1 (OQ3): the hard gate held, so the
dedicated send_bench_start call-table slot stands as landed;
the sentinel-send_progress fallback described there was not
taken.
- Note for phase 3: ~4.4 KiB of core headroom remains. The
bench guest op itself adds nothing further to core — the two
thunks above are the full cost already counted here — so the
next pressure on this budget is whichever future plan appends
the next call-table sender. Check the actual field count in
CallTable (src/shared/src/lib.rs) before citing a slot
number in a future plan rather than trusting this document's
count to still be current; simplest is to just say "the next
call-table append".