Skip to content

feat(sessions,minimald,minvmd): session persistence contracts + provider index - #715

Closed
norrietaylor wants to merge 1 commit into
mainfrom
feat/vdb-unit3-persistence
Closed

feat(sessions,minimald,minvmd): session persistence contracts + provider index#715
norrietaylor wants to merge 1 commit into
mainfrom
feat/vdb-unit3-persistence

Conversation

@norrietaylor

Copy link
Copy Markdown
Member

Stacked on #705. Now that session worktrees and the package cache survive a VM restart on the /dev/vdb volume, several things that were harmless when all state died with the per-boot tmpfs become persistent traps. This PR closes them.

Failure modes fixed

1. A corrupt session index bricks every session — silently, after READY.
The session store hard-failed to construct if sessions/index.json didn't parse. On a tmpfs that was nearly impossible (the file was rewritten every boot); on the persistent volume a torn write survives, and an unclean stop is exactly how you get one. The daemon then reached READY but every session RPC failed against the un-constructable store — a VM that looks healthy and can touch none of its sessions, with no path back short of hand-deleting the file.
Now: a parse failure is treated as recoverable (the index is derived state, not the source of truth). The store falls back to an empty index, rebuilds it from the per-session record.json files via the self-heal pass that already existed, and writes a valid index back. A read I/O error still fails — only corruption self-heals.

2. Stale provider identity persists across reboots.
providers/ holds boot-ephemeral identity — the host SSH key, known_hosts, socket paths. On the tmpfs it was regenerated every boot. On the persistent volume it would carry a previous boot's host key forward, so a client that learned the new key mid-session, or any host-key rotation, would silently mismatch.
Now: the guest resets providers/ after mounting the volume and before regenerating its instance dir, while sessions/ and cache/ are preserved. The reset only ever touches entries inside providers/ — never a glob over the state root — so persistent user data is untouchable by construction.

3. A dirty worktree silently swallows every push.
When a session's checked-out worktree had uncommitted changes, the git post-receive hook printed a note to stderr and continued — the push exited 0, the refs updated, but the working tree was never checked out. The client believes it deployed new code; the guest keeps running the old code. On a tmpfs the dirty state cleared on the next boot; on the persistent worktree it sticks, so every subsequent push to that session is silently ignored until someone manually cleans the tree.
Now: a pre-receive hook rejects the push loudly — ! [remote rejected] (pre-receive hook declined) and a non-zero git push exit — unless the client passes git push -o force-checkout, which discards the uncommitted changes and checks out. receive-pack advertises push options and sets receive.denyCurrentBranch=ignore (the hook pair, not git's default guard, owns worktree consistency). Recovery from a mid-checkout failure is a re-push.

4. The host can't locate a session that outlives its VM.
Sessions now persist on the volume image after the VM process exits, but nothing on the host recorded which image holds which session — the mapping lived only inside the ext4 image, invisible until a VM booted it. Any future routing of attach/activate to the owning VM (#311) had nothing to route against.
Now: a host-side ProviderIndex at <state>/session_index.json maps each session id to { image_path, vm_id }, written atomically. It's derived state — a corrupt or missing file loads empty and is rebuilt — so it never becomes its own failure mode.

Transport note (why not the guest→host RPC the design sketched)

Maintaining the index was originally sketched as the guest pushing session-lifecycle events to the host. That's unimplementable safely: libkrun's vsock wedges when a guest→host connection overlaps host→guest traffic (#588 — the failure that red-lit autospawn-e2e on #672), and lifecycle events fire precisely while the client's own host→guest SSH connection is open, so even one-shot emits can't be serialized clear of it. Instead the run supervisor polls ListSessions over the existing host→guest bridge (the proven direction) and reconciles the index — MINVMD_SESSION_POLL_SECS, default 15 s, 0 disables. Entries persist across VM stop (a stopped VM's sessions stay routable); the cost is bounded staleness, fine for a consumer that's future work. Only the run supervisor maintains the index — bare minvmd boot leaves nothing behind to poll. architecture.md is updated to record this.

Verification

Gated e2e (MINVMD_E2E=1, verified on macOS/HVF): a created session appears in session_index.json with the right image path and vm id, a destroyed one is removed, and a session live at minvmd stop persists in the index. Unit coverage: corrupt-index recovery (three shapes — bad bytes, wrong-shape JSON, corrupt-with-no-records), providers reset preserving sessions/cache, the git-push hook matrix (clean push, clean re-push, dirty rejection, forced override) driven against real git, and ProviderIndex round-trip / atomic-flush / corrupt-load.

Refs: #583. Full multi-VM routing on top of the index is #311.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: fc5d80c5-e8ac-4bd4-b97d-018e0ffaa477

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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

Base automatically changed from feat/vdb-unit2-quiesce to main July 10, 2026 22:15
…der index

Unit 3 of the per-VM writable ext4 volume spec (R3.1-R3.5): the
operational contracts that follow from user data living permanently on
the volume.

- R3.1 (sessions): DiskLoader::new no longer hard-fails on a corrupt
  sessions/index.json — the index is derived state, so a parse failure
  falls back to an empty index, the existing self-heal pass rebuilds it
  from per-session record.json files, and a valid index is flushed
  back. Transient read I/O errors still fail. A torn index write now
  survives restarts, so this path is reachable in practice.

- R3.2 (minimald): the guest boot path resets `providers/` (boot-
  ephemeral host keys, known_hosts, socket paths) after mounting the
  volume, while `sessions/` and `cache/` are untouchable by
  construction — no glob over the state root.

- R3.3 (minimald, rescoped to the git-push path — the tar receiver in
  rpc.rs has no ported client, #603): a push into a session worktree
  with uncommitted tracked changes is rejected loudly by a new
  pre-receive hook ("[remote rejected] (pre-receive hook declined)",
  non-zero `git push` exit) instead of post-receive's silent skip;
  `git push -o force-checkout` overrides and discards those changes.
  receive-pack now advertises push options and sets
  receive.denyCurrentBranch=ignore — the hook pair owns worktree
  consistency, and git's default guard refused every re-push to the
  branch post-receive last checked out. Hooks live in
  src/git_hooks/*.sh via include_str!.

- R3.4 (minvmd): new ProviderIndex — a persistent JSON map of
  SessionId -> { image_path, vm_id } at <state>/session_index.json,
  atomic rename writes, corrupt-file loads as empty (derived data).

- R3.5 (minvmd): the `run` supervisor maintains the index by polling
  ListSessions over the existing host->guest bridge UDS
  (MINVMD_SESSION_POLL_SECS, default 15, 0 disables). This deliberately
  replaces the spec's guest->host SessionLifecycle RPC: a held-open
  guest->host vsock wedges against host->guest traffic (#588), and
  lifecycle events fire exactly while the client's own host->guest
  connection is open, so one-shot emits cannot be serialized safely
  either. Entries for other VMs — and entries for a VM that stopped —
  persist: the mapping is what future multi-VM routing (#311) needs.
  architecture.md updated accordingly (ledger row settled-by-avoidance).

e2e (gated MINVMD_E2E=1, verified on macOS/HVF): a created session
appears in session_index.json with this VM's image path and vm_id, a
destroyed one is removed, and a session live at `minvmd stop` persists.

Refs: #583

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@norrietaylor
norrietaylor force-pushed the feat/vdb-unit3-persistence branch from 6e782ea to b4ceaf2 Compare July 10, 2026 22:21
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.

1 participant