dev-rust-patterns
Skill179 starsChanged 12 days agoApache-2.0View on GitHub
Rust patterns and hard-won lessons. Use when writing Rust in capsem-core, capsem-app, or capsem-agent: async/tokio, non-blocking I/O, cross-compilation.
In this repository37 other files it gives its agents.
- AGENTS.mdAGENTS.md
- asset-pipelineSkill
- build-imagesSkill
- build-initrdSkill
- citadelSkill
- dev-benchmarkSkill
- dev-bug-reviewSkill
- dev-cacheSkill
- dev-capsem-doctorSkill
- dev-capsemSkill
- dev-ciSkill
- dev-debuggingSkill
- dev-gateSkill
- dev-installationSkill
- dev-justSkill
- dev-mcpSkill
- dev-mitm-proxySkill
- dev-session-debugSkill
- dev-setupSkill
- dev-skillsSkill
- dev-sprintSkill
- dev-startSkill
- dev-testing-frontendSkill
- dev-testing-hypervisorSkill
- dev-testing-pythonSkill
- dev-testingSkill
- dev-testing-vmSkill
- frontend-designSkill
- ironbankSkill
- meta-find-skillsSkill
- meta-organize-skillsSkill
- meta-skill-creationSkill
- release-processSkill
- site-architectureSkill
- site-infraSkill
- site-marketingSkill
- use-capsemSkill
The file
Show its rules and text
We split this skill into the rules it gives an agent
28 rules. About 0 are common, 8 are distinctive, and 20 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈8
Distinctive · estimate
This author's own choices. Worth a look.
20
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 10
Read data-path performance before changing transport, admission, serialization, hashing or buffer ownership.
- Very specific: line 82
Do not hide route SQL by adding route-specific helpers to DbWriter.
- Very specific: line 94
Use tokio::select! with a cancellation token or shutdown signal.
- Very specific: line 107
Use anyhow::Result for application code (capsem-app, scripts)
- Very specific: line 108
Use thiserror for library errors in capsem-core (typed, matchable)
- Very specific: line 126
Use unix::contained::ContainedDir for guest-influenced paths.
- Very specific: line 130
Use unix::fs for owner-only directories, no-follow regular-file access, filesystem capacity, atomic private publication, permission bits …
- Very specific: line 137
When foundation lacks a primitive, add it to foundation with its tests.
- Very specific: line 143
Put each primitive in its own module with tests in the sibling tests.rs.
- Very specific: line 147
When transferring connected sockets with unix::router_channel, retain the original socket until a request-bound receipt confirms that the …
- Distinctive: line 178
Before changing guest RAM, KVM storage or OCI boot, read Guest RAM and VirtioFS performance.
- Very specific: line 180
Keep RAM and KVM slot offsets aligned to 2 MiB and request transparent huge pages through capsem_foundation::unix::guest_memory::GuestRam.
- Very specific: line 199
Never add serde_json::Value fields to structs that parse LLM request/response bodies.
- Distinctive: line 200
Remove unused fields from deserialization structs -- they still force Serde to allocate.
- Very specific: line 229
Shared runtime caches: Use capsem-cache primitives instead of a local map and eviction policy.
- Distinctive: line 255
When to reach for this pattern:
- Distinctive: line 261
When NOT to reach for it:
- Very specific: line 263
If the contention is per-VM (two handlers acting on the same VM), protect the VM entry in instances: Mutex<HashMap<...>> instead.
- Distinctive: line 264
If the "contention" is really a durability race (writer thread hasn't flushed), the right fix is usually the signal-handler …
- Distinctive: line 320
Call out when NOT to use it:
- Very specific: line 325
When adding a new long-running process or a new background-thread owner, wire it through Shutdown from day one.
- Very specific: line 332
Use structured fields: tracing::info!(domain = %domain, status = %code, "request completed")
- Distinctive: line 379
Content-Encoding: Always handle response decompression generically.
- Very specific: line 383
Debouncer timing: If a VM shuts down before debounced events flush, telemetry is lost.
- Very specific: line 405
UDS socket permissions must be 0600: After UnixListener::bind(), immediately set_permissions(..., 0o600).
- Very specific: line 418
Do not use registry.get(&id) in route code unless the route id has first been resolved to the registry key.
- Very specific: line 420
When returning UI/API payloads, preserve both id and name.
- Very specific: line 446
Read references/rust-async-patterns.md for comprehensive tokio patterns (tasks, channels, streams, error handling).
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Rust Patterns
Data paths: zero-copy first, no speculative hashing or JSON
Read data-path performance before changing transport, admission, serialization, hashing or buffer ownership. Prefer moving or borrowing bytes and one bounded encoding into the final frame. Hashing and JSON require a concrete, documented contract; neither is a default safety step.
Async / non-blocking
Capsem uses tokio for all async I/O. The MITM proxy, vsock manager, and file monitor are all async.
Publish matching in-memory ownership inside the serialized blocking admission,
before releasing its I/O permit. A reader can observe the durable record before
the async caller resumes after spawn_blocking; publishing its ticket after
.await exposed valid managed-session claims to recovery_pending. The
controller regression pauses that continuation and claims through the real store.
Never block the tokio runtime
Long-running synchronous work (FUSE request processing, disk I/O, compression) must run on a dedicated thread via tokio::task::spawn_blocking or a dedicated std::thread. Blocking inside a tokio task starves other tasks.
The VirtioFS FUSE server runs on its own thread for this reason -- FUSE ops are synchronous by nature (read, write, lookup) and can't be made async without significant complexity.
Blocking-in-async anti-pattern (systemic -- audit, don't spot-fix)
Any code path that does blocking I/O inside an async function or while holding a tokio::sync::Mutex is a bug. This causes the tokio worker thread to stall, freezing the entire gateway, UI, or network stack until the blocking operation completes.
What counts as blocking I/O:
std::process::Command(subprocess execution)std::fs::*(read, write, copy, remove_dir_all, create_dir_all)walkdir::WalkDir(directory traversal)blake3::Hasheron large data (hash computation)std::thread::sleep
The fix pattern -- in capsem-service, route handlers go through ServiceState::off_worker (crates/capsem-service/src/blocking.rs), and crates/capsem-service/src/tests/async_io_contract.rs refuses direct blocking calls. Elsewhere, the shape is:
let result = tokio::task::spawn_blocking(move || {
let rt = tokio::runtime::Handle::current();
rt.block_on(async {
let mut guard = mutex.lock().await;
sync_blocking_work(&mut guard)
})
}).await.unwrap_or_else(|e| /* handle panic */);Example site: the fork clone (crates/capsem-service/src/vm_files/fork.rs) runs clone_sandbox_state -- fsync, clonefile, and a full tree walk -- on the blocking pool. Any new tree copy, hash, or directory walk reachable from an async handler needs the same treatment.
Channel patterns
tokio::sync::mpscfor producer-consumer (vsock data flow, telemetry events)tokio::sync::broadcastfor fan-out (serial output to multiple subscribers)tokio::sync::oneshotfor single-response request-reply (control messages)
Logger DB boundary
capsem-logger owns SQLite connection/thread/storage mechanics. Rust service,
gateway, MCP, UI, and benchmark code must not call rusqlite::Connection::open
or DbReader::open directly for telemetry/security ledgers, and must not add
service-owned projection caches.
Callers may own query intent, but the DB object owns execution:
db.ready().await?;
db.query(sql, params).await?;
db.write(event).await?;db.write(event).await is an accept boundary: the DB-owned producer buffer
has taken responsibility for the event. Read-after-write tests must use the DB
flush barrier or shutdown/reopen before asserting route-visible rows. Never add
route sleeps, route projection caches, or caller-owned SQLite reads to force
visibility.
Do not hide route SQL by adding route-specific helpers to DbWriter; the DB
writer is not a product route registry. Put connection threads, mem/disk
tables, batching, flushing, WAL tuning, and future FTS5/search in
the DB layer. Empty tables are fine. Missing tables or columns are schema
contract failures and must not be converted into empty results.
Coalescing buffer
Terminal output uses a CoalesceBuffer (8ms window, 64KB cap) to batch small vsock reads into larger writes. This prevents xterm.js from choking on thousands of tiny updates. The pattern: accumulate into a buffer, flush on timer or size threshold.
Graceful shutdown
Use tokio::select! with a cancellation token or shutdown signal. Every long-running task must respect shutdown. Dangling tasks after VM exit cause resource leaks.
Cross-compilation
Guest binaries target aarch64-unknown-linux-musl and x86_64-unknown-linux-musl. Key gotchas:
- Platform-specific types:
libc::ioctlrequest param isc_ulongon macOS butc_inton Linux. Useas _to let the compiler infer the correct type. - Linker:
.cargo/config.tomlsetslinker = "rust-lld"for both musl targets. - No std dependencies: musl builds are fully static. Avoid crates that link to system libraries.
- Test on both:
cargo check --target aarch64-unknown-linux-muslcatches cross-compile errors without needing to boot a VM.
Error handling
- Use
anyhow::Resultfor application code (capsem-app, scripts) - Use
thiserrorfor library errors in capsem-core (typed, matchable) - Propagate errors up, don't swallow them. If a function returns
Result, the caller must handle it. - Log errors at the point where you have context, then propagate. Don't log AND propagate (causes duplicate log lines).
Host Unix boundary
Reusable host Unix mechanics belong in the small modules under
capsem_foundation::unix; application crates consume their typed APIs instead
of calling nix or libc directly. This keeps ownership, EINTR handling,
errno classification, CLOEXEC, no-follow behavior, and race checks identical
across the CLI, service, process, guard, credentials, and core.
- Accept
BorrowedFdand returnOwnedFdorFile. Never return a raw descriptor whose close responsibility is implicit, and never pairManuallyDrop<File>with a manualclose. - Expected races are typed outcomes: a missing process and lock contention are not generic errors. Keep expected disconnects quiet; log unexpected failures once where the caller knows the operation, object identity, and errno.
- Use
unix::contained::ContainedDirfor guest-influenced paths. Its descriptor-relative traversal, constrained open options, and regular-file checks must remain in the same syscall sequence; do not put a path-based check in front of a separate path-based action. - Use
unix::fsfor owner-only directories, no-follow regular-file access, filesystem capacity, atomic private publication, permission bits (set_mode), durability (sync,sync_before_barrier,sync_filesystem) and copy-on-write cloning (clone_file_into,copy_sparse). Useunix::tree_cloneto copy a directory tree a guest can write. Useunix::lockfor advisory locks, including synchronous read-modify-write sections. - When foundation lacks a primitive, add it to foundation with its tests;
never reach for
nixorlibcat the call site "just this once". - Inside foundation,
nixis the syscall layer andunix::errno::ioconverts its errors. A rawlibc::call is allowed only wherenixcannot express it (no wrapper, or a platform gap such asMSG_NOSIGNALon macOS), and each one is listed with that reason intests/citadel/unix_boundary_debt.toml. - Put each primitive in its own module with tests in the sibling
tests.rs. Cover errno preservation, ownership after the source is dropped, symlink and path-replacement races, special files, exact modes, and concurrent callers.
When transferring connected sockets with unix::router_channel, retain the
original socket until a request-bound receipt confirms that the receiver owns
and validated its descriptor. A completed sendmsg only queues SCM_RIGHTS;
Darwin may collect sockets with no owner outside that queue. Bound the receipt
wait, reject wrong identities, directions and extra descriptors, and preserve
scope cancellation and positive child reaping. The native MCP regression is
upstream_broker/mcp_spawn/tests.rs: it delays adoption, checks that the original
stays alive, and proves it is released after the receipt. Keeping an extra
socket for the child's entire lifetime masks disconnects and leaks descriptors.
The same rule covers startup policy, proxy traffic/MCP/trace hints, ledger
clients, gateway connections and the aggregator's owner connection. Auditing
only the native MCP bootstrap missed these paths and left a macOS policy-update
broken pipe. tests/citadel/test_descriptor_adoption_lifetime.py guards the
early-drop sites, including private cable handoff: its table reference can be
replaced by a reconnect before adoption. Gateway and upstream grant tests drop the received copy
before acknowledging to prove the sender still owns its original, then assert
EOF after adoption to rule out retention leaks.
Direct Unix calls remain valid only in modules that implement a kernel or
platform ABI rather than reusable host policy, such as KVM/FUSE and guest audit
plumbing. tests/citadel/test_unix_boundary.py and its exact domain-ABI
inventory enforce that distinction; extending the inventory requires explicit
review, not a convenience exemption. The same guard inventories every raw
libc:: call and extern "C" binding inside foundation itself, rejects a new
one that nix already wraps, and catches aliased imports (use ::libc as sys),
extern "C" { fn ... } blocks and renamed or table-form manifest
dependencies. Before it did, foundation's own internals were unscanned and
raw calls with nix wrappers accumulated there unnoticed.
Guest RAM and VirtioFS performance
Before changing guest RAM, KVM storage or OCI boot, read
Guest RAM and VirtioFS performance.
Keep RAM and KVM slot offsets aligned to 2 MiB and request transparent huge
pages through capsem_foundation::unix::guest_memory::GuestRam. A regular
4-KiB-backed mapping once made kernel startup take 1.1 seconds and a 352-MB
EROFS scan wait 1.5 seconds in reads. The allocation and slot regression is
guest_ram_and_kvm_slot_offsets_preserve_huge_page_alignment in
hypervisor/kvm/memory/tests.rs; alignment alone is insufficient without the
per-mapping huge-page hint.
Bidirectional I/O -- thread per direction
When bridging two blocking file descriptors bidirectionally (e.g., TCP socket to vsock in net_proxy.rs, or master PTY to vsock in capsem-pty-agent), doing both reads and writes in a single thread using poll(2) causes deadlocks. If both outgoing buffers fill simultaneously, a single thread blocks on writing and stops reading, creating mutual lockup. Always spawn a dedicated thread for at least one direction (std::thread::spawn for fd_b -> fd_a while the main thread handles fd_a -> fd_b).
Serde -- avoid serde_json::Value on LLM payloads
The MITM proxy and ai_traffic parsers handle massive HTTP payloads (megabytes of tool calls, histories, images). Parsing these into serde_json::Value does full DOM allocation, which is inefficient and risks memory exhaustion.
Rules:
- Define targeted structs with
#[derive(Deserialize)]. Serde skips and discards fields not in the struct without allocating memory for them. - For struct fields that hold large, unconstrained JSON (tool call arguments, function responses, full model outputs) and are only converted to strings: use
Box<serde_json::value::RawValue>instead ofserde_json::Value.RawValuekeeps the JSON as an unparsed string slice -- zero DOM allocation. Access the raw JSON string via.get(). - Never add
serde_json::Valuefields to structs that parse LLM request/response bodies. If you only need a string representation, useRawValue. If you need to traverse nested fields, use a typed struct. - Remove unused fields from deserialization structs -- they still force Serde to allocate.
Example -- before (bad):
struct FunctionCall {
name: Option<String>,
args: Option<serde_json::Value>, // full DOM parse of potentially huge args
}
// later: let arguments = fc.args.as_ref().map(|v| v.to_string());After (good):
struct FunctionCall {
name: Option<String>,
args: Option<Box<serde_json::value::RawValue>>, // zero-copy string slice
}
// later: let arguments = fc.args.as_ref().map(|v| v.get().to_owned());Memory and resource management
- File handle limits: VirtioFS caps at 4096 open file handles, returns
EMFILEbeyond that. - Read size limits: VirtioFS clamps reads to 1MB, gather buffers to 2MB.
- Safe deserialization:
read_structreturnsOption<T>with bounds checks in all builds (not just debug). - irqfd for interrupt delivery: Guest interrupt signaling uses
irqfdto avoid cross-thread syscall overhead.
Concurrency patterns
- Shared runtime caches: Use
capsem-cacheprimitives instead of a local map and eviction policy. Import capacities and freshness windows from the generatedcapsem_proto::CACHE_*constants; their only numeric source is[cache]inconfig/data/runtime-limits.toml. - Arc for shared state: VM state, proxy config, and telemetry handles are
Arc-wrapped for sharing across tasks. - Per-connection tasks: The MITM proxy spawns a new tokio task per connection. Each task owns its TLS state and upstream connection. No shared mutable state between connections.
- Config-owned runtime ceilings: Network concurrency and in-memory cache
policy are owned by
config/data/runtime-limits.tomland generated intocapsem-proto. Import the shared protocol constant in host and guest consumers; never duplicate a numeric capacity in individual crates. Ledger queue and batch capacities derive from the same supported-client ceiling, and bounded coalescing waits use the generated duration rather than a local sleep literal. Extend the runtime-limit Citadel guard for every new consumer. See/dev-mitm-proxyfor the load proof required when a limit changes.
Host-serialization locks for per-host critical sections
When a service orchestrates N sibling child processes on a single host and some operations cannot safely run two-at-a-time on that host -- whether because of a framework constraint (Apple VZ save/restore) or because of shared-resource starvation (VZ teardown + WAL checkpoint + virtiofs drain all competing for main-thread and I/O bandwidth) -- park a tokio::sync::Mutex<()> on the service's shared state struct and acquire it at the top of the handler for the whole duration of the critical section. Mutex<()> isn't a weird construction: the unit value is the lock-token, the type signals "pure serialization, no protected payload". Semaphore::new(1) is equivalent -- pick one and stay consistent.
Current instances in crates/capsem-service/src/main.rs:
save_restore_lock: serializes Apple VZsaveMachineStateToURL/restoreMachineStateFromURLacross sibling VMs. Concurrent save/restore corrupts the VirtioFS ring state on the unlucky VM, surfaces as ext4-on-loop0 I/O errors after resume. Held throughhandle_suspend(IPC + child-exit wait) andhandle_resume(spawn +wait_for_vm_ready). Seeweb/docs/src/content/docs/gotchas/concurrent-suspend-resume.md.shutdown_lock: serializes VM teardown acrosshandle_delete/handle_stop/handle_purge/handle_run. Without it, N concurrent deletes under load starve each other of the bandwidth eachcapsem-processneeds to exit cleanly within the 1s fast-path budget; past the budget the service SIGKILLs mid-checkpoint and leaves a non-emptysession.db-wal. Held throughshutdown_vm_processfor the wholeSIGTERM+wait_for_process_exitwindow.
When to reach for this pattern:
- Symptom is "works solo, fails under concurrency on the same host."
- Root cause is a per-host resource, not per-VM: Apple VZ main thread, virtiofsd, DbWriter checkpoint, APFS fsync.
- Production runs exactly one service per host per user, so an in-process tokio mutex is enough -- no need for a file-lock or distributed primitive.
When NOT to reach for it:
- If the contention is per-VM (two handlers acting on the same VM), protect the VM entry in
instances: Mutex<HashMap<...>>instead. - If the "contention" is really a durability race (writer thread hasn't flushed), the right fix is usually the signal-handler explicit-cleanup pattern below, not another serialization lock.
Signal-driven explicit cleanup for background-thread owners
Any long-running Rust process that owns background threads (SQLite writer, the fs-monitor scan loop, MCP aggregator subprocess, vsock relay) and runs under a bounded SIGTERM-to-SIGKILL budget must NOT rely on Drop + tokio-runtime-drop ordering to finish cleanup. On SIGTERM, hand owned resources to the signal handler and drain them synchronously BEFORE letting the main run loop return.
Symptom when this is missing: under concurrent teardowns on one host, the service SIGKILLs a child mid-checkpoint or mid-flush. Visible as session.db-wal left non-empty, missing fs_events rows, dangling aggregator subprocesses. Works solo, fails under -n 4.
Concrete primitives in this tree:
DbWriter::shutdown_blocking(&self)— takes the stored mpsc sender, joins the writer thread, runs the finalPRAGMA wal_checkpoint(TRUNCATE). Arc-safe: otherArc<DbWriter>clones remain valid but their writes become no-ops. Idempotent. Drop delegates to it.FsMonitor::shutdown_and_join(&self)— sends on the shutdown channel so the scan loop runs one final scan and emission, then joins the thread. Bounded at two scans and two emissions. Must run BEFORE DbWriter shutdown, because fs_events fan into DbWriter.CAPSEM_TEST_SLOW_CHECKPOINT_MS— test-only env var inwriter_loopthat inserts a sleep before the final checkpoint. Use in tests that need to distinguish explicit cleanup from implicit runtime-drop ordering.
Canonical wiring in crates/capsem-process/src/main.rs:
struct Shutdown {
db: Option<Arc<DbWriter>>,
fs_monitor: Option<FsMonitor>,
}
impl Shutdown {
fn drain_blocking(&mut self) {
// fs_events fan into DbWriter -- flush fs_monitor first.
if let Some(m) = self.fs_monitor.take() { m.shutdown_and_join(); }
if let Some(db) = self.db.take() { db.shutdown_blocking(); }
}
}
// Populate as owners are constructed:
shutdown.lock().await.db = Some(Arc::clone(&db));
shutdown.lock().await.fs_monitor = Some(monitor);
// Signal handler drains through spawn_blocking, then stops the run loop:
rt.spawn(async move {
/* wait on SIGTERM/SIGINT */
let mut owned = std::mem::take(&mut *shutdown.lock().await);
let _ = tokio::task::spawn_blocking(move || owned.drain_blocking()).await;
unsafe { core_foundation_sys::runloop::CFRunLoopStop(...); }
});Key properties:
- Deterministic order. The drain order is explicit (fs_monitor -> db), not "whatever reverse-declaration-order Drop happens to give us after tokio aborts tasks."
- Synchronous join. The handler waits for each background thread to finish. No "hope the task finishes before the runtime drops."
- Run loop stops last.
CFRunLoopStop(macOS) fires only after drain returns. Main returns afterwards; the remaining tokio-runtime drop is now a no-op fast path because the heavy work already completed. - Arc-safe shutdown APIs.
shutdown_blocking(&self)works through a sharedArc<DbWriter>— callers don't have to chase down every clone. Usestd::sync::Mutex<Option<Sender>>internally; the hot-pathwrite()clones the sender under the lock and releases it before.await.
When to reach for this pattern:
- The process has
std::thread::spawnortokio::task::spawn_blockingworkers that run durability-critical work on shutdown (WAL checkpoint, queue flush, child-process wait). - A parent sends SIGTERM then SIGKILLs after a short, fixed budget.
- Today's cleanup relies on Drop running inside tokio task abort — i.e., you can't draw a line between "cleanup finished" and "run loop exited."
Call out when NOT to use it:
- One-shot CLIs that exit on natural task completion (no run loop, no signal window).
- Workers whose only side effects are in-memory (no durability to lose).
When adding a new long-running process or a new background-thread owner, wire it through Shutdown from day one. Don't ship a new binary that "should be fine because Drop will run" — under load, Drop won't run in time.
Logging
tracingcrate withFmtSpan::CLOSEfor timing spansRUST_LOG=capsem=debugfor full boot timing breakdownRUST_LOG=capsem=infofor top-level only- Use structured fields:
tracing::info!(domain = %domain, status = %code, "request completed")
One rule, one function
A rule that lives in its callers is a rule that will be wrong in some of them. Resolution, precedence, and lifetime rules belong in a function; callers call it.
This is the single most expensive pattern in this codebase's history. Two instances, both found the same day:
| Rule | Copies | Outcome |
| "read the recent log" | 4 (support_bundle, /service-logs, triage, service session tails) | rotation landed, one copy learned, two silently returned nothing -- a daemon logging errors reported none |
| "point Capsem at a temp root" | every fixture, by hand | CAPSEM_RUN_DIR/CAPSEM_ASSETS_DIR outrank CAPSEM_HOME, so setting the home alone read the caller's directories: green locally, broken in the gate |
Both look locally correct in every copy. That is what makes them expensive: nothing is visibly wrong at any one site, and a fix applied to one never reaches the others.
Established wrappers -- use them, do not re-derive:
| Need | Call | Never |
| Host log content | telemetry::read_log_tail(stream, max) | File::open/fs::read on a *.log path |
| Session console/transcript | session::read_session_serial_log / read_session_transcript | Reopening a root-level log path or resolving worker-writable parents by path |
| Enumerate a stream | telemetry::log_stream_files(stream) | read_dir + name filtering |
| Redirect paths in a fixture | paths::CapsemPathsGuard::redirect(root) | set_var("CAPSEM_HOME", ...) |
Any ~/.capsem/... path | a paths:: helper | home.join(".capsem") |
| Checkpoint marker | paths::checkpoint_complete_path(cp) | rebuilding <name>.complete |
build_system/tests/gate/test_path_and_log_wrappers_are_mandatory.py enforces the first three.
When unifying, take the better implementation, not the first one.
support_bundle's tail reader seeked to each file's end; the shared one read
whole files. Guest console output is guest-controlled, so read-whole let a
chatty VM decide how much memory capsem support allocated. The shared
function now seeks.
Two copies that agree today are still a bug. checkpoint_complete_path
existed in capsem-process and capsem-service, identical except that one
hardcoded the fallback name and the other used a constant. Changing that
constant would have left the process writing a resume marker the service never
looked for, with nothing wrong at either site.
Lessons learned
- Content-Encoding: Always handle response decompression generically. Gzip compressed SSE responses caused NULL telemetry because the parser got binary garbage. Never strip Accept-Encoding as a workaround.
- Platform type widths:
as _is your friend for cross-platform libc calls. Explicit casts (as c_ulong) will fail on the other platform. - Debouncer timing: If a VM shuts down before debounced events flush, telemetry is lost. Add
sleep 1in test commands, or use explicit flush on shutdown. - VirtioFS whiteouts: Apple VZ's VirtioFS doesn't support
mknod, so overlayfs can't use it directly as upper. The ext4 loopback workaround provides full POSIX. - setsid for controlling terminal: Without
setsid, the PTY has no foreground process group and Ctrl-C (SIGINT) is not delivered.capsem-initusessetsidto fix this. - serde_json::Value on LLM hot path: Three ai_traffic struct fields (
ResponseInfo.output,FunctionResponse.response,FunctionCall.args) usedserde_json::Valuefor large payloads that were only stringified. This forced full DOM allocation on every streaming request. Fixed by removing unused fields and switching toBox<serde_json::value::RawValue>. - Prefer syscalls over subprocesses:
std::process::Commandcosts 5-30ms per spawn (fork/exec). If a syscall does the same thing, use it. Example:cp -c -Rfor APFS clonefile was 20-30ms; a direct clone syscall is <1ms.capsem_foundation::unix::fs::clone_file_intousesfclonefileaton macOS and theFICLONEioctl on Linux -- no subprocess. Always check if the OS provides a syscall before reaching forCommand. - Blocking I/O in MCP tool handlers: Tool handlers once ran blocking I/O (clonefile subprocess, walkdir, blake3) directly on tokio worker threads while holding a
tokio::sync::Mutex, so tool calls hung from the model's perspective. Fixed by wrapping inspawn_blockingeverywhere; see the anti-pattern section above. - Clone guest-writable trees through descriptors, never paths: Everything below
session_dir/guest/is guest-controlled and may change mid-copy (an entry swapped for a symlink to a host path). Fork/create-from therefore go throughcapsem_core::session::clone_sandbox_state, which composescapsem_foundation::unix::tree_clone: descriptor-relativeO_NOFOLLOWopens, exclusive destination creation, symlinks recreated but never followed, special files skipped, setuid/setgid/sticky bits dropped, and a non-regularrootfs.imgrefused. File contents clone from the open descriptor viaunix::fs::clone_file_into(shared extents, or a sparse copy when the filesystem cannot share them). Never add a path-basedstd::fs::copy/walkdircopy of guest state. A CoW clone is metadata-only, so flushrootfs.imgbefore cloning (flush_system_overlay) or the clone captures stale data. - Platform-gate all macOS-only APIs: Any code using macOS-only symbols (
libc::clonefile, Apple framework bindings, etc.) must be wrapped in#[cfg(target_os = "macos")]-- both the struct/impl and the tests. The Linux app build (Tauri deb/AppImage) compiles the full workspace; ungated macOS symbols causecannot find functionerrors on Linux CI. This burned v0.14.7: a clone helper usedlibc::clonefilewithout a cfg gate. Rule: when adding platform-specific code, gate the definition, the impl, and the tests. - Readiness gates must reflect actual state:
handle_ipc_connectionresponded to Ping with Pong the moment the UDS socket existed -- before vsock connections, boot handshake, or command handler spawn.wait_for_vm_readytreated Pong as "ready", so exec commands were sent to a process that couldn't handle them yet, blocking silently in a channel untilsetup_vsockfinished. Tests masked this withwait_exec_ready()client-side retry loops, creating a double-wait: 30 client retries x 30s server wait each. Fix:Arc<AtomicBool>(vm_ready) gated bysetup_vsockafter BootReady; IPC handler only sends Pong when the flag is set. One wait, one place -- the server waits; the client calls once. When adding any new IPC readiness check, never respond "ready" based on socket existence alone; check actual process state via a shared flag or state enum. - VirtioFS and FSEvents: Apple VZ VirtioFS guest writes bypass macOS FSEvents (the kernel's file notification subsystem), so
notify::RecommendedWatchersilently drops guest-originated events and a host directory shared into a guest has to be polled. Do not reach fornotify::poll::PollWatcherto do it: it walks withfollow_links(true)hardcoded (notify-7.0.0/src/poll.rs) and itsconfigureis a no-op, so one guestln -s / workspace/evilturns every scan into a walk of the host's entire filesystem. Own the loop instead, ascrates/capsem-core/src/fs_monitor.rsdoes: awalkdirwalk withfollow_links(false), a snapshot of(kind, len, mtime, ctime, ino)per entry, and the difference between two walks as the events. Owning it also lets the cadence track the measured scan cost rather than a constant, and lets a symlink be recorded as a symlink instead of being descended. - Process sandbox: env_clear() on child spawn: When spawning a child process (e.g., capsem-process from service), always call
env_clear()then re-add only the minimal env vars needed (HOME,PATH,USER,TMPDIR,RUST_LOG). The service's shell environment may contain API keys, tokens, or secrets that the child process has no business seeing. The guest's--envargs are a separate injection path and are already validated. - UDS socket permissions must be 0600: After
UnixListener::bind(), immediatelyset_permissions(..., 0o600). The default umask leaves sockets world-accessible, meaning any local user can connect to a VM's IPC or terminal WebSocket with no auth. The gateway token file already does this; per-VM sockets must match. - Never process::exit() on guest-controlled I/O: A guest can close a vsock fd at any time. If the host handler calls
process::exit(1)on read error, the guest has an unconditional DoS. Usebreakto exit the read loop and let the process shut down through normal channels. - Sensitive logs cross a worker boundary: Terminal and serial files contain raw user data. Use
unix::fssingle-link, no-follow file helpers and bound reads; a confined writer can still plant a redirect at its own log path. Below worker-writable directories, anchor access throughContainedDirdescriptors. Preparesession::prepare_session_logsunder the exclusive owner lock before confinement: it retainsowner/logs/and migrates legacy generations without overwriting conflicts. Hypervisor console and terminal output share oneSharedLogWriterand rotation counter; never open an independent appender. Writers retain directory descriptors across rotation, which needs directory authority rather than exact-file grants. Keep session root and ledger outside that grant. Create files with mode 0600 and repair permissions before confinement or through the authorized broker; confined writers must not callset_permissions. Session readers use the shared console/transcript APIs, falling back only for a missing layout. Support exports redact recognized credential bytes even in mixed binary/text lines, preserving unrelated bytes without a UTF-8 bypass. The PTY, telemetry, support-export and actual owner-sandbox regressions prove retention and malicious redirects. - VirtioFS share boundary -- only guest/ subtree: The VirtioFS share must point at
session_dir/guest/, notsession_diritself. Host-only state (session.db,owner/logs/,owner/checkpoint.vzsave,system/rootfs.img) stays outside the share. The system disk lives in the host-onlysystem/directory; never restore a compatibility link into guest storage. Put guest-visible content underguest/. Usecapsem_core::guest_share_dir(session_dir)to get the share root. - VM route id is not the display name:
SandboxInfo.id, route path ids,InstanceInfo.id, DB handle keys, session directory basenames, andCAPSEM_VM_IDmust be the opaque VM/session id. Persistent registrynamevalues such asco-work1orcode-vm1are display aliases only. User surfaces may accept names, but they must translate to id before calling/vms/{id}/.... Do not useregistry.get(&id)in route code unless the route id has first been resolved to the registry key; use the persistent identity helpers instead. When returning UI/API payloads, preserve bothidandname. Regression coverage lives inpersistent_session_routes_keep_uuid_id_separate_from_display_name,resume_sandbox_requires_uuid_route_id_not_display_name, andpersistent_route_identity_source_guard. - Use
capsem_core::poll::poll_untilfor all async polling: All "wait until ready" patterns must use the sharedpoll_untilutility incapsem-core/src/poll.rs. It provides deadline-based timeout, exponential backoff, and structured tracing (attempt count, elapsed time, label). Never write ad-hocfor _ in 0..N { sleep(X) }orwhile now < deadline { sleep(fixed) }loops -- they lack logging, use fixed intervals instead of backoff, and hardcode timeouts. For sync code (guest agent),vsock_connect_retryinvsock_io.rshas the same pattern witheprintlnlogging. Every retry loop must have a total deadline. - DRY wait patterns -- one wait, one place: When a server endpoint already waits for a subprocess to become ready (e.g.,
wait_for_vm_readyinhandle_exec), clients must not add their own retry loop on top. The test helperwait_exec_readypreviously polled 30 times with 1s sleep, and each poll triggered a 30s server-side wait -- a 30x30s pathological cascade. After fixing the readiness gate (lesson 9), the client calls exec once with adequate HTTP timeout and the server handles the wait. Apply this DRY principle to any client/server readiness pattern: decide which layer owns the wait and make others pass through. - Companions must not outlive their parent --
kill_on_dropis not enough:tokio::process::Command::kill_on_drop(true)only fires when the parent'sChildhandle is dropped on graceful shutdown. Under SIGKILL/OOM/test-harness timeout/ pytest-xdist worker death, Drop never runs and companion processes (gateway, tray) get re-parented to PID 1 and survive forever. Everyjust test -n 4run leaked a fresh batch of orphans; accumulated orphans caused VM-ready poll spins, UDS-port collisions, and the suspend/resume regression. Defense in depth is mandatory for any spawned companion process, enforced on the COMPANION side so the parent can't get it wrong:- Pass
--parent-pid <spawner_pid>when spawning. - Companion calls
capsem_guard::install(parent_pid, lock_path)?at startup:- Refuses to run if parent PID is missing, dead, or not our actual
getppid()(parent_is_expected). Exit 0 — standalone launches become silent no-ops. - Acquires an
flock(2)singleton atlock_path(O_CLOEXEC opened atomically; process-local registry covers the brief fork-to-exec window where the flock fd can be inherited). Second instance exits 0. - Spawns a 100ms-interval watcher thread that calls
std::process::exit(0)the momentgetppid()no longer equals the declared parent PID.getppid()is immune to zombie state and flips to 1 on re-parenting, which is the reliable signal across SIGKILL, SIGSEGV, and OOM. - The mandatory terminator runs before any best-effort tracing. Parent death commonly closes a companion's stdout/stderr pipes; logging to that dead sink can panic the watcher thread, so observability must never stand between detecting reparenting and exiting.
- Refuses to run if parent PID is missing, dead, or not our actual
- Lock paths: tray is SYSTEM-WIDE (
$HOME/.capsem/run/tray.lock) because the macOS menu bar is a shared global resource; gateway is per-run_dir because each test's gateway bridges a distinct UDS. Regression tests intests/capsem-service/test_companion_lifecycle.pycover: refuse-standalone (no parent / wrong parent), singleton (double spawn, 20-way hammer), and die-with-parent (SIGKILL the parent, companion exits within 5s). When adding any new companion process, wire it throughcapsem-guard— don't invent a new pattern.
- Pass
- Retry loops must classify errors, not time-bound a blanket wait: When waiting for a resource to come up, the retry closure must distinguish retryable errors from permanent ones, and the classification depends on the caller's context, not the error itself. Identical
NotFound/ConnectionRefusederrors mean "service is down, give up" on an initial probe but "socket not bound yet, keep waiting" one call later in the post-launch retry. Pattern:- Use
capsem_core::poll::poll_until, not a hand-rolled backoff loop. The poll primitive already gives you deadline, exponential backoff, per-attempt logging (label + elapsed + attempts), and a typedTimedOuterror. Every new retry site that reinvents these is a future bug -- thecapsem doctor"Service manager started capsem but socket not ready" bug existed only becauseUdsClient::connect_with_timeouthand-rolled its own loop and fast-failed onENOENTbefore the just-started service had bound its socket. - Inside the
poll_untilclosure: returnNoneon retryable errors (poll_untilkeeps polling), returnSome(Err(...))on permanent errors (poll_untilexits immediately). - Thread a small enum, not a
patient: bool, so every call site documents intent:ConnectMode::FailFastvsConnectMode::AwaitStartup,ProbeMode::ExpectedvsProbeMode::MustBeRunning.crates/capsem/src/client.rs::UdsClient::connect_with_timeoutis the canonical example in the tree. - Don't
.map_err(|_| anyhow!(...))on the timeout branch. You erase the inner cause. Chain withContextso the root error lives in the error chain and{err:#}prints both the summary and the underlying io::Error kind.
- Use
Async reference
Read references/rust-async-patterns.md for comprehensive tokio patterns (tasks, channels, streams, error handling). From the community (6.4K installs).
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

