Skip to content

feat(minimald): log RPC dispatch, session lifecycle, and upload bytes - #895

Merged
norrietaylor merged 2 commits into
mainfrom
feat/minimald-rpc-dispatch-logging
Jul 22, 2026
Merged

feat(minimald): log RPC dispatch, session lifecycle, and upload bytes#895
norrietaylor merged 2 commits into
mainfrom
feat/minimald-rpc-dispatch-logging

Conversation

@norrietaylor

@norrietaylor norrietaylor commented Jul 22, 2026

Copy link
Copy Markdown
Member

The deficit, measured

On main, crates/minimald/src/rpc.rs (1292 lines) contains:

macro count
tracing::info! 1 (line 391, "state volume quiesced for shutdown")
tracing::warn! 18
tracing::debug! 0
tracing::error! / tracing::trace! 0

Every one of the 18 warns is the same interpolated line —
tracing::warn!("RPC handler for {} failed: {}", X::NAME, e) — which is
also the shape docs/rust-coding-standards.md explicitly rules out.

Three consequences, all verified in the tree:

  1. The trace ids are minted and thrown away. handle_ssh_rpc builds an
    rpc span carrying rpc, trace_id, span_id, parent_span_id and
    instruments every handler future with it — but emits no event inside
    it. grep -rn 'FmtSpan\|with_span_events' crates/minimald/src returns
    nothing, so neither the console layer nor mlog::json_file_layer renders
    span open/close. A span containing no events produces no output
    whatsoever. The W3C traceparent is parsed off the channel env, adopted,
    re-minted as a child — and then never written anywhere.
  2. serve_create_session logs nothing on success.
  3. serve_stream_workspace_files — the workspace upload — emits nothing
    at all
    : no start, no byte count, no completion, and no failure line
    beyond whatever the transport happens to log. The daemon pipes
    c.make_reader()ZstdDecoderasync_tar::Archive::unpack with no
    counter anywhere.

Why it matters — the incident

During #869, a guest-side log read, in full:

INFO  minimald::server: accepted connection conn=4 peer=cid: 2 port: 3903114191
WARN  minimald::server: session ended with error conn=4 error=Protocol error: No buffer space available (os error 105)

Between those two lines, 1.81 seconds apart, the daemon created a session
and received ~12.5 MB of tar
. Neither is in any log. The session's UUIDv7
decodes to 0.4 ms after the accept, which is how it was eventually
established — by decoding a timestamp out of a session id, because no log
line said so.

Worse, the absence was misread as evidence. The investigation concluded the
console had dropped records and spent a stretch reasoning about a transport
that was fine. A later measurement — diffing the console mirror against the
on-volume log byte for byte — showed nothing was ever dropped. The
evidence was never written.

What each new record buys a reader

1. One pair of records per served RPC (served)

INFO conn{conn=4 transport=vsock}:rpc{rpc=…/CreateSession trace_id=… span_id=…}: rpc dispatched
INFO conn{conn=4 transport=vsock}:rpc{rpc=…/CreateSession trace_id=… span_id=…}: rpc served outcome=ok duration_ms=3

This is the record that makes the rpc span emit at all — the ids above
reach the log for the first time. It also gives the incident's silent
window a shape: which RPCs ran on conn=4, in what order, and how long
each took. duration_ms is the field that separates "the daemon was busy"
from "the daemon was blocked".

The rpc name, trace_id and span_id come from the span rather than being
repeated as event fields: mlog::json_file_layer sets
flatten_span_list_on_top_level(true), so span fields already land at the
top level of every JSON record, and duplicating rpc on the event would
emit a duplicate key.

2. Session lifecycle

INFO …:rpc{rpc=…/CreateSession …}: session created session_id=0198… session_name=norrie-minimal-4f2a
INFO …:rpc{rpc=…/DestroySession …}: session destroyed session_id=0198… session_name=norrie-minimal-4f2a

Directly answers the question that cost the most time in #869: did the
daemon create a session on this connection, and which one?
No UUIDv7
timestamp archaeology. DestroySession carries only an id on the wire, so
the name is resolved from the record before the delete — best-effort, and a
failed lookup degrades to <anonymous> rather than failing a destroy that
would otherwise succeed.

3. Workspace upload, with bytes

INFO …:rpc{rpc=…/WorkspaceFilesTarZst …}: workspace upload started session_id=0198…
INFO …:rpc{rpc=…/WorkspaceFilesTarZst …}: workspace upload complete bytes_received=12782336
WARN …:rpc{rpc=…/WorkspaceFilesTarZst …}: workspace upload failed bytes_received=12500992 error=unpack failed: …

bytes_received is the single most valuable number in this PR. In the
incident, "12.5 MB received, then failed" against a 12.78 MB client-side
payload
points straight at a transport that dies near the end of a large
transfer — as opposed to a rejected handshake (bytes_received=0) or a
malformed archive (full byte count, unpack error). That distinction was
unavailable and had to be reconstructed by hand.

The tally comes from a CountingReader spliced between the SSH channel and
the zstd decoder, so it counts compressed wire bytes — the figure that
compares directly against what the client sent, not the size of the
unpacked tree. The counter is an Arc<AtomicU64> rather than a return
value precisely because the reader is swallowed by the decoder and then the
unpacker: on a mid-stream failure they surface an error and drop the
reader, and the shared tally is the only surviving evidence of how far the
upload got.

Start-and-finish vs finish-only

Chosen: start-and-finish. Finish-only halves the volume and is
tempting, but a finish-only log cannot distinguish these two states:

  • an RPC that is still running, and
  • an RPC that was never dispatched.

Both look identical: nothing in the log. "Started and never finished" is
exactly what a hang looks like, and a hang is the failure mode this logging
exists to catch — #869's connection died with an RPC in flight. A dispatch
record turns that from an absence into a positive statement: this rpc,
with this trace id, on this connection, began and did not return.

The same argument applies to the workspace upload's start record, which is
emitted after the session resolves and before the first byte is pulled, so
a wedged transfer still leaves a line naming its destination session.

Log-volume impact at INFO

The RPC subsystem is a per-CLI-invocation path, not a per-keystroke
one: interactive attach traffic runs over shell/exec channels, which this
PR does not touch. So the multiplier applies to command dispatches, not to
terminal I/O.

Per served RPC: 2 INFO records where there were 0. A console record at
this shape (timestamp + level + conn/transport + the four rpc span
fields + target + message) runs ~260 B, so ~520 B per RPC.

A full session launch is CreateSessionWorkspaceFilesTarZst
ConfigureLoadoutSubmitVerdict) ≈ 4 RPCs:

records bytes
dispatch pairs (4 RPCs) 8 ~2.1 KB
session created 1 ~0.3 KB
upload start + complete 2 ~0.5 KB
per session launch 11 ~2.9 KB

A minimal ls is 1 RPC → 2 records, ~0.5 KB.

At a sustained 10 RPC/s — well beyond realistic interactive use; that is
ten CLI invocations every second, every second — this adds ~5 KB/s, ~19 MB/h.
The file log is Rotation::DAILY with max_log_files(14), so even a
pathological load is bounded by rotation rather than growing without limit.
Realistic steady state is single-digit RPCs per user action: tens of
records per minute, low hundreds of KB per day.

Nothing was added at DEBUG; the level distribution stays deliberate rather
than becoming a firehose that has to be filtered back out.

Arguably already-owed scope

Spec 10 R4.3 (on origin/arch/diagnostics-spec, not main
docs/specs/10-spec-diagnostics/10-spec-diagnostics.md:540) reads:

R4.3: Top-level operations (CLI command dispatch, daemon RPC
dispatch
, session/binding lifecycles) shall mint ids in OTLP-required
formats — trace_id 32 lowercase hex, span_id 16 lowercase hex —
recorded as span fields. Correlation values (channel_id, session_id,
…) are span fields with stable snake_case keys, never interpolated into
messages.

Daemon RPC dispatch does mint them, correctly, in the required formats.
It just never emits them, which makes the requirement satisfied on paper
and inert in practice. This PR is the missing half.

Incidental cleanup

The 18 duplicated if let Err(e) = res { tracing::warn!("RPC handler for {} failed: {}", …) } blocks are subsumed by the dispatch record, so the
serve_* handlers now return their Result instead of swallowing it —
one error path instead of eighteen, and the interpolated-string form is
gone from the file. serve_stream_workspace_files still relays its failure
to the client over extended data exactly as before.

Verification

macOS cannot build minimald (procfs), so both runs are via cross against
aarch64-unknown-linux-musl.

$ CROSS_CONTAINER_OPTS="--env HOME=/tmp" cross test -p minimald --target aarch64-unknown-linux-musl
running 146 tests
test rpc::tests::counting_reader_tallies_every_byte_pulled_through ... ok
test rpc::tests::stream_workspace_files_rejects_unknown_session ... ok
test rpc::tests::stream_workspace_files_unpacks_tarball_into_workspace ... ok
test result: ok. 146 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 73.50s

     Running unittests src/main.rs
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.08s

     Running tests/mesh_uc7.rs
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

     Running tests/netns_root_integration.rs
test result: ok. 0 passed; 0 failed; 3 ignored; 0 measured; 0 filtered out

   Doc-tests minimald
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
$ CROSS_CONTAINER_OPTS="--env HOME=/tmp" cross clippy -p minimald \
    --target aarch64-unknown-linux-musl --all-targets -- -D warnings
    Checking minimald v0.5.0-rc1
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1m 19s

The one new test, counting_reader_tallies_every_byte_pulled_through,
pulls an 8 KiB payload through CountingReader in many small polls and
asserts the tally accumulates across them rather than recording only the
last read — the counter is worth logging only if it is exact. The record
emission itself is declarative; the arithmetic is the part that can be
wrong.

Refs: #869

Note

Add RPC dispatch logging, session lifecycle events, and upload byte counting to minimald

  • Wraps every RPC handler with a new served() helper in rpc.rs that logs rpc dispatched on start and rpc served/rpc failed with elapsed duration on completion, replacing per-handler warn! calls.
  • Logs session created and session destroyed events with session_id and session_name (falling back to <anonymous>) on successful create/destroy.
  • Adds CountingReader<R>, an AsyncRead wrapper that tracks bytes read via a shared Arc<AtomicU64>, used to report bytes_received in workspace upload logs.
  • All handlers now return Result<(), ConnectionError> and propagate errors to the caller instead of logging locally.

Macroscope summarized e403426.

Summary by CodeRabbit

  • Improvements
    • Improved RPC reliability and consistency across session, workspace, and system operations.
    • Added clearer logging for RPC dispatches, outcomes, durations, session deletion, and workspace uploads.
    • Improved visibility into workspace upload sizes, including compressed data transferred.
    • Standardized error handling to provide more consistent failure reporting.

`rpc.rs` held exactly one `tracing::info!` against 18 `tracing::warn!`
and zero `tracing::debug!`. `handle_ssh_rpc` built an `rpc` span carrying
`rpc`/`trace_id`/`span_id` and instrumented every handler future with it,
but emitted no event inside it — and no fmt layer enables `FmtSpan`, so a
span containing no events produces no output at all. The ids were minted
and discarded.

Add three sets of records, structured fields throughout, all emitted
inside the existing spans so they carry `conn`, `transport`, `rpc`,
`trace_id` and `span_id` without repeating them:

- `served` brackets every dispatched handler with `rpc dispatched` and
  `rpc served`/`rpc failed` (`outcome`, `duration_ms`). Start-and-finish
  rather than finish-only: finish-only cannot distinguish an RPC still
  in flight from one never dispatched, and "started, never finished" is
  exactly the shape of a hang.
- `create_session` and `destroy_session` emit `session_id` and
  `session_name` at INFO.
- The workspace upload emits start and completion/failure records
  carrying `bytes_received`, tallied by a `CountingReader` spliced
  between the channel and the zstd decoder. The transfer previously
  emitted nothing whatsoever.

The 18 per-handler `if let Err(e) { warn!("RPC handler for {} failed") }`
blocks are subsumed by the dispatch record, so the `serve_*` handlers now
return their `Result` rather than swallowing it.

Refs: #869
@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

RPC handlers now propagate ConnectionError results to a centralized logging wrapper. Workspace uploads count compressed bytes through a shared reader counter, and destroy-session logging resolves the session name before deletion.

Changes

RPC observability and transport handling

Layer / File(s) Summary
RPC handler result propagation
crates/minimald/src/rpc.rs
RPC handlers return Result<(), ConnectionError> directly while preserving endpoint response mappings and unavailable-feature handling.
Workspace upload byte accounting
crates/minimald/src/rpc.rs
Workspace streaming counts compressed bytes consumed by the zstd decoder and tests accumulation across multiple reads.
Centralized RPC dispatch logging
crates/minimald/src/rpc.rs
Dispatched RPC futures are instrumented and wrapped with success, failure, and elapsed-duration logging.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SSHChannel
  participant handle_ssh_rpc
  participant served
  participant RPCHandler
  SSHChannel->>handle_ssh_rpc: receive RPC request
  handle_ssh_rpc->>served: spawn instrumented future
  served->>RPCHandler: invoke RPC handler
  RPCHandler-->>served: return result
  served-->>handle_ssh_rpc: log outcome and duration
Loading

Possibly related PRs

  • gominimal/minimal#754: Updates overlapping session-lifecycle RPC handlers and their request/response behavior.

Suggested reviewers: evanspearman

Poem

I counted each byte as it hopped through the stream,
While RPCs logged their outcome and gleam.
Errors returned neatly, no warnings astray,
Session names surfaced before deletion day.
A rabbit applauds: “What a tidy array!”

🚥 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.
Title check ✅ Passed The title is concise, conventional, and accurately summarizes the main change: added RPC/session/upload logging in minimald.
Description check ✅ Passed The description includes Summary, Testing, and a checklist section, with substantial implementation and verification detail.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

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

@norrietaylor

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@norrietaylor

Copy link
Copy Markdown
Member Author

@macroscope review

@macroscopeapp

macroscopeapp Bot commented Jul 22, 2026

Copy link
Copy Markdown

Manual reviews triggered for commit a215997:

All prior checks · these links stay valid even if you push more commits.

@norrietaylor
norrietaylor enabled auto-merge (squash) July 22, 2026 13:23
@macroscopeapp

macroscopeapp Bot commented Jul 22, 2026

Copy link
Copy Markdown

Just FYI for future @mentions, I'm Macroscope-App, not Macroscope.

Review in progress. Results will be posted as check runs when complete.

@macroscopeapp

macroscopeapp Bot commented Jul 22, 2026

Copy link
Copy Markdown

Approvability

Verdict: Would Approve

Adds observability logging (RPC dispatch timing, session lifecycle, upload byte tracking) without changing runtime behavior. Author owns the modified file, and new CountingReader logic is covered by a unit test.

Macroscope would have approved this PR. Enable approvability here.

…patch-logging

# Conflicts:
#	crates/minimald/src/rpc.rs
@norrietaylor
norrietaylor merged commit 0013c46 into main Jul 22, 2026
29 checks passed
@norrietaylor
norrietaylor deleted the feat/minimald-rpc-dispatch-logging branch July 22, 2026 17:50
norrietaylor added a commit that referenced this pull request Jul 22, 2026
#918)

PR #895 changed the serve! dispatch so every handler returns
Result<(), ConnectionError> for outcome logging; PR #878 merged
alongside it with serve_stream_diag_bundle still returning (). The two
were each green against a main that lacked the other, and the type
mismatch only surfaced on branches built after both landed.

Failure still relays the message over the channel's extended-data
stream before surfacing as ConnectionError::Internal, mirroring
serve_stream_workspace_files.

Refs: #878, #895

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
norrietaylor added a commit that referenced this pull request Jul 22, 2026
…880)

* feat(minimal): guest fetch and degraded-mode fallback for `min bug`

Per provider, `min bug` now performs the staged socket probe and — when
the probe handshakes — downloads the daemon's own bundle over the
DiagBundleTarZst subsystem, nesting it under providers/<name>/guest/.
--no-guest skips daemon contact entirely; --guest-timeout-secs bounds
each provider's download. Host-side log-prefix skips are deferred until
the provider loop settles whether the daemon's logs reached the bundle
another way, so the manifest never claims an absence the archive does
not back.

Squashed rebuild of the original branch onto main after #878 landed
there, replacing the merge-heavy history whose #889 squash title also
failed commitlint.

Refs: #802

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

* fix(minimald): return the served contract from the diag bundle handler

PR #895 changed the serve! dispatch so every handler returns
Result<(), ConnectionError> for outcome logging; PR #878 merged
alongside it with serve_stream_diag_bundle still returning (). The two
were each green against a main that lacked the other, and the type
mismatch only surfaced on branches built after both landed.

Failure still relays the message over the channel's extended-data
stream before surfacing as ConnectionError::Internal, mirroring
serve_stream_workspace_files.

Refs: #878, #895

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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