Skip to content

refactor(husk): restore and resume the guest while dormant, not while a tenant waits#892

Merged
stubbi merged 2 commits into
mainfrom
perf/husk-prepare-restore
Jul 10, 2026
Merged

refactor(husk): restore and resume the guest while dormant, not while a tenant waits#892
stubbi merged 2 commits into
mainfrom
perf/husk-prepare-restore

Conversation

@stubbi

@stubbi stubbi commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Stacked on #883 (slice 1, tap-at-prepare). Base is perf/husk-prepare-egress-link; I will retarget to main once #883 merges. Review #883 first.

Thinking Path

  • Mitos claims a warm, dormant husk pod on create, and the tenant waits for the whole activate.
  • Measured on prod (mitos-kvm-1), one matched claim: husk activate 112.8 ms, of which the restore (vmstate_restore 22.9), the resume (2.4), and the guest-ready wait (40.6, largely demand fault-in under the lazy UFFD restore) are ~66 ms.
  • All of that is a function of the pod and its pool template, not of the claim. Only the fork-correctness handshake (fresh entropy, the clock step, the per-VM network re-addressing, the tenant secrets) and serving the sandbox API with the claim token actually need the claim.
  • Slice 1 (refactor(husk): bring the tap up while the pod is dormant, not while a tenant waits #883) already moved the tap up to Prepare, which is the prerequisite: Firecracker binds the baked NIC to the tap at snapshot load, so the tap must exist first.
  • This pull request moves the snapshot restore and the guest resume to Prepare too, so a claim pays only the handshake.
  • The benefit is the single largest lever on time-to-interactive: it removes ~66 ms from every warm claim AND makes the first run_code warm (the ipykernel pages are faulted in during dormancy, not on the tenants first call).

Linked Issues or Issue Description

Slice 2 of #889 (prepare-time restore). Closes most of the gap tracked in #871.

What Changed

  • Options.PrepareRestore plus --prepare-restore (stub) and --husk-prepare-restore (controller), threaded through HuskPodOptions. All DEFAULT OFF.
  • prepareRestoreDefaultVM runs setLiveCowMemSource / LoadSnapshotWithOverrides / PatchDrive / Resume / guest-ready for the DEFAULT VM after the tap is up, then CLOSES the ready conn (not held across dormancy, where it could go stale).
  • activateInstance takes a fast path when inst.preRestored: skip restore/rebind/resume/guest-ready, re-dial the already-running guest (sub-ms), pay only the handshake.
  • The handshake-and-serve tail is extracted into activateHandshakeAndServe, shared by both paths byte-for-byte.
  • docs/threat-model.md records the dormant-resumed-guest surface; the plan marks slice 2 done.

Fail-closed, the load-bearing part

  • Prepare refuses to go dormant if any restore step fails, so the pool never offers a half-restored pod.
  • Activate refuses a claim whose snapshot dir differs from the pre-restored one, with a named error: a resumed guest cannot be reloaded and must never be served against the wrong image.
  • Only the DEFAULT VM is pre-restored. Co-located fork children (own snapshot, childUFFD backend, per-fork tap) and the pre-warm generic child are excluded, so the fork paths are unchanged.

Verification

  • go test -race ./internal/husk/ -count=1
  • go test ./internal/controller/ ./cmd/husk-stub/ -count=1
  • go build ./..., gofmt -l clean, golangci-lint run clean for GOOS=darwin and GOOS=linux

New tests, each failing on the unfixed code (state machine is engine-independent, so the mock VMM exercises it): TestPrepareRestoresAndResumesTheGuestWhileDormant (load+resume happen at Prepare, neither at Activate), TestPrepareRestoreIsOptIn (default off is byte-for-byte activate-time), TestActivateFailsClosedOnASnapshotMismatchAfterPrepareRestore, TestBuildHuskPodThreadsPrepareRestore.

NOT KVM-verified and NOT canaried. The state machine is proven with the mock; the real restore/resume needs a firecracker-test gate and a one-pod prod canary before it may be called verified. That work is tracked in #889.

Risks

internal/husk is a security-sensitive path per CLAUDE.md and needs a named human reviewer. This is the highest-risk change in the latency effort: it resumes a live guest during dormancy on the tenant-VM boot path.

The dormant guest is a new surface, recorded in the threat model. It serves no tenant during dormancy (the sandbox API is bound and the token delivered only at the claim), it is reseeded fail-closed at the claims NotifyForked exactly as a restore-at-activate guest is (the dormancy window only enlarges the absolute clock step), and it holds its working set resident (~72 MiB/sandbox) versus ~0 for a never-loaded VMM.

Off (the default, and every deployment) the restore path is byte-for-byte the activate-time one, which TestPrepareRestoreIsOptIn pins. The startup guard rejects --husk-prepare-restore without --husk-prepare-egress-link (and therefore without --multi-vm-fork).

Not deployed by merging: this is a flag-gated engine change that will not touch prod until an operator sets the flag, after the KVM gate and canary in #889.

Model Used

  • Claude Opus 4.8 (claude-opus-4-8), 1M context, extended thinking.

Checklist

  • PR title is a conventional commit (feat, fix, docs, ci, chore, refactor, test)
  • Thinking Path traces from project context to this change
  • Model Used is filled in
  • Tests added for behavior changes, in the same commit (TDD)
  • Docs updated in the same PR
  • Threat-model delta included (the security surface moved)
  • Benchmark run: measured budget in the plan doc; the KVM measurement is prepare-time restore: move the microVM snapshot restore off the warm-claim hot path (slices 2-3) #889
  • No em or en dashes introduced anywhere
  • Secret values never logged, in errors, in condition messages, or on host paths
  • No internal/instance-local references
  • Every commit carries a Signed-off-by trailer

Summary by CodeRabbit

  • New Features

    • Added an opt-in prepare-time restore mode for warm husk pods (including a new controller and husk-stub CLI flag).
    • Default VM snapshots can be loaded and resumed while pods remain dormant, with an activation fast path to avoid repeating work.
    • Strengthened validation so restore requires the prepare-time egress link, and activation fails closed on snapshot directory mismatches.
  • Documentation

    • Updated rollout plan and threat model to reflect the prepare-time restore behavior and safeguards.
  • Tests

    • Expanded unit/integration coverage for opt-in behavior, fast-path activation, and wrong-snapshot rejection.

@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 166175af-307b-4e6f-9c41-c03bbac1541f

📥 Commits

Reviewing files that changed from the base of the PR and between b9cf1fa and 068eeca.

📒 Files selected for processing (2)
  • internal/husk/multivm.go
  • internal/husk/multivm_network_test.go

📝 Walkthrough

Walkthrough

The PR adds an opt-in prepare-time restore mode for the default multi-VM husk VM, propagates its configuration through controllers and CLIs, introduces an activation fast path with snapshot validation, and adds lifecycle tests and documentation updates.

Changes

Prepare-time restore

Layer / File(s) Summary
Prepare-restore configuration and propagation
cmd/controller/main.go, cmd/controller/main_test.go, cmd/husk-stub/main.go, internal/controller/...
The new flags and configuration fields are validated and propagated to husk pods, which emit --prepare-restore only with the required multi-VM and egress settings.
Stub option and instance state
internal/husk/stub.go
Husk options and per-VM state now record whether the default VM was restored during preparation and which snapshot directory was used.
Prepare restore and activation fast path
internal/husk/multivm.go
Preparation can load, resume, and await the default VM; activation validates snapshot identity, reconnects to the guest, and uses the shared handshake and serving path.
Lifecycle validation and behavior documentation
internal/husk/multivm_network_test.go, docs/superpowers/..., docs/threat-model.md
Tests cover opt-in behavior, activation reuse, and snapshot mismatch rejection; documentation describes the implemented flow and fail-closed conditions.

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

Possibly related issues

Possibly related PRs

  • mitos-run/mitos#795 — Modifies the same multi-VM prepare/activate concurrency and instance lifecycle flow.
  • mitos-run/mitos#799 — Shares the multi-VM tap, egress, and snapshot-restore flow changed here.
  • mitos-run/mitos#805 — Directly overlaps with husk-pod option and command-argument construction.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is a clear conventional commit that matches the main change: moving guest restore/resume to dormant prepare time.
Description check ✅ Passed The description includes all required sections from the template and provides substantive details, verification, risks, and model info.
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.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/husk-prepare-restore

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

Base automatically changed from perf/husk-prepare-egress-link to main July 10, 2026 21:28
… a tenant waits

Slice 2 of docs/superpowers/plans/2026-07-10-prepare-time-restore.md, building on the
tap-at-prepare slice (#883). Together they move almost the whole warm-claim activate off
the hot path.

Measured on prod, one matched claim: the husk activate is 112.8 ms, of which the restore
(vmstate_restore 22.9), the resume (2.4), and the guest-ready wait (40.6, largely demand
fault-in under the lazy UFFD restore) are ~66 ms. All of it is a function of the pod and
its template, not of the claim. Only the fork-correctness handshake (fresh entropy, the
clock step, the per-VM network re-addressing, and the tenant secrets) and serving the
sandbox API with the claim token actually need the claim.

Behind --husk-prepare-restore (which requires --husk-prepare-egress-link, because
Firecracker binds the baked NIC to the tap at snapshot load), a warm pod now loads its
default VM's snapshot and RESUMES its guest while dormant. Activate takes a fast path
when the instance is pre-restored: it skips the restore, the rootfs rebind, the resume,
and the guest-ready wait, re-dials the already-running guest (sub-millisecond, the vsock
listener has been up since the prepare-time resume), and pays only the handshake. The
handshake-and-serve tail is extracted into activateHandshakeAndServe so both paths share
it byte-for-byte.

Fail-closed, which is the load-bearing part:

  - Prepare refuses to go dormant if the restore, rootfs rebind, resume, or guest-ready
    fails, so the pool never offers a half-restored pod.
  - Activate refuses a claim whose snapshot dir differs from the one pre-restored, with a
    named error, because a resumed guest cannot be reloaded and must never be served
    against the wrong image.
  - Only the DEFAULT VM is pre-restored. A co-located fork child (its own snapshot,
    childUFFD backend, per-fork tap) and the pre-warm generic child are excluded, so the
    fork paths are unchanged.
  - The ready conn proven at prepare is CLOSED, not held across dormancy where it could
    go stale.

The dormant guest is a new surface and the threat model records it: it serves no tenant
(the sandbox API is bound and the token delivered only at the claim), it is reseeded
fail-closed at the claim's NotifyForked exactly as a restore-at-activate guest is (the
dormancy window only enlarges the absolute clock step), and it holds its working set
resident (~72 MiB/sandbox) versus ~0 for a never-loaded VMM.

DEFAULT OFF at every layer: cmd/controller rejects --husk-prepare-restore without
--husk-prepare-egress-link (and therefore without --multi-vm-fork), the pod builder emits
--prepare-restore only inside the prepared-link block, and the stub no-ops unless the tap
was prepared. Off, the restore path is byte-for-byte the activate-time one.

NOT KVM-verified and NOT canaried on prod: this needs a firecracker-test gate and a
one-pod canary before it may be called verified, and it is the highest-risk change in the
latency effort (it resumes a live guest during dormancy on the tenant-VM boot path).

Verified with the mock VMM (the state machine is engine-independent):
TestPrepareRestoresAndResumesTheGuestWhileDormant (load+resume at prepare, none at
activate), TestPrepareRestoreIsOptIn (default off is byte-for-byte activate-time),
TestActivateFailsClosedOnASnapshotMismatchAfterPrepareRestore,
TestBuildHuskPodThreadsPrepareRestore. Each fails on the unfixed code. go test -race
./internal/husk/, go test ./internal/controller/ ./cmd/husk-stub/, and golangci-lint
clean for both GOOS=darwin and GOOS=linux.

internal/husk is a security-sensitive path per CLAUDE.md and needs a named human reviewer.

Signed-off-by: Jannes Stubbemann <jannes@openclaw.rocks>
@stubbi stubbi force-pushed the perf/husk-prepare-restore branch from 5617ff9 to b9cf1fa Compare July 10, 2026 21:34
@stubbi

stubbi commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto main now that #883 has merged (base retargeted to main). The prepare-restore guard is folded into the validatePrepareFlags function #883 introduced, so --husk-prepare-restore requires --husk-prepare-egress-link (and transitively --multi-vm-fork and the husk-pods run path). TestValidatePrepareFlags now covers the restore combinations too. Full suite and both lint invocations green.

@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: 3

🤖 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 `@internal/husk/multivm_network_test.go`:
- Around line 448-467: Extend
TestActivateFailsClosedOnASnapshotMismatchAfterPrepareRestore to assert that the
recording runner rr has no captured calls after Activate rejects the mismatched
snapshot. Check rr’s call collection using its existing field or accessor,
ensuring the assertion specifically verifies that no egress-filter mutation
occurred before the identity mismatch was detected.

In `@internal/husk/multivm.go`:
- Around line 660-688: Move the pre-restored snapshot identity check from the
inst.preRestored fast-path block to immediately after the empty SnapshotDir
validation, before the verify-on-activate gate and per-VM egress policy setup.
Reuse the existing mismatch error and return ActivateResult with OK false;
remove the later duplicate check so mismatched pre-restored claims cannot
trigger verification or network mutation.
- Around line 408-421: Update the prepareRestoreDefaultVM docstring to replace
the nonexistent opts.preWarmBoot precondition with the actual generic-child
exclusion condition, opts.skipRootfsClone || opts.reuseVM != nil, matching the
guard and nearby inline comment.
🪄 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: ASSERTIVE

Plan: Pro Plus

Run ID: f09ef878-237a-4f0a-a13a-8756e7892d1c

📥 Commits

Reviewing files that changed from the base of the PR and between ad9e518 and b9cf1fa.

📒 Files selected for processing (12)
  • cmd/controller/main.go
  • cmd/controller/main_test.go
  • cmd/husk-stub/main.go
  • docs/superpowers/plans/2026-07-10-prepare-time-restore.md
  • docs/threat-model.md
  • internal/controller/huskpod.go
  • internal/controller/huskpod_test.go
  • internal/controller/sandboxclaim_controller.go
  • internal/controller/sandboxpool_controller.go
  • internal/husk/multivm.go
  • internal/husk/multivm_network_test.go
  • internal/husk/stub.go

Comment thread internal/husk/multivm_network_test.go
Comment thread internal/husk/multivm.go
Comment thread internal/husk/multivm.go
…effect

Review found that the snapshot-identity gate fired too late. It sat at the pre-restored
fast path, AFTER the verify-on-activate re-hash and AFTER the per-VM egress filter, so a
claim naming a different snapshot than the one this pod pre-restored had already had the
tenant's real egress policy installed on the tap fronting the wrong guest (and a
re-verify run against the mismatched dir) before the "refusing to serve the wrong image"
rejection fired. The VM never went active and never got secrets or a reseed, but the
network mutation happened before the gate, which is the opposite of fail-closed.

The identity check now runs FIRST, right after the empty-snapshot-dir guard, before any
verify or egress mutation. The fast path keeps only a comment noting the invariant now
holds. TestActivateFailsClosedOnASnapshotMismatchAfterPrepareRestore now records the net
command count and asserts a mismatch adds NONE; with the gate moved back to its old
position the test fails with "mutated the egress datapath (1 new net commands)".

Also from review: two docstrings named a nonexistent opts.preWarmBoot; the actual guard
is opts.skipRootfsClone || opts.reuseVM != nil, and the docstrings now say so.

Signed-off-by: Jannes Stubbemann <jannes@openclaw.rocks>
@stubbi

stubbi commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

All three addressed in 068eeca9.

Snapshot-mismatch fires too late (Major). Correct, and a genuine fail-closed violation. The identity check sat at the pre-restored fast path, AFTER the verify-on-activate re-hash and AFTER the per-VM egress filter, so a wrong-image claim had already installed the tenant policy on the tap fronting the (wrong) guest before the rejection fired. The check now runs FIRST, right after the empty-snapshot-dir guard, before any verify or egress mutation:

if inst.preRestored && req.SnapshotDir != inst.preRestoredSnapshotDir {
    // ... refuse before any side effect ...
}

Assert no egress mutation on mismatch (Trivial). Done, and it is exactly what pins the fix. TestActivateFailsClosedOnASnapshotMismatchAfterPrepareRestore now records the net-command count and asserts a mismatch adds NONE. Verified it bites: with the gate moved back to its old late position the test fails with a wrong-image claim mutated the egress datapath (1 new net commands): [nft -f - ...].

Stale opts.preWarmBoot docstring (Minor). Fixed. The guard is opts.skipRootfsClone || opts.reuseVM != nil (matching the inline comment), and both docstrings now say so.

go test -race ./internal/husk/ and both golangci-lint invocations are clean.

@stubbi stubbi merged commit 9bc2172 into main Jul 10, 2026
35 checks passed
@stubbi stubbi deleted the perf/husk-prepare-restore branch July 10, 2026 22:22
@stubbi

stubbi commented Jul 12, 2026

Copy link
Copy Markdown
Contributor Author

Prod observation after rolling v1.40.0 (which carries this PR, flags OFF): the warm-claim activate_rpc stage regressed from 98.2 ms P50 (v1.39.1, n=33 Ready claims, 24h window ending 2026-07-10T19:40Z) to 118.8 ms P50 (v1.40.0, n=100 sequential claims, 2026-07-12 ~17:00Z window), max 137. Every claim succeeded; the shift is uniform, not a tail. mark_pod_claimed and status_write_ready moved little (16.5 / 9.5 ms P50), so the +20 ms sits inside the husk activate round trip specifically.

Both samples are prod, same node, same pool, steady state (the v1.40.0 sample starts 2h after the roll). Client-observed create median stayed flat (189.3 to 190.9 ms) because the gateway round-trip cuts (#895) landed in the same release and offset part of it; first exec also drifted up (136.8 to 163.1 ms median), which could be the same underlying cause or environment drift.

Not asserting a root cause: the flag-off path is meant to be inert here, but the numbers moved with the roll. Raw stage medians and the method are reproducible from the controller "claim stage timing" logs for those windows if useful.

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