Skip to content

feat(minimal): add min bug diagnostic bundle command - #784

Closed
norrietaylor wants to merge 23 commits into
mainfrom
min-bug-diagnostics
Closed

feat(minimal): add min bug diagnostic bundle command#784
norrietaylor wants to merge 23 commits into
mainfrom
min-bug-diagnostics

Conversation

@norrietaylor

@norrietaylor norrietaylor commented Jul 16, 2026

Copy link
Copy Markdown
Member

Summary

Adds a min bug command that assembles a redacted diagnostic bundle (minimal-diag-<ts>.tar.zst) for support triage, plus the daemon-side streaming subsystem that captures in-VM state, the host-side captures needed to root-cause the field incidents in #788, and the crate/observability cleanups from review.

Diagnostic bundle

  • min bug CLI (minimal): collects host system info, environment, config (redacted), state and workspace listings, logs, process tree, network state (listening sockets, interfaces with MACs masked to their OUI, routes), and per-provider daemon bundles — each collector failure-isolated into a manifest that explains every collected/skipped/errored entry.
  • DiagBundleTarZst subsystem (minimald-rpc, minimald): streaming RPC contract and daemon handler serving the in-VM daemon's own view, nested raw into the host bundle per provider; the client fails fast when a daemon refuses the subsystem.
  • Redaction (diagnostics): shared fail-closed key-based masking (JSON + TOML) and metadata-only listing, applied identically by the CLI and the daemon.

#788 field-incident captures

  • boot.log — minvmd persists the VMM's hvc0 console to <provider>/boot.log by default (was dev-only), bundled per provider.
  • Degraded-mode guest harvest — on guest-bundle timeout/failure, records the data-volume image's vital signs and best-effort harvests /logs from the ext4 image via debugfs, with an offline-extraction hint otherwise.
  • Hang triage — thread samples (macOS) / wchan+kernel-stack+fds (Linux) plus lsof for the minimal process family, capped at 8 pids.
  • Host power historypmset/journalctl sleep-wake transitions, so suspend/resume can be ruled in or out of a "stream died silently" report.

Review cleanups

  • diagnostics crate — the app-agnostic machinery (redact, listing, bundle, manifest) moved out of common/minimal::diag into a dedicated crate; host-specific collectors stay put and import it.
  • Reload-based log release — the microVM's on-volume log appender now uses a tracing_subscriber::reload layer with the release closure owned by ServerState (invoked via a method by the Shutdown RPC), replacing the hand-rolled deferred writer, the fork-hazardous process-global static mutex, and the free-function state change.
  • CodeRabbit's 20 comments addressed (redaction compound-key leak, vars_os panic-proofing, subprocess kill_on_drop, 0600 bundle perms, symlink guards, --no-guest honoring the probe, argv scoping on the daemon proc table, and more).

Test plan

  • Unit + integration tests across common, diagnostics, minimal, minimald (redaction, manifest, collectors, bundle streaming, socket probe) — verified via cross for the Linux-only crates
  • Clippy clean under -D warnings (incl. --all-features to cover feature-gated callers)
  • Ultrareview findings (9) addressed
  • CodeRabbit review (20 comments) addressed
  • Field re-validation of the reload log-release before merge: idle attach + minvmd stop, confirm the ext4 journal stays clean (the reload swap is compile/unit-verified; the microVM release-before-quiesce path is not exercised by the test suite)

Related: #788 (field incidents this bundle diagnoses), #798 (keepalive fix, split out)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added the min bug command to create compressed diagnostic bundles with manifests.
    • Collects host, networking, process, power, configuration, state, logs, and provider diagnostics.
    • Includes optional guest daemon diagnostics with timeout and size controls, plus volume-based fallback.
    • Redacts sensitive configuration, environment values, and session data.
    • Adds staged provider socket health checks and records unavailable or failed collectors without stopping collection.
  • Bug Fixes

    • Improved detached and VM logging, boot-log capture, and clean shutdown behavior.

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds the min bug diagnostic bundle command, reusable tar.zst and redaction primitives, host and guest collectors, a daemon streaming RPC, provider probing, and revised detached, microVM, and boot-log handling.

Changes

Diagnostic bundle foundations

Layer / File(s) Summary
Bundle contracts and redaction
Cargo.toml, crates/diagnostics/*, crates/minimal/src/diag/redact.rs, crates/minimal/src/dirs.rs
Adds manifest types, deterministic metadata listings, JSON/TOML redaction, directory-report formatting, and workspace wiring.
Bundle writer and client download
crates/diagnostics/src/bundle.rs, crates/minimal/src/client.rs
Creates finalized tar.zst bundles with manifests and tail caps, and downloads daemon bundles through a bounded SSH channel stream.

Diagnostic collection flow

Layer / File(s) Summary
Daemon diagnostic RPC
crates/minimald-rpc/src/lib.rs, crates/minimald/src/diag.rs, crates/minimald/src/rpc.rs
Defines the diagnostic subsystem request and streams daemon logs, state, sessions, process, network, disk, and microVM probe data.
Host collectors
crates/minimal/src/diag/collect.rs, crates/minimal/src/diag/net.rs, crates/minimal/src/diag/power.rs, crates/minimal/src/diag/procs.rs
Collects redacted configuration, environment, state, provider, network, power, process, and filesystem diagnostics with bounded or best-effort behavior.
CLI orchestration and guest fallback
crates/minimal/src/diag/mod.rs, crates/minimal/src/diag/guest.rs, crates/minimal/src/lib.rs, crates/minimal/tests/bug.rs
Adds min bug, coordinates collectors and provider probes, retrieves nested guest bundles, falls back to volume logs, and tests daemon, stale-socket, no-daemon, and no-guest cases.

Logging lifecycle

Layer / File(s) Summary
Deferred and rotated logging
crates/minimald/src/main.rs, crates/minimald/src/server.rs, crates/minimald/src/rpc.rs, crates/minvmd/src/*
Adds detached rolling logs, post-mount microVM log activation and release, default boot-log capture, connection identifiers, and shutdown log cleanup.
Supporting runtime and recipe updates
crates/minimald/build.rs, crates/minimald/src/session_host.rs, justfile, scripts/session-e2e.sh
Makes build metadata tolerant of missing Git data, adjusts binding logging, removes boot-log recipe wiring, and fixes the VM diagnostic fallback path.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant Minimal as min bug
  participant Provider as Provider socket
  participant Daemon as minimald
  participant Archive as BundleWriter

  User->>Minimal: run min bug
  Minimal->>Archive: create host bundle
  Minimal->>Provider: probe socket
  Provider-->>Minimal: probe stages and client
  Minimal->>Daemon: request diagnostic subsystem
  Daemon-->>Minimal: stream daemon tar.zst
  Minimal->>Archive: add host and guest entries
  Archive-->>User: finalized bundle and manifest
Loading

Suggested reviewers: twitchyliquid64

Poem

A rabbit packs logs in a zstd-bound sack,
Redacts every secret that sneaks through a crack.
The daemon sends bytes down a channel so neat,
Host and guest clues make the bundle complete.
“Hop!” says the rabbit, “the manifest is bright!”
Diagnostics gathered by moonlight.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding the min bug diagnostic bundle command.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands.

@norrietaylor
norrietaylor force-pushed the min-bug-diagnostics branch from 4d4cfc8 to b3850fb Compare July 16, 2026 04:27
@norrietaylor

Copy link
Copy Markdown
Member Author

Design note: hand-rolled collectors vs. public diagnostic/scraping crates

A fair review question for this PR is why the collectors are hand-rolled reads instead of an off-the-shelf system-info crate. Summary of the reasoning and the alternatives we could have reached for:

Dependency footprint

This PR adds zero new external crates. The collectors are std/tokio file reads plus a few libc calls (statvfs, geteuid, syncfs); the archive reuses the existing async_tar + async-compression stack (same pattern as the workspace upload); rolling logs reuse tracing-appender. The only manifest changes are workspace-internal (common, toml, libc wiring and a nix feature flag). That follows the repo dependency policy (std → workspace crates → blessed.rs → elsewhere).

Why raw reads instead of a scraping library

  1. The raw bytes are the deliverable. The bundle ships /proc/net tables, /proc/<pid>/status, and /proc/mounts verbatim — the dev team decodes them. A typed scraper (sysinfo, procfs) parses and re-serializes: parser version-skew silently drops fields, and a parse failure becomes a collection failure. For diagnostics, fidelity beats prettiness (diag/net.rs says exactly this about the /proc/net fallback).
  2. Failure isolation is the core contract. Every collector is best-effort and must degrade to a manifest note, never abort the run. That's easy to guarantee over ten-line readers, hard over a large library surface.
  3. Two constrained environments. The same collectors run on the macOS host CLI and as the microVM's pid-1 in a minimal rootfs (no ps, size-sensitive initramfs). Cross-platform system-info crates optimize for the general case and carry platform backends we'd never exercise.
  4. Redaction is the genuinely novel part, and the ecosystem has no equivalent. secrecy, veil, and redact are type-level: they protect secrets in your own structs at Display/Debug time. Nothing does data-driven, recursive, key-based scrubbing of arbitrary user TOML/JSON with a fail-closed policy — which is what bundling someone else's config requires.

Crates we could have used, per need

  • bugreport (sharkdp) — the nearest whole-concept analog: assembles system/env snippets for bug reports. CLI-oriented markdown output; no archive, no redaction, no daemon/RPC side. Would have covered roughly host/system.json + host/env.json.
  • sysinfo — cross-platform processes/disks/networks. Would replace ps + statvfs + parts of /proc, at the cost of a refresh-model dependency and typed re-serialization; our filtered process capture is ~60 lines.
  • procfs — typed /proc, Linux-only, and already in minimald's dependency graph (via hakoniwa/libcgroups). The strongest candidate: if the daemon-side collectors ever compute over proc data instead of shipping it raw, this is the crate to switch to.
  • nix — already in the workspace (sandbox2 uses statfs/statvfs). Honest wash: nix::sys::statvfs could replace both hand-rolled unsafe statvfs blocks and libc::geteuid. Reasonable follow-up cleanup if we want fewer SAFETY comments.
  • netstat2 / listeners — socket enumeration; thin maintenance, and we prefer verbatim ss/netstat output (what we'd ask a user to paste anyway) with a raw /proc/net fallback.
  • os_info / whoami / hostname — replaced by two uname invocations with a 5s timeout.
  • human-panic / sentry-style crates — different problem class (crash/panic telemetry), not an on-demand support bundle.

Bottom line

The collection layer is ~2.5k lines including tests, most of it best-effort file reads where raw fidelity and failure isolation matter more than typed models. The redaction engine is where the real logic lives, and it had to be bespoke. Worth re-evaluating procfs (daemon side) and nix (statvfs/euid) if the collectors grow beyond capture-and-ship.

@norrietaylor
norrietaylor marked this pull request as ready for review July 16, 2026 05:03
@norrietaylor

Copy link
Copy Markdown
Member Author

Field notes from a live incident this PR is squarely aimed at (#788 — an established attach stream died silently mid-session on the unstable release build, which has none of this diag plumbing). The bundle as designed would have fixed the two biggest gaps we hit (no persisted daemon logs, no in-VM state capture). Three additions the incident argues for:

  1. Collect boot.log into the bundle. The logging comments route microVM pid-1 records to serial → "host boot.log", but I couldn't find a collector entry for it — only providers/<name>/run.log is picked up. In the incident, the VMM child's stdout went to /dev/null and the console stream was exactly the evidence we lost (~1.7 KB written and discarded). If boot.log lands on the host but isn't bundled, wedged-boot reports still arrive blind.

  2. Degraded-mode guest harvest when the transport is the suspect. On guest-bundle timeout the bundle gets only providers/<provider>/guest/error.txt. The incident's failure domain was the vsock transport itself — the same path DiagBundleTarZst rides — so the timeout branch is precisely the case where guest logs matter most. The data volume is plain ext4 and safely readable read-only from the host while the VM runs (verified with debugfs -c against the live data-vol.raw during the incident): on timeout, harvest /logs directly from the image, or minimally record the image's size/mtime (the mtime alone dated the stall for us) plus a manifest hint for offline extraction.

  3. Hang-triage collectors. The diff has no sample/lsof equivalents, yet the incident diagnosis ran almost entirely on thread stacks and socket states of the min/minvmd/VMM process family (vCPUs parked in WFI, proxy parked in kevent, unix socket open with no EOF — that combination was the finding). A 2-second sample <pid> per relevant process on macOS (/proc/<pid>/stack or eu-stack on Linux) plus lsof -p for the process set from the existing argv0 filter fits inside COLLECTOR_TIMEOUT and would make "it's frozen" reports self-diagnosing.

🤖 Generated with Claude Code

@norrietaylor

Copy link
Copy Markdown
Member Author

All three field-note items from the incident notes are now on the branch, plus host network capture:

  1. boot.log (c2cfd580) — minvmd now defaults the hvc0 console capture to <provider dir>/boot.log (truncated per boot; MINVMD_BOOT_LOG still overrides; capture failure warns and boots on). min bug bundles it per provider, tail-capped, alongside run.log.
  2. Degraded-mode guest harvest (2c18ff60) — on guest-bundle timeout, download failure, or a dead socket probe, the bundle now records providers/<n>/guest/volume-meta.json (image size + mtime — the stall-dating signal) and best-effort harvests /logs from the ext4 image via debugfs -c into guest/volume-logs/. Without e2fsprogs the manifest carries the exact offline rdump command.
  3. Hang-triage collectors (97817519) — for up to 8 matched pids: sample <pid> 1 on macOS (host/proc/<pid>.sample.txt), wchan/syscall/kernel-stack/fd-readlinks on Linux (host/proc/<pid>.stack.txt), and one lsof -nP over the set (host/proc/lsof.txt, accepting lsof's exit-1-with-output convention). Every miss degrades to a manifest note.
  4. Host interfaces + routes (d22f70e0) — host/net/interfaces.txt (MACs masked to vendor OUI) and host/net/routes.txt, so own-IP/CGNAT collisions (100.64/10 — Tailscale et al.) and gvproxy switch-subnet overlaps (192.168.127/24) are visible in the bundle.

🤖 Generated with Claude Code

norrietaylor and others added 16 commits July 16, 2026 08:03
…ostics

Key-based secret masking (redact_json / is_sensitive_key, with wholesale
masking of vars/env-style tables) and a metadata-only recursive directory
listing. Shared by the upcoming `min bug` CLI collector and the minimald
diagnostic RPC so both sides apply identical redaction rules.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New streaming RPC: the client writes one JSON DiagBundleRequest and
half-closes; the daemon streams back a zstd-compressed tar of its
diagnostic bundle. Pre-stream errors relay over extended-data stream 1,
mirroring WorkspaceFilesTarZst.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A detached supervisor's stdout goes nowhere, so in-field boot failures
left no log trail. `run --detach` now marks its re-exec'd child with
MINVMD_DETACHED, which routes tracing to
<state>/logs/minvmd.log.<date> (14 retained), mirroring minimald's
scheme so diagnostics find both daemons' logs in one place. Tracing
init moves after CLI parsing because the log dir derives from the
--minimal-state-dir override; the run.log stderr redirect stays and
keeps catching panics and the final error print.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New diag module streams the daemon's own view for `min bug`: meta
(version, pid, in_microvm, state_volume_mounted), rolling logs
(tail-capped), state-dir listing (metadata only), session records with
secret-shaped values redacted, /proc process and net tables, a gvproxy
gateway probe when in a microVM, and disk/mounts. Collector failures
accumulate into an errors.json entry instead of aborting, so a
half-broken daemon still reports what it can. Served identically by the
native daemon and the in-VM pid-1 instance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One command for in-field debugging: writes
minimal-diag-<timestamp>.tar.zst containing host system/process/network
state, redacted config and loadouts, state-dir listings (never session
file contents), rolling daemon logs, and — per provider instance — a
staged socket probe (stat -> connect -> handshake -> GetVersion, whose
failing stage distinguishes stale-socket from wedged-vsock from
healthy) plus the daemon's own bundle streamed over the new
DiagBundleTarZst subsystem. Every collector is failure-isolated into
manifest.json errors; the command never autospawns daemons and never
mutates state (reads minvmd.toml raw rather than via effective_state(),
which repairs on read). client.key and host keys are never opened.

The guest download is bounded by --guest-timeout-secs (60s default) and
a 256 MiB cap; `min dirs` now names the minvmd rolling log too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The microVM's pid-1 minimald logged only to stdout (serial console), so
`min bug`'s guest bundle never carried the in-VM daemon's logs: the
`logs` collector reads <state>/logs, which stayed empty. Two causes
stacked — pid-1 is never `--detach`'d (so it took the stdout branch),
and init_tracing runs before the state volume mounts (so a naive file
path would be shadowed by the later mount).

Route the microVM to log to both the console (kept for boot diagnosis
via the host boot.log) and a daily-rotated <state>/logs/minimald.log.
A DeferredFileWriter is installed at init (discarding) and pointed at
the appender once /var/lib/minimal is mounted and state has relocated
onto it. Activation failure only warns and falls back to console — it
must never wedge pid-1.

Verified on macOS via `just dm1`: the guest bundle now contains
logs/minimald.log.<date> with real runtime records, errors.json == [].

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
download_diag_bundle only matched Data and ExtendedData in its channel
loop, so when a daemon that predates the RPC refuses the
DiagBundleTarZst subsystem, the resulting SSH_MSG_CHANNEL_FAILURE fell
into `_ => {}` and was dropped. A bare refusal does not close the
channel, so wait() never returned None and the download blocked until
the caller's guest timeout (60s per provider).

Handle ChannelMsg::Failure: bail immediately with an upgrade hint. The
happy path is unchanged — a healthy daemon replies Success and then
streams the bundle.

Verified: against a pre-diagnostics daemon `min bug` now errors in
0.08s (was 60s) with a clear message; against a current daemon the
bundle still downloads with errors[] empty.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two review findings let secret-shaped values escape the `min bug`
redaction pass:

- A sensitive-named key holding a table/array (`[tokens]`,
  `api_token = [...]`) recursed without the mask, leaking its members
  unless their own keys also looked sensitive. The sensitive-key check
  now propagates into container values, mirroring env-table semantics.
- Env vars matching the project prefixes were captured verbatim even
  when their names were secret-shaped (`MINIMAL_AUTH_TOKEN`). The
  allowlist now defers to the sensitive-name deny, and the bare
  `MINIMAL` prefix is anchored to `MINIMAL_`.

Also anchors the `public` exemption to `public_key` shapes so
`publication_secret` and friends stay masked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review findings on the DiagBundleTarZst server path:

- If the channel copy failed mid-stream (client disconnect), the
  build task stayed blocked writing into the full duplex pipe forever,
  leaking the task and its ServerStateHandle clone. The pipe's read
  half is now dropped before the task is awaited so its next write
  fails with BrokenPipe, and copy errors propagate first.
- The logs collector bundled the newest five files of any name from
  the shared logs dir; on native installs minvmd's rotated logs sort
  after minimald's and crowded them out entirely. It now takes only
  `minimald.log*` files — minvmd's logs belong to the host collector.
- Collectors used blocking std::fs on the RPC-serving runtime; reads
  now go through tokio::fs and the /proc walk runs in spawn_blocking.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PROCESS_MARKERS were substring-matched against the whole ps args
column, so a user editing or tailing our logs (`vim minimald.log`,
`tail -f minvmd.log.2026-07-15`) had their process — full command
line, plus /proc status on Linux — captured into the bundle. Only the
executable name is matched now, the same discipline the
`min`/`minimal` names already used.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review findings on the guest download path of `min bug`:

- A daemon error arriving after some archive bytes had streamed was
  silently discarded, landing a corrupt nested archive with a clean
  manifest. Any daemon-reported error now fails the download, and the
  guest collector records it in the bundle as before.
- A truncated guest bundle was recorded both as skipped and as
  collected-verbatim; it is now a single collected entry with a new
  `truncated` redaction level.
- Guest collector errors were timed from the start of the whole run
  instead of the provider's own collection.
- Host collectors used blocking std::fs in async context; they now
  use tokio::fs (provider_dirs became async accordingly).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The in-VM daemon's rolling log file (persisted to the state volume for
`min bug`) holds a write-open fd under /var/lib/minimal at shutdown.
That fd defeats both the plain unmount and the read-only remount in
quiesce_state_volume, so every clean stop left the ext4 journal dirty
(INCOMPAT_RECOVER set) — caught by
stop_quiesces_volume_leaving_clean_ext4_journal in CI.

pid-1 now parks the appender handle and its worker guard in a release
hook next to the quiesce code; the Shutdown RPC runs it (idempotently)
after the session drain and before the quiesce, flushing and closing
the file. Console logging (serial -> host boot.log) is unaffected, so
the teardown records still land in the host's boot log.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Own-IP guest egress rides 100.64/16, inside the CGNAT block that
Tailscale-style VPNs also occupy; published-port and MTU tickets need
to know which host addresses and default route exist. Neither was
visible in the bundle.

host/net/interfaces.txt (`ip addr` / `ifconfig -a`) with every MAC
masked to its vendor OUI — the IPs and subnets are the diagnostic
payload, hardware identifiers are not — and host/net/routes.txt
(`ip route` / `netstat -rn`) verbatim. Same attempt-list-with-timeout
pattern as the listening-sockets collector; hosts without the tools
degrade to a recorded collector error, which the integration test now
accepts as the alternative to the files being present.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The hvc0 console was captured only when the dev-only MINVMD_BOOT_LOG
env var was set; detached production boots discarded it, which is
exactly the evidence lost when a guest wedges before the daemon is
reachable or its transport dies afterwards (#788).

minvmd now defaults the console capture to <provider dir>/boot.log,
truncated per boot so it holds the current VM generation; the env var
still overrides, and a capture failure logs a warning and boots on —
diagnostics must never become a boot dependency. `min bug` bundles the
file per provider (tail-capped) alongside run.log.

Refs: #788

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On guest-bundle timeout or download failure the bundle carried only an
error.txt — yet the failure domain in that case is often the vsock
transport itself, the very path DiagBundleTarZst rides, so the guest's
logs were lost precisely when they mattered most (#788).

The data volume is plain ext4 and safe to read read-only from the host
while the VM runs. On any guest-collection failure (including a dead
socket probe) `min bug` now records the image's vital signs
(volume-meta.json — its mtime alone dates a stall) and best-effort
harvests /logs from the image via `debugfs -c` into
providers/<n>/guest/volume-logs/ when e2fsprogs is installed; absent
that, the manifest carries the exact offline-extraction command.

Refs: #788

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
"It's frozen" diagnosis runs on thread stacks and socket states of the
min/minvmd/VMM family (#788: vCPUs parked in WFI, proxy in kevent, a
unix socket open with no EOF) — none of which the bundle captured.

For up to eight matched pids: a 1-second `sample` per process on macOS
(host/proc/<pid>.sample.txt); wait channel, current syscall, kernel
stack (root-only — the error is data) and readlink'd fds on Linux
(host/proc/<pid>.stack.txt); plus one `lsof -nP` over the whole set
(host/proc/lsof.txt), accepting lsof's exit-1-with-output convention.
Every miss degrades to a manifest note.

Refs: #788

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
minvmd now defaults the hvc0 console capture to <provider>/boot.log,
so the blanket exports in `just env`/dm1/dm3 did nothing but redirect
the console AWAY from the path the `min bug` collector reads — dev
bundles silently lost their boot log while production installs kept
theirs. Drop the exports (and the scratch boot.log from `just clean`);
the env var stays supported for callers that set it deliberately
(bench/soak scripts, CI).

session-e2e's failure diagnostics now tail the provider-dir default
when the variable is unset, instead of a knowingly-dead path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 20

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🟡 Minor comments (2)
crates/common/src/listing.rs-12-18 (1)

12-18: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Only emit the truncation marker when an entry was omitted.

At Line 16, an exactly max_entries-sized tree is incorrectly reported as truncated. Track whether traversal actually encountered another entry after reaching the cap, and add an exact-cap test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/common/src/listing.rs` around lines 12 - 18, Update listing_text and
the walk_listing traversal to distinguish reaching max_entries from actually
encountering an omitted entry; emit the truncation marker only when traversal
confirms an additional entry exists beyond the cap. Add a test covering a tree
with exactly max_entries entries and verify it is not marked truncated.
crates/common/src/listing.rs-41-68 (1)

41-68: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Report symlink metadata without dereferencing. entry.metadata() follows symlinks, so symlink entries are printed as their target’s type/size/mode even though recursion already skips them. Switch this to symlink_metadata() and add a symlink case to the test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/common/src/listing.rs` around lines 41 - 68, Update the metadata
lookup in walk_listing to use symlink_metadata() so symlink entries are reported
using their own type, size, and mode without dereferencing; keep the existing
file_kind handling and recursion behavior unchanged. Add or update the relevant
listing test to cover a symlink and verify it is reported as a symlink rather
than its target.
🧹 Nitpick comments (1)
crates/minimal/tests/bug.rs (1)

249-265: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Verify that --no-guest makes no daemon connection.

This only proves that the downloaded archive is omitted; it still passes when cmd_bug probes and handshakes with the daemon. Instrument the test server and assert that no handshake or diagnostic RPC is received.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/minimal/tests/bug.rs` around lines 249 - 265, Update
bug_no_guest_skips_daemon_contact to instrument the test daemon from setup, then
assert that it receives neither a handshake nor a diagnostic RPC when cmd_bug
runs with --no-guest. Retain the existing archive assertions and use the
daemon’s established request or call-tracking mechanism.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/common/src/redact.rs`:
- Around line 35-40: Update is_sensitive_key so explicit secret markers take
precedence over the public-key exemption: classify keys containing sensitive
markers such as token, password, or private as sensitive even when they match
public-key patterns. Restrict the false return to unambiguously
public-key-shaped names, and add regression tests covering compound keys
including public_key_token, public_key_password, and private_public_key.

In `@crates/minimal/src/client.rs`:
- Around line 260-275: Bound daemon_error accumulation in the channel.wait loop
alongside bundle in the ExtendedData ext: 1 branch. Limit appended bytes to the
remaining max_bytes capacity, mark truncation or stop reading once the
diagnostic limit is reached, and preserve the existing archive-data handling.

In `@crates/minimal/src/diag/bundle.rs`:
- Around line 72-74: Update the file-collection logic around
tokio::fs::File::open to open sources with O_NOFOLLOW, reject anything that is
not a regular file, and record rejected or symlink entries as skipped rather
than failing or copying them. Preserve the existing context for genuine open
errors and continue bundling other files.
- Around line 34-39: Update create to ensure the diagnostic archive at out_path
has owner-only permissions (0600), including when overwriting an existing file.
Preserve the existing context-wrapped file creation and configure or apply the
permissions after creation so the final mode is not affected by the process
umask.
- Around line 75-96: Update the file-reading flow around the metadata, seek, and
read_to_end calls to enforce cap on the reader itself, using a bounded read such
as take(cap) after positioning at the tail when needed. Ensure read_to_end
cannot consume more than cap bytes even if the file grows during reading, while
preserving the existing TailCapped and None redaction behavior in add_bytes.

In `@crates/minimal/src/diag/collect.rs`:
- Around line 380-389: Update the file filter in the rolling-log collection loop
for prefixes minimald.log and minvmd.log to accept only filenames matching the
exact prefix.YYYY-MM-DD pattern. Reject arbitrary suffixes such as
.private-notes before files are opened, while preserving the existing
newest-first sorting and collection behavior for valid dated logs.
- Around line 193-205: Update env to use std::env::vars_os() instead of
std::env::vars(), preventing invalid Unicode environment entries from aborting
collection. Convert names safely for allowlist checks and JSON keys, preserve
value lengths using OsStr/OsString byte or encoded representations, and retain
the existing redaction behavior and host/env.json output through BundleWriter.

In `@crates/minimal/src/diag/guest.rs`:
- Around line 128-135: Update the debugfs Command construction in the timeout
flow to enable kill_on_drop(true), ensuring the child process is terminated when
timeout drops the output future. Keep the existing timeout duration, arguments,
image path, and output handling unchanged.

In `@crates/minimal/src/diag/mod.rs`:
- Around line 113-151: Update the collection flow around net::probe_socket and
the guest_result match so args.no_guest is checked before probing the daemon.
When --no-guest is enabled, record the guest collection as skipped and avoid
calling probe_socket or performing any daemon handshake; otherwise preserve the
existing socket probe and guest collection behavior.

In `@crates/minimal/src/diag/net.rs`:
- Around line 135-142: Update command_output to configure the spawned tokio
process with kill_on_drop(true) before applying the timeout, ensuring
netstat/ifconfig children terminate when the timeout drops the output future.
- Around line 240-247: Update the Unix socket connection in the Stage::run call
to enforce the probe’s per-stage deadline before recording the result in
probe.connect. Apply the timeout to the awaited UnixStream::connect future,
preserve the existing success mapping, and convert both connection and timeout
failures into the expected error string format.

In `@crates/minimal/src/diag/procs.rs`:
- Around line 198-214: Update the process-list formatting in the relevant
function, including the analogous path around the additional reported range, so
command-line output preserves argv0 and process metrics but does not emit
complete arguments. Redact or omit subsequent arguments and sensitive flag
values before appending lines to text, while keeping the existing PID filtering
and collection behavior unchanged.
- Around line 126-148: The diagnostic capture paths around command_capture and
the /proc/<pid>/fd walk need bounded resource usage and child cleanup. Limit
captured command output by bytes, cap the number of file descriptors traversed,
and ensure timeout handling explicitly kills and reaps the spawned child rather
than only dropping its future. Apply these limits consistently to the
sample/lsof capture callers while preserving existing error reporting.

In `@crates/minimal/src/diag/redact.rs`:
- Around line 11-25: Replace the broad project prefixes in
ENV_VALUE_ALLOWLIST_PREFIXES with an explicit list of known-safe project
variable names, while retaining the exact allowlist and the sensitive-key
rejection in is_env_value_allowlisted. Ensure variables such as
MINIMAL_DATABASE_URL and MINIMAL_WEBHOOK_URL are no longer allowlisted unless
explicitly known to be safe.

In `@crates/minimald/src/diag.rs`:
- Around line 139-143: Update the include_state_listing branch to execute
common::listing::listing_text through the blocking pool, awaiting its result
before calling append_bytes. Preserve the existing LISTING_MAX_ENTRIES limit and
state-listing error reporting, including handling any blocking-task failure.
- Around line 303-333: Update proc_table to stop reading and exporting raw
/proc/<pid>/cmdline contents. Replace the cmdline value with a safe process
identity such as /proc/<pid>/comm or the executable basename, ensuring the
generated table never exposes command-line arguments or secrets.
- Around line 257-284: Update the session-record loop around
read(entry.path().join("record.json")) and serde_json::from_slice so unreadable
or malformed records add a per-record collector error or safe errors.json entry
before continuing. Include the session identifier and failure context without
exposing record contents, while preserving processing of subsequent sessions.
- Around line 217-235: Update the log-entry collection loop before pushing paths
into files: validate each entry as a regular, non-symlink file and ensure its
filename matches the expected minimald.log rolling-name shape. Skip symlinks,
directories, and other non-regular entries so read_tail() only processes
eligible log files.
- Around line 45-55: Bound diagnostic request handling in stream_diag_bundle and
the corresponding request-read paths: apply a short timeout to read_to_end,
enforce a small maximum request-body size before parsing, and validate or cap
the requested log_tail_bytes against a server-defined maximum before allocating
or collecting logs. Preserve normal parsing and response behavior for requests
within all limits.

In `@crates/minvmd/src/cmd/vmm_child.rs`:
- Around line 89-95: Update the console registration flow around
ctx.set_console_output so any failure to register the boot log is logged as a
warning and does not propagate from run_vmm. Preserve the existing best-effort
File::create handling and continue VM boot regardless of either diagnostic setup
failure.

---

Minor comments:
In `@crates/common/src/listing.rs`:
- Around line 12-18: Update listing_text and the walk_listing traversal to
distinguish reaching max_entries from actually encountering an omitted entry;
emit the truncation marker only when traversal confirms an additional entry
exists beyond the cap. Add a test covering a tree with exactly max_entries
entries and verify it is not marked truncated.
- Around line 41-68: Update the metadata lookup in walk_listing to use
symlink_metadata() so symlink entries are reported using their own type, size,
and mode without dereferencing; keep the existing file_kind handling and
recursion behavior unchanged. Add or update the relevant listing test to cover a
symlink and verify it is reported as a symlink rather than its target.

---

Nitpick comments:
In `@crates/minimal/tests/bug.rs`:
- Around line 249-265: Update bug_no_guest_skips_daemon_contact to instrument
the test daemon from setup, then assert that it receives neither a handshake nor
a diagnostic RPC when cmd_bug runs with --no-guest. Retain the existing archive
assertions and use the daemon’s established request or call-tracking mechanism.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 002f733c-a8b6-43ab-8134-e9667cfc9608

📥 Commits

Reviewing files that changed from the base of the PR and between 5ca67a5 and 110c523.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (31)
  • crates/common/src/lib.rs
  • crates/common/src/listing.rs
  • crates/common/src/redact.rs
  • crates/minimal/Cargo.toml
  • crates/minimal/src/client.rs
  • crates/minimal/src/diag/bundle.rs
  • crates/minimal/src/diag/collect.rs
  • crates/minimal/src/diag/guest.rs
  • crates/minimal/src/diag/manifest.rs
  • crates/minimal/src/diag/mod.rs
  • crates/minimal/src/diag/net.rs
  • crates/minimal/src/diag/procs.rs
  • crates/minimal/src/diag/redact.rs
  • crates/minimal/src/dirs.rs
  • crates/minimal/src/lib.rs
  • crates/minimal/tests/bug.rs
  • crates/minimal/tests/common/mod.rs
  • crates/minimald-rpc/src/lib.rs
  • crates/minimald/src/diag.rs
  • crates/minimald/src/guest.rs
  • crates/minimald/src/lib.rs
  • crates/minimald/src/main.rs
  • crates/minimald/src/rpc.rs
  • crates/minimald/src/server.rs
  • crates/minvmd/Cargo.toml
  • crates/minvmd/src/cmd/run.rs
  • crates/minvmd/src/cmd/vmm_child.rs
  • crates/minvmd/src/lib.rs
  • crates/minvmd/src/main.rs
  • justfile
  • scripts/session-e2e.sh

Comment thread crates/common/src/redact.rs Outdated
Comment thread crates/minimal/src/client.rs
Comment thread crates/minimal/src/diag/bundle.rs Outdated
Comment on lines +72 to +74
let mut file = tokio::fs::File::open(src)
.await
.with_context(|| format!("opening {}", src.display()))?;

@coderabbitai coderabbitai Bot Jul 16, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Do not follow symlinks when collecting files.

File::open follows symlinks, so a crafted log entry can make the bundle copy an unrelated readable file. Open with O_NOFOLLOW, verify it is a regular file, and record other entries as skipped.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/minimal/src/diag/bundle.rs` around lines 72 - 74, Update the
file-collection logic around tokio::fs::File::open to open sources with
O_NOFOLLOW, reject anything that is not a regular file, and record rejected or
symlink entries as skipped rather than failing or copying them. Preserve the
existing context for genuine open errors and continue bundling other files.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially addressed: add_file_tail now lstat-rejects non-regular files (symlink_metadata, no follow), which closes the exfiltration-via-shared-bundle path. Full O_NOFOLLOW plumbing is unnecessary — no privilege boundary is crossed (same-user), and the residual risk was the planted-symlink case this guard covers.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline crates/minimal/src/diag/bundle.rs --items all --type function,method --match 'add_file_tail|add_bytes|append'

printf '\n--- add_file_tail implementation and nearby tests ---\n'
nl -ba crates/minimal/src/diag/bundle.rs | sed -n '45,150p'

printf '\n--- relevant file-operation references in the diagnostic module ---\n'
rg -n -C 3 'symlink_metadata|File::open|O_NOFOLLOW|is_file|file_type' crates/minimal/src/diag

Length of output: 1092


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- add_file_tail ---'
sed -n '75,135p' crates/minimal/src/diag/bundle.rs

printf '%s\n' '--- tests ---'
sed -n '250,330p' crates/minimal/src/diag/bundle.rs

printf '%s\n' '--- related diagnostic file operations ---'
rg -n -C 3 'symlink_metadata|File::open|O_NOFOLLOW|is_file|file_type' crates/minimal/src/diag

Length of output: 7794


@norrietaylor, thanks—the symlink_metadata guard correctly rejects symlinks already present when checked, so it is a meaningful improvement. However, I can’t mark this resolved: there is still a TOCTOU window between symlink_metadata (Line 92) and File::open (Line 98). A process able to modify the source path can replace the regular file with a symlink in that interval, and File::open will follow it.

This remains relevant even without a privilege boundary: the impact is including another readable local file in a diagnostic archive that may be shared externally. Opening with O_NOFOLLOW (and validating the opened descriptor is a regular file) closes that remaining race.


✏️ Learnings added
Learnt from: norrietaylor
Repo: gominimal/minimal PR: 784
File: crates/minimal/src/diag/bundle.rs:98-100
Timestamp: 2026-07-17T00:15:22.192Z
Learning: For `crates/minimal/src/diag/bundle.rs`, the maintainer considers `symlink_metadata`-based non-regular-file rejection sufficient in the same-user threat model and views additional `O_NOFOLLOW` plumbing as unnecessary. The implementation still uses `tokio::fs::File::open` after the metadata check.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Comment thread crates/minimal/src/diag/bundle.rs Outdated
Comment thread crates/minimald/src/diag.rs
Comment thread crates/minimald/src/diag.rs
Comment thread crates/minimald/src/diag.rs
Comment thread crates/minimald/src/diag.rs
Comment thread crates/minvmd/src/cmd/vmm_child.rs
norrietaylor and others added 2 commits July 16, 2026 14:39
The #788 analysis was derailed twice by log framing: abrupt closes of
the CLI's own oneshot RPC connections logged as WARN "session ended
with error" (reading like transport churn), and the binding's
duplicate-request skips logged as WARN with no channel identity
(reading like a dropped shell request).

- every accepted connection gets a monotonic `conn` id, stamped on
  accept, handshake failure, and close
- peer hangups (early eof / broken pipe / disconnected) close at INFO
  as "connection closed by peer"; real errors stay WARN
- bindings log attach and mainloop exit (with reason) at INFO, carrying
  their channel id — the missing correlation for adoption-stall triage
- the catch-all for pre-attach request duplicates drops to DEBUG with
  the channel id

Refs: #788

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Suspend/resume is the most plausible mundane explanation for a
long-lived stream dying silently (#788), yet ruling it in or out meant
asking the user to run pmset by hand. host/power.txt now captures the
last 100 sleep/wake transitions (pmset -g log on macOS, plus sysctl
kern.{boottime,sleeptime,waketime}; journalctl -b -k suspend/resume
lines on Linux), best-effort in the collector idiom. Today's incident
was exonerated this way — the host stayed awake through the wedge —
and that check is now one bundle away instead of a support round-trip.

Refs: #788

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread crates/diagnostics/src/listing.rs
Comment thread crates/diagnostics/src/redact.rs
Comment thread crates/minimal/src/diag/mod.rs

/// The staged probe record for one provider socket.
#[derive(Serialize)]
pub struct SocketProbe {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is a socket probe?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The socket probe is the staged connection-health record for one provider.

It walks the four steps of reaching the daemon in order — stat() the socket file → connect() → SSH handshake+auth → GetVersion RPC — and records which step each one reached. The failing step is the
diagnosis:

  • no socket file → daemon never ran or cleaned up;
  • connect refused → stale socket after a crash;
  • handshake timeout → wedged guest behind libkrun's always-accepting bridge;
  • RPC failure → daemon up but unhealthy.

Comment thread crates/minimald/src/guest.rs Outdated
/// stop. The binary registers a closure here after wiring the appender;
/// [`release_volume_log`] runs it (once) so the file is closed before the
/// unmount. Console logging is unaffected.
static VOLUME_LOG_RELEASE: std::sync::Mutex<Option<Box<dyn FnOnce() + Send>>> =

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Static global mutexes are a smell and rather unsafe in the context of forks - what does this do and can we do it more idiomatically?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Everyone agrees this is a smell 👃

Comment thread crates/minimald/src/main.rs Outdated
/// is mounted, via [`DeferredFileWriter::activate`].
#[derive(Clone, Default)]
struct DeferredFileWriter {
inner: std::sync::Arc<std::sync::RwLock<Option<tracing_appender::non_blocking::NonBlocking>>>,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

std::sync::Arc<std::sync::RwLock<Option<tracing_appender::non_blocking::NonBlocking>>>

Wat

Comment thread crates/minimald/src/rpc.rs Outdated
// The on-volume log appender holds a write-open fd under the mountpoint,
// which defeats both the clean unmount and the read-only remount —
// close it first. Records keep flowing to the console (host boot.log).
crate::guest::release_volume_log();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function calls to change state dont scale, and this is part of that global static mutex being a smell. Maybe a VolumeLog type that mediates log writing, and its Drop does the release?

Are we really sure theres no existing crates that do logging + rotation?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question: I looked for collection and scrubbing crates but didn't look for log rotation (which sure seems like a solved problem)

// `accepted connection`/`closed` lines in `minimald::server` — the
// #788 analysis stalled twice for want of this correlation.
let chan_id = self.channel.id();
tracing::info!(channel = %chan_id, "binding attached to session channel");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The other way to do this would be to setup a tracing span (wired for async with tracing::Instrument) and have this as metadata.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ooooooh

}
// Mark the child as detached so it routes tracing to the rolling
// log file (`<state>/logs/minvmd.log.<date>`) instead of stdout.
cmd.env(crate::DETACHED_ENV, "1");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why dont we just do this unconditionally? also we can do both log files and stdout in the tracing init config

Comment thread crates/minvmd/src/main.rs
.build(&log_dir)
.context("building rolling log appender")?;
let (writer, guard) = tracing_appender::non_blocking(appender);
tracing_subscriber::registry()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wont this overwrite the one init'd when DETACHED_ENV is set?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The file init here runs only in the detached case and the stdout init only in the foreground case,

  • one .init() per process
  • init_tracing early-returns: if DETACHED_ENV.is_none()
  • run --detach re-execs minvmd run with DETACHED_ENV=1,
  • foreground parent and the detached child are separate processes

norrietaylor and others added 4 commits July 16, 2026 17:13
Hardening from CodeRabbit review of the min bug diagnostics work.

common:
- is_sensitive_key: strip the exempt `public_key` token before the
  marker scan so `public_key_token` / `private_public_key` stay masked
  (fail-closed); regression tests for the compound keys

minimal:
- bundle file created 0600 (unredacted contents, lands in cwd)
- add_file_tail: lstat-reject symlinks and bound the read with take(cap)
  so a live-appended log can't exceed the cap
- env collector uses vars_os (vars panics on non-UTF-8, unwinding past
  the collect_step! guard and aborting the whole run)
- download_diag_bundle caps the accumulated daemon error at 64 KiB
- --no-guest skips the socket probe too (it handshakes with the daemon,
  which the flag promises not to do)
- kill_on_drop on every timed subprocess (net, procs, guest debugfs,
  system uname)

minimald:
- proc table records full argv only for the minimal process family;
  everything else contributes comm (name) only — a native daemon's
  /proc is the whole host process table
- state listing runs on spawn_blocking, off the RPC-serving runtime
- session records: read failures and unparseable-withheld records leave
  a record.error.txt note instead of vanishing
- log collector rejects non-regular files (symlink on a guest-writable
  volume)

minvmd:
- boot-log console wiring failure warns and boots on, matching its
  own "not a boot dependency" comment (was propagating)

Refs: #784

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Maintainer review: common is for baseline types used everywhere, and
the diagnostics machinery doesn't belong there. Move the app-agnostic
core into a new `diagnostics` crate:

- redact (was common::redact) — key-based secret masking
- listing (was common::listing) — bounded recursive dir listing
- bundle + manifest (were minimal::diag::{bundle,manifest}) — the
  tar+zstd BundleWriter and its self-describing manifest types

The host-specific collectors (which need a live daemon client, resolved
paths, and the machine's process/network state) stay in minimal::diag
and minimald::diag, now importing from `diagnostics`. BundleWriter no
longer bakes in the CLI version via env!("LONG_VERSION") — the crate is
app-agnostic, so create() takes it as a parameter.

Also hardens minimald/build.rs: git being unavailable (source tarball,
or a worktree whose .git isn't mounted in a cross container) degraded
to an unwrap panic; it now falls back to an empty hash.

Refs: #784

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Maintainer review of the boot-log quiesce fix flagged three linked
smells: a hand-rolled `Arc<RwLock<Option<NonBlocking>>>` deferred
writer ("Wat"), a process-global `static Mutex<Option<Box<dyn FnOnce>>>`
release hook (fork-hazardous), and a free function mutating that global
state (doesn't scale).

Replace all three:
- the deferred writer becomes a `tracing_subscriber::reload` layer that
  starts inert (records still reach the console) and is swapped to the
  on-volume appender post-mount — the library's mechanism, no custom
  MakeWriter. init_tracing returns a `LogActivator` closure so the
  reload handle's generic type never surfaces in a signature.
- the release closure moves from the static into a `VolumeLogRelease`
  field on `ServerState`, threaded async_main -> Server::run ->
  ServerState::new (it can't ride in the serializable `Config`).
- the Shutdown RPC calls `s.release_volume_log().await` — a state
  method, not a free function.

Behavior is unchanged: the on-volume appender's fd is still closed
before the quiesce, keeping the ext4 journal clean on a stop. Needs
the field re-validation (idle attach + `minvmd stop`) before merge.

Refs: #784

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The reload refactor added a parameter to ServerStateHandle::new but the
test-harness caller (gated behind the test-support feature, so the
plain --all-targets clippy missed it) still called it with one arg,
breaking the clippy/tests/root-integration CI lanes. Compile clippy
with --all-features to catch feature-gated callers.

Refs: #784

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
crates/minimald/src/server.rs (1)

236-244: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Add coverage for one-shot volume-log release. Add a test that calls release_volume_log twice and asserts the closure runs only once, so the volume-backed fd is released before unmount.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/minimald/src/server.rs` around lines 236 - 244, Add coverage for
Server::release_volume_log by configuring a volume-log release closure that
records invocation, calling the method twice, and asserting the closure ran
exactly once. Ensure the test verifies the one-shot behavior while preserving
the existing microVM volume-unmount release flow.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/diagnostics/src/listing.rs`:
- Around line 41-51: Update the kind-character computation in the listing flow
to use the non-following result from entry.file_type(), so symlinks are reported
as l while preserving metadata() for size, mtime, and mode. Keep the existing
file_kind behavior for non-symlink entries and leave the recursion check
unchanged.

In `@crates/minimald/build.rs`:
- Around line 3-12: Update the git metadata and cleanliness-check flow in
build.rs to preserve whether Git status was unavailable instead of converting it
to a false dirty result. Only emit the dirty-check warning when the git status
command completes successfully and reports a dirty checkout; source archives or
environments without Git must retain an unknown state and produce no dirty-tree
warning.

---

Nitpick comments:
In `@crates/minimald/src/server.rs`:
- Around line 236-244: Add coverage for Server::release_volume_log by
configuring a volume-log release closure that records invocation, calling the
method twice, and asserting the closure ran exactly once. Ensure the test
verifies the one-shot behavior while preserving the existing microVM
volume-unmount release flow.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: dba2a88e-d6ba-4ae0-9e51-224f60fbfe93

📥 Commits

Reviewing files that changed from the base of the PR and between 6f59c28 and 77fc711.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (22)
  • Cargo.toml
  • crates/diagnostics/Cargo.toml
  • crates/diagnostics/src/bundle.rs
  • crates/diagnostics/src/lib.rs
  • crates/diagnostics/src/listing.rs
  • crates/diagnostics/src/manifest.rs
  • crates/diagnostics/src/redact.rs
  • crates/minimal/Cargo.toml
  • crates/minimal/src/diag/collect.rs
  • crates/minimal/src/diag/guest.rs
  • crates/minimal/src/diag/mod.rs
  • crates/minimal/src/diag/net.rs
  • crates/minimal/src/diag/power.rs
  • crates/minimal/src/diag/procs.rs
  • crates/minimal/src/diag/redact.rs
  • crates/minimald/Cargo.toml
  • crates/minimald/build.rs
  • crates/minimald/src/diag.rs
  • crates/minimald/src/main.rs
  • crates/minimald/src/rpc.rs
  • crates/minimald/src/server.rs
  • crates/minimald/src/test_harness.rs
🚧 Files skipped from review as they are similar to previous changes (8)
  • crates/minimal/src/diag/redact.rs
  • crates/minimald/src/rpc.rs
  • crates/minimal/src/diag/mod.rs
  • crates/minimal/Cargo.toml
  • crates/minimal/src/diag/power.rs
  • crates/minimald/src/diag.rs
  • crates/minimal/src/diag/guest.rs
  • crates/minimal/src/diag/net.rs

Comment on lines +41 to +51
let (kind, size, mtime, mode) = match entry.metadata() {
Ok(m) => (
file_kind(&m),
m.len().to_string(),
m.mtime().to_string(),
format!("{:o}", m.mode() & 0o7777),
),
Err(_) => ('?', "-".into(), "-".into(), "-".into()),
};
let _ = writeln!(out, "{kind}\t{size}\t{mtime}\t{mode}\t{}", rel.display());
if entry.file_type().is_ok_and(|t| t.is_dir()) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Symlinks report their target's type instead of l.

entry.metadata() follows symlinks, so file_kind returns the target's type (e.g., f or d) rather than l. Meanwhile, line 51 correctly uses entry.file_type() (no follow) for the recursion decision — so a symlinked directory is listed as d but never descended into, which is misleading. Use entry.file_type() for the kind character to stay consistent with the recursion check.

🔧 Proposed fix
 fn walk_listing(root: &Path, dir: &Path, out: &mut String, count: &mut usize, max: usize) {
     use std::fmt::Write as _;
     use std::os::unix::fs::MetadataExt as _;
 
     let Ok(entries) = std::fs::read_dir(dir) else {
         let rel = dir.strip_prefix(root).unwrap_or(dir).display();
         let _ = writeln!(out, "?\t-\t-\t-\t{rel}/ <unreadable>");
         return;
     };
     let mut entries: Vec<_> = entries.flatten().collect();
     entries.sort_by_key(|e| e.file_name());
 
     for entry in entries {
         if *count >= max {
             return;
         }
         *count += 1;
         let path = entry.path();
         let rel = path.strip_prefix(root).unwrap_or(&path);
-        let (kind, size, mtime, mode) = match entry.metadata() {
-            Ok(m) => (
-                file_kind(&m),
-                m.len().to_string(),
-                m.mtime().to_string(),
-                format!("{:o}", m.mode() & 0o7777),
-            ),
-            Err(_) => ('?', "-".into(), "-".into(), "-".into()),
-        };
+        let (size, mtime, mode) = match entry.metadata() {
+            Ok(m) => (
+                m.len().to_string(),
+                m.mtime().to_string(),
+                format!("{:o}", m.mode() & 0o7777),
+            ),
+            Err(_) => ("-".into(), "-".into(), "-".into()),
+        };
+        let kind = match entry.file_type() {
+            Ok(ft) => file_kind(&ft),
+            Err(_) => '?',
+        };
         let _ = writeln!(out, "{kind}\t{size}\t{mtime}\t{mode}\t{}", rel.display());
         if entry.file_type().is_ok_and(|t| t.is_dir()) {
             walk_listing(root, &path, out, count, max);
         }
     }
 }
 
-fn file_kind(meta: &std::fs::Metadata) -> char {
-    let ft = meta.file_type();
+fn file_kind(ft: &std::fs::FileType) -> char {
     if ft.is_dir() {
         'd'
     } else if ft.is_symlink() {
         'l'
     } else if ft.is_file() {
         'f'
     } else {
         's' // socket/fifo/device — "special"
     }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let (kind, size, mtime, mode) = match entry.metadata() {
Ok(m) => (
file_kind(&m),
m.len().to_string(),
m.mtime().to_string(),
format!("{:o}", m.mode() & 0o7777),
),
Err(_) => ('?', "-".into(), "-".into(), "-".into()),
};
let _ = writeln!(out, "{kind}\t{size}\t{mtime}\t{mode}\t{}", rel.display());
if entry.file_type().is_ok_and(|t| t.is_dir()) {
let (size, mtime, mode) = match entry.metadata() {
Ok(m) => (
m.len().to_string(),
m.mtime().to_string(),
format!("{:o}", m.mode() & 0o7777),
),
Err(_) => ("-".into(), "-".into(), "-".into()),
};
let kind = match entry.file_type() {
Ok(ft) => file_kind(&ft),
Err(_) => '?',
};
let _ = writeln!(out, "{kind}\t{size}\t{mtime}\t{mode}\t{}", rel.display());
if entry.file_type().is_ok_and(|t| t.is_dir()) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/diagnostics/src/listing.rs` around lines 41 - 51, Update the
kind-character computation in the listing flow to use the non-following result
from entry.file_type(), so symlinks are reported as l while preserving
metadata() for size, mtime, and mode. Keep the existing file_kind behavior for
non-symlink entries and leave the recursion check unchanged.

Comment thread crates/minimald/build.rs
Comment on lines +3 to +12
// git may be unavailable (source tarball, a worktree whose .git is not
// mounted inside a cross container): degrade to an empty hash rather than
// panicking the build.
let git_hash = Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()
.unwrap();
let git_hash = String::from_utf8(output.stdout).unwrap();
println!("cargo:rustc-env=GIT_HASH={}", git_hash);
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.unwrap_or_default();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Do not report missing Git as a dirty checkout.

When git is unavailable, git_hash becomes empty, but the cleanliness probe fails to false, causing release builds from source archives to emit an inaccurate dirty-tree warning. Preserve an “unknown/unavailable” state and warn only after a successful git status.

Also applies to: 25-29

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/minimald/build.rs` around lines 3 - 12, Update the git metadata and
cleanliness-check flow in build.rs to preserve whether Git status was
unavailable instead of converting it to a false dirty result. Only emit the
dirty-check warning when the git status command completes successfully and
reports a dirty checkout; source archives or environments without Git must
retain an unknown state and produce no dirty-tree warning.

@norrietaylor

Copy link
Copy Markdown
Member Author

This one has gotten out of hand - moving to draft

@norrietaylor

Copy link
Copy Markdown
Member Author

Decomposition plan of record is up: spec + architecture in #802, tracking issue #801. This PR stays draft as the reference implementation — the eight unit PRs take file states from this branch — and closes when the series lands.

norrietaylor added a commit that referenced this pull request Jul 22, 2026
* docs: add diagnostics subsystem spec and architecture

Decomposes the min bug diagnostic bundle work (PR #784, moved to draft
at +4107 lines) into eight demoable units, each an independently
mergeable PR under 1000 lines. Records the design decisions from the
review and research pass: app-agnostic machinery in a dedicated
diagnostics crate, one manifest schema for both bundle layers,
size-capped rotation via logroller, OTEL-compatible log conventions
with zero OTEL dependencies, and a one-shot blob transport chosen for
the wedged-process case.

Refs: #801

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: add system and interface diagrams to the diagnostics architecture

Four mermaid views: crate-boundary system overview with the degraded
debugfs path, the full min bug collection sequence, the in-VM log
pipeline with the release-before-quiesce ordering, and TRACEPARENT
propagation across the CLI-daemon boundary.

Refs: #801

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: address CodeRabbit review on the diagnostics spec

Pins the mode-specific manifest paths, upgrades the symlink guard to a
no-follow open (TOCTOU-safe), gives the listing API failure/truncation
semantics, adds the capture-timeout proof, layers the sensitive-key
policy over the env allowlist, sets the per-prefix log-file cap at 5,
corrects the Unit 3 artifact locations, standardizes the TRACEPARENT
env name, bounds nested-bundle verification against decompression
bombs, defines the argv key=value scrub and honest log/argv trust
wording, exports the capture API in the curated surface, shows the
serde defaults on DiagBundleRequest, and reconciles the traceparent
fallback with R4.4.

Refs: #801

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: capture the guest-side incident trio in the daemon bundle

New R6.6: the in-VM bundle gains routes+addresses (route + fib_trie),
hang triage for the marker-matched family (pid-1, session hosts, task
children), the fd-to-socket-inode join as the binary-free lsof
equivalent, and allowlisted env. The partial wedge — daemon responsive
while one binding or child is stuck, the #788 shape — was invisible to
the guest bundle. R5.2 now requires the Linux mechanics be pure /proc
reads so pid-1 can run them: the microVM rootfs has no lsof, ss, or ip.

Refs: #801

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: recast the diagnostics spec in functional language

Removes issue/PR numbers and reviewer attributions from the spec and
architecture doc. The motivating incident is described as a failure
class, review guidance as settled design boundaries, and the prior
branch strictly as the reference implementation baselines cite. The
tracking-issue frontmatter is unchanged (house convention).

Refs: #801

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: distinguish single-shot streaming from live streaming in the spec

The bundle transport is itself a streaming RPC — one request, one
streamed tar.zst, close — mirroring the wire's existing
WorkspaceFilesTarZst subsystem pattern in the reverse direction. The
spec's earlier 'not streaming' framing conflated that single-shot form
with what it actually rejects: a long-lived stream of live debug data
from a running daemon. R6.1 now cites the established pattern, and the
Non-Goals, Design Considerations, and Alternatives sections use the
precise terms.

Refs: #801

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: adopt the two zero-runtime OTEL-orbit crates in the spec

Sharpens the Unit 4 boundary from 'zero OTEL deps' to 'zero OTEL
runtime dependencies' and adopts the two crates that carry none of the
rejected costs: opentelemetry-semantic-conventions (consts only) pins
the resource-attribute names, and json-subscriber replaces the built-in
JSON formatter — flat records with static top-level resource fields the
stock formatter cannot emit, dependency closure already in the
workspace, composing over the Unit 3 MakeWriter stack unchanged.
Resource identity moves from a root span to top-level statics, which
map onto the OTLP Resource exactly. Also corrects the future-seam
pricing: prost is already resident, so an opentelemetry-proto OTLP-JSON
emitter is cheaper than the general estimate.

Refs: #801

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: record the log-pipeline backpressure coupling and the bridge boundary

Technical Considerations now names the lossy(false) backpressure chain
— a wedged volume can propagate through the bounded non_blocking
channel into every logging daemon thread — as a deliberate coupling
with its two mitigations (console layer independent of the volume;
volume fallback collects without the daemon), backed by a new
needs-spike ledger row measuring the headroom claim during Unit 3. The
adopted-crates rationale states json-subscriber's execution honestly:
log-write path of a healthy process only, no pipeline machinery,
nothing at collection time. tracing-opentelemetry is named as the
future export-seam crate and why it cannot be adopted piecemeal (id
generation requires TracerProvider machinery; batch-buffered spans
lose the tail on a hang).

Refs: #801

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(specs): land reusable collector mechanics in-crate from first use

The host-bundle unit's generic mechanics (TOML redaction walker, masked
process env, rotated-log selection, disk usage, first-line capture) move
into the diagnostics crate at introduction rather than migrating later:
each has a second consumer in the daemon bundle. The CLI contributes
policy only — allowlist, prefixes, paths, archive layout.

Refs: #801
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(specs): reconcile unit 3 baseline with shared versioning; mtime log ordering

The per-crate build.rs the daemon-logs unit patched no longer exists —
the shared version crate's build script already falls back cleanly
without git, so the no-git-panic requirement is satisfied on the
baseline and the unit carries no code for it. Rotated-log selection is
documented as modified-time ordered: logrotate-style numeric shifting
sorts opposite to date suffixes, so filename order cannot be the
contract.

Refs: #801
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(specs): system probe joins the diagnostics crate surface

Refs: #801
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(specs): reconcile the spec with the landed crate boundary

The host-bundle unit's crate additions grew: the system probe joins the
surface (disk paths as caller policy, best-effort fields), so the
incident-collectors unit no longer migrates generic helpers — its
requirement now asserts the crate rule holds rather than re-moving
already-moved code. Collector requirements pick up the hardened
contracts: filesystem errors are never reported as absence, every
content read goes through the shared no-follow open, the listing walk
runs on a blocking thread so collector timeouts stay effective, and
rotation records the logrotate-style shift plus graceful archive
publication that keeps an unmounting volume free of partial files. The
system probe drops the version field claim — the manifest records the
producer version once.

Refs: #801
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(specs): drop the superseded unit-5 collect.rs sketch

Refs: #801
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(specs): revert rotation decision to tracing-appender daily

logroller's size-based rotation is reverted for a smaller dependency
surface and simpler shutdown; tracing-appender's daily appender rotates
and prunes inline, so the volume-log release is a plain guard drop with
no background thread to join and no partial intermediates on unmount.
R3.6 becomes time-based with max_log_files retention; the design
consideration, technical consideration, assumption ledger, and system
diagrams follow, and the accepted tradeoff (no intra-day size cap) is
recorded.

Refs: #801
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(specs): add a porting baseline for the remaining units

The reference tree is a single 4107-line branch, but the merged units
have reshaped the crate and daemon layout the later units build on. Add
a Porting baseline section that maps the current state a fresh executor
must reconcile against — the diagnostics crate's already-exported
mechanics, the crates/minimal/src/diag layout, the minimald DaemonLogger
and rpc dispatch anchor, and the minimald-rpc trace module — so Units
5-8 can be picked up without reverse-engineering the merged PRs.
Re-anchor the drifted origin/main citation for the streaming-subsystem
pattern on its grep-able constant rather than a line number.

Refs: #801
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@norrietaylor
norrietaylor deleted the min-bug-diagnostics branch July 25, 2026 04:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants