Skip to content

refactor(husk): bring the tap up while the pod is dormant, not while a tenant waits#883

Merged
stubbi merged 14 commits into
mainfrom
perf/husk-prepare-egress-link
Jul 10, 2026
Merged

refactor(husk): bring the tap up while the pod is dormant, not while a tenant waits#883
stubbi merged 14 commits into
mainfrom
perf/husk-prepare-egress-link

Conversation

@stubbi

@stubbi stubbi commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Thinking Path

  • Mitos boots Firecracker microVMs and forks them via copy-on-write snapshots. A hosted create claims an already-booted, dormant husk pod, and the tenant waits for the whole activate.
  • Measured on prod (mitos-kvm-1), one matched claim: the controller sees activate_rpc at 129.7 ms, the husk stub reports 112.8 ms of its own, and inside that egress_filter is 29.6 ms.
  • egress_filter is two different things: bringing the tap up (enable forwarding, delete any leftover link, one ip -batch) and installing the netfilter policy (one atomic nft -f -).
  • Only the POLICY depends on the claim. The tap does not: its name is netconf.DeriveTapName(guestIP) and the in-pod guest IP is the fixed constant 10.200.0.2 (huskGuestIP). So the tap can be brought up while the pod is dormant and no tenant is attached.
  • This is also a prerequisite for the larger win: Firecracker requires the tap to exist at restore time, so moving the snapshot restore into Prepare (guest_ready 40.6 ms plus vmstate_restore 22.9 ms, plus the ~70 ms of demand fault-in the first run_code pays) cannot happen until the tap does.
  • This pull request splits applyEgressFilter into its two halves and lets a warm pod pay the link half while dormant, behind a default-off flag.
  • The benefit is roughly 20 ms off every warm claim, and the ground cleared for slice 2.

Linked Issues or Issue Description

No issue existed. Latency attribution, and the first slice of docs/superpowers/plans/2026-07-10-prepare-time-restore.md (added here).

Related: #871 (the gap between the published warm-claim activate and what the hardware does).

Time to Interactive on the hosted API is 307.7 ms P50 (bench/results/2026-07-10-tti-hosted.md), of which create is ~196 ms. The husk activate is the largest single component the engine controls.

What Changed

  • applyEgressFilter is split into ensureEgressLink (forwarding, link delete, ip -batch) and applyEgressPolicy (one atomic nft -f -). The composed call is unchanged: TestApplyEgressFilterIsTheTwoHalves records both paths and compares them command for command, argv and stdin.
  • Options.PrepareEgressLink plus InPodGuestIP / InPodGatewayIP on the stub; --prepare-egress-link, --in-pod-guest-ip, --in-pod-gateway-ip on cmd/husk-stub; --husk-prepare-egress-link on cmd/controller, threaded through HuskPodOptions. All DEFAULT OFF.
  • prepareInstance brings the default VM's tap up with a default-deny policy when opted in, and records it on activeTap (so Close tears it down even if the pod is never claimed) and on a new preparedLinkTap.
  • activateInstance installs only the tenant policy when preparedLinkTap matches the tap the claim resolves to, and takes the full path otherwise (a fork child, or a pod that did not opt in).
  • docs/superpowers/plans/2026-07-10-prepare-time-restore.md: the measured budget, what is actually claim-specific, the target, the guards, and the three slices.
  • docs/threat-model.md: the dormant-tap surface.

Fail-closed, which is most of the work

  • Prepare creates the tap and its default-deny policy together and REFUSES to go dormant if either half fails, so the pool never offers a pod with a half-built datapath and a tap never exists without a policy.
  • The claim REPLACES the policy in one nft transaction, so there is no window in which a VM is unfiltered.
  • A rejected claim policy tears the tap down (as it always did) AND clears the prepared marker, so the retry rebuilds the link instead of installing a policy on a tap that no longer exists, which would poison the warm pod permanently with EBUSY (husk activation: egress-setup step fails on stock k3s (tap teardown now deterministic via #429) #428).
  • A malformed allowlist is still rejected before ANY command runs. Splitting the call must not start creating taps for a policy that can never be installed.

Nothing is loaded behind a dormant tap today: the snapshot restore stays on the activate path. That is slice 2.

Verification

  • go test -race ./internal/husk/ -count=1
  • go test ./internal/controller/ ./cmd/husk-stub/ ./internal/firecracker/ -count=1
  • go build ./..., gofmt -l clean on the touched trees
  • golangci-lint run --timeout=8m clean for ./internal/husk/... ./cmd/husk-stub/... ./cmd/controller/..., and again with GOOS=linux

New tests: TestApplyEgressFilterIsTheTwoHalves, TestApplyEgressPolicyTouchesOnlyNft, TestApplyEgressPolicyFailsClosed, TestEnsureEgressLinkInstallsNoPolicy, TestApplyEgressFilterRejectsABadAllowlistBeforeTouchingTheLink, TestPrepareBringsTheTapUpDormantAndActivateOnlyInstallsThePolicy, TestPrepareTimeLinkIsOptIn, TestAFailedClaimPolicyRebuildsTheLinkOnRetry, TestBuildHuskPodThreadsPrepareEgressLink.

The controller test asserts the stub receives the fixed in-pod /30 alongside the flag: a mismatch would bring up a tap the claim never resolves to, and the skip would silently not apply.

Not yet canaried on prod. The flag is off, so merging changes nothing until an operator sets it. The rollout in the plan is: canary ONE dormant husk pod, check restartCount=0, claim it, compare the egress_filter stage, then recycle the pool.

Risks

The security surface moves, and the threat model is updated in this commit: a dormant husk pod may now hold a tap in its own netns before any tenant is attached. It carries a default-deny policy from the moment it exists, Prepare fails closed if it cannot, nothing is loaded behind it, and the pod netns is shared with no tenant.

The subtle failure mode is a stale prepared marker: if a claim tore the tap down but the marker survived, the retry would install a policy on a missing tap and the pod would be poisoned. TestAFailedClaimPolicyRebuildsTheLinkOnRetry exists for exactly that, and the marker is cleared before either filter call so no error path can skip it.

Off (the default, and every deployment today) the filter path is byte-for-byte the activate-time one, which the two-halves test pins.

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 (with version and capability details)
  • Tests added for behavior changes, in the same commit (TDD)
  • Docs updated in the same PR
  • Threat-model delta (docs/threat-model.md) included if the security surface moved
  • Benchmark run (bench/) included if the hot path was touched
  • 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 (only public #NNN / mitos-run/mitos URLs)
  • Every commit carries a Signed-off-by trailer (git commit -s)

Summary by CodeRabbit

  • New Features
    • Added an experimental prepare-time egress link flow for multi-VM dormant pods, using default-deny protection that swaps to tenant policy on activation.
    • Introduced new CLI flags for enabling prepare-time egress link and configuring in-pod guest and gateway IPs.
    • Improved warm-claim activation timing by overlapping token Secret setup with activation.
  • Bug Fixes
    • Fail-closed egress behavior now validates inputs early and reliably cleans up when policy application fails.
  • Documentation
    • Added prepare-time restore design docs and updated the egress enforcement threat model.
  • Tests
    • Added unit coverage for flag validation, husk stub argument wiring, and prepared egress-link behavior.

Jannes Stubbemann and others added 8 commits July 9, 2026 23:34
…solve off the warm-claim path

The engine's warm-claim activate is 60 ms P50, but nothing measured what surrounded
it. Adding claim-path stage timing (logClaimStage, the analog of the fork path's
logForkOrchStage) showed the Kubernetes round-trips cost about as much as the microVM
restore, and one of them was a 300 ms cold spike paid by whichever user created the
first sandbox after a controller start.

Three changes, in decreasing size:

  * The mTLS dial config is pre-warmed once the manager cache is up. The first claim
    after a controller start or a leader handover used to pay 301 ms in dial_tls (the
    lazy Secret informer cache fill), against ~1 ms for every claim after it. Best
    effort: a pre-warm failure is not fatal, the reconciler still resolves on demand.

  * The Restoring status write is now in memory only. It was a full API round-trip on
    the critical path that no observer could ever see: control only reaches that line
    once a dormant pod has been SELECTED, so the phase was Restoring for exactly the
    duration of the activate. A claim that cannot place returns Pending far above it.
    The quota live-counter counts Pending and Restoring alike, so concurrency
    accounting is unchanged, and every failure path writes its own terminal phase.

  * The per-sandbox token Secret is written CONCURRENTLY with the activate RPC. Both
    inputs (the minted token, the endpoint derived from the pod IP) are known before
    the RPC, so it never needed to be serial. The ordering contract holds: the write is
    joined before the Ready status write, so the Secret is durable before the claim is
    servable. A failed activate leaves a Secret owner-ref'd to the claim, garbage
    collected with it and overwritten by the next pass.

Measured on prod, mean of 9 sequential warm claims:

  stage                    before      after
  status_write_restoring    10.40       gone
  dial_tls                  34.42       1.04    (max 301.69 -> 2.37)
  token_secret (blocking)    7.42       0.00    (overlapped, span 115 ms)
  mark_pod_claimed          16.39      14.86    (the concurrency gate; unchanged)
  status_write_ready         9.44       8.39
  TOTAL                    190.34     139.45    (max 471.04 -> 174.53)

token_secret is reported as two stages on purpose. token_secret_wait is what the
critical path pays (0 ms, the activate outlasts the write); token_secret_span is the
overlapped wall time. Reporting only the span would read as a 150 ms regression when
the write in fact left the critical path entirely.

Verified: internal/controller envtest suite green, both golangci-lint invocations
clean, hosted create/run_code/fork/child-run_code and the #870 restart signal all
still pass against the built controller on prod.

Signed-off-by: Jannes Stubbemann <jannes@openclaw.rocks>
…et write its own copy

Two review findings, both real.

Claim stage timings were recorded as claim_* labels on
mitos_fork_stage_duration_seconds, whose help text documents a fixed fork-stage
vocabulary. Any aggregation over "all fork stages" would have quietly included
warm-claim work. They now go to mitos_claim_stage_duration_seconds, and a test
asserts a claim observation increments that histogram and leaves the fork one
untouched.

The concurrent token Secret write was handed the LIVE claim object. The
activate-failure path mutates claim.Status and returns without joining the
goroutine, so a still-running writer shared mutable state with the reconcile
path under no synchronization. It now gets claim.DeepCopy() and a precomputed
Secret name, so nothing it reads can be written underneath it. The owner
reference is unchanged: the copy carries the same name, namespace, and UID, so
the Secret is still garbage-collected with the claim.

Verified: go test -race ./internal/controller/ passes, and golangci-lint is
clean for both GOOS=darwin and GOOS=linux.

Signed-off-by: Jannes Stubbemann <jannes@openclaw.rocks>
…ducible peer table

TTI is create() to a command having actually run inside the sandbox and returned,
the definition the public ComputeSDK benchmark uses. Paced 20 iterations from a
same-region client against api.mitos.run: P50 495 -> 341 ms, min 273 ms, 20/20.
Both runs hit the same server build, so the whole difference is the client-side
connection reuse and template caching in the previous commit.

The peer numbers are computed, not copied. The vendor leaderboard renders client
side and the upstream README shows only chart placeholders, so bench/peer-tti.py
fetches the raw per-iteration JSON the harness commits and computes percentiles
itself, per the no-unverified-claims rule. Doing that caught an error in the
figure I had been carrying: Northflank is 95.9 ms P50, not 269 ms. 269 was
Upstash, at 258.3.

The result page states plainly what the table cannot support. Their probe is
node -v against our print(1) through a Python interpreter, their run is 100
un-paced iterations against our 13 s pacing under the free tier's rate limit, and
their client and region are not ours. The single clean statement is the ordering
against E2B, the one peer documenting the same isolation primitive: 340.7 ms vs
365.6 ms P50. The sub-40 ms entries are below the cost of restoring a microVM at
all on this hardware, so they are almost certainly not booting one per request.

ComputeSDK's snapshot-fork benchmark measures object storage (S3, R2, Azure Blob,
Tigris), not VM fork, so there is still no public peer number for the primitive
mitos is built on.

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

Review found a data-integrity bug in the concurrent Secret write this branch added,
and it is a real one.

The warm-claim path writes the per-sandbox token Secret concurrently with the activate
RPC. The activate-failure branch returned WITHOUT joining that write. The writer is
still holding THIS pass's token; the next pass mints a NEW token, activates the husk
with it, and rewrites the SAME Secret. Whichever write lands last wins. A stale writer
landing last leaves a Ready claim whose Secret holds a token the husk never received,
and every tenant call made with it is rejected. Nothing would have pointed at the
cause.

The write now runs under a cancellable context and EVERY path out of the function joins
it. The failure branch cancels first so the join is prompt, then drains, then persists
Pending. A cancelled write is not an error worth logging; any other failure is logged
without the token value, and the next pass re-mints and overwrites it anyway.

Proven by TestHuskClaimActivateFailureJoinsTheTokenSecretWrite, through a new
ensureTokenSecret seam: the fake writer blocks and deliberately ignores its context, so
the reconcile can only proceed by joining it. Without the join the test fails with
"claim reached phase Pending while the token Secret write was still in flight". It also
asserts the writer's context was cancelled, so a slow write can always be cut short.

Verified: go test -race ./internal/controller/ passes (three consecutive runs), and
golangci-lint is clean for both GOOS=darwin and GOOS=linux.

Signed-off-by: Jannes Stubbemann <jannes@openclaw.rocks>
# Conflicts:
#	bench/results/2026-07-10-tti-hosted.md
@stubbi stubbi changed the title perf(husk): bring the tap up while the pod is dormant, not while a tenant waits refactor(husk): bring the tap up while the pod is dormant, not while a tenant waits Jul 10, 2026
…a tenant waits

A warm claim pays the whole microVM restore while a tenant waits. Measured on prod, one
matched claim: the husk stub's own activate is 112.8 ms, of which egress_filter is 29.6.
That stage is two things: bringing the tap up (enable forwarding, delete any leftover
link, one ip -batch) and installing the netfilter policy (one atomic nft transaction).
Only the policy depends on the claim. The tap does not: its name derives from the guest
IP, and the in-pod guest IP is a fixed constant.

applyEgressFilter is split into ensureEgressLink and applyEgressPolicy. The composed call
still runs exactly the same commands in the same order, pinned by a test that records
both and compares them command for command, so a pod that does not opt in is byte-for-
byte what it was.

Behind --husk-prepare-egress-link (controller) and --prepare-egress-link (stub), both
DEFAULT OFF, a warm multi-VM pod brings its default VM's tap up while DORMANT under a
default-deny policy, and the claim installs the tenant's policy on it. The hot path
becomes exactly one command.

Fail-closed at every step, which is most of the work:

  - Prepare creates the tap and its default-deny policy together and refuses to go
    dormant if either half fails, so the pool never offers a pod with a half-built
    datapath and a tap never exists without a policy.
  - The claim REPLACES the policy in one nft transaction, so there is no window in which
    a VM is unfiltered.
  - A rejected claim policy tears the tap down (as it always did) AND clears the prepared
    marker, so the retry rebuilds the link instead of installing a policy on a tap that no
    longer exists, which would poison the warm pod permanently.
  - A malformed allowlist is still rejected before any command runs; splitting the call
    must not start creating taps for a policy that can never be installed.

Nothing is loaded behind a dormant tap today: the snapshot restore stays on the activate
path. That is slice 2, and it needs this because Firecracker requires the tap to exist at
restore time. The design, the measured budget, the guards, and the rollout are written
down in docs/superpowers/plans/2026-07-10-prepare-time-restore.md, and the threat model
records the new dormant-tap surface.

Verified: go test -race ./internal/husk/, go test ./internal/controller/ ./cmd/husk-stub/
./internal/firecracker/, and golangci-lint for both GOOS=darwin and GOOS=linux. Not yet
canaried on prod; the flag is off, so merging changes nothing until an operator sets it.

Signed-off-by: Jannes Stubbemann <jannes@openclaw.rocks>
@stubbi stubbi force-pushed the perf/husk-prepare-egress-link branch from e89cec8 to c4fc27d Compare July 10, 2026 10:37
@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

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: 5fab36c7-5263-4917-b8a6-8129b59f08c3

📥 Commits

Reviewing files that changed from the base of the PR and between 15235ef and c4e5c07.

📒 Files selected for processing (2)
  • cmd/controller/main.go
  • cmd/controller/main_test.go

📝 Walkthrough

Walkthrough

The PR adds an opt-in prepare-time dormant egress tap and default-deny policy for multi-VM husks, replaces that policy during activation, instruments warm-claim stages, overlaps token Secret creation with activation, and adds controller validation, tests, and design documentation.

Changes

Prepare-time egress and warm-claim flow

Layer / File(s) Summary
Configuration and runtime wiring
cmd/controller/main.go, cmd/husk-stub/main.go, internal/controller/..., internal/husk/stub.go
Prepare-egress flags and options flow through controller reconcilers and husk pod arguments into stub runtime state, with multi-VM and husk-pod validation.
Split egress link and policy application
internal/husk/netfilter.go, internal/husk/netfilter_test.go
Egress setup separates link creation from policy installation, validates inputs before link creation, and tears down state when policy application fails.
Dormant tap preparation and activation replacement
internal/husk/multivm.go, internal/husk/multivm_network_test.go, internal/husk/stub.go
The default VM can create a dormant tap with default-deny policy during preparation, then reuse the tap for tenant-policy replacement during activation.
Warm-claim activation coordination and metrics
internal/controller/sandboxclaim_controller.go
Token Secret writing runs concurrently with Activate, failure and success paths join the writer appropriately, and claim-stage timings are logged.
Rollout and threat-model documentation
docs/superpowers/plans/..., docs/threat-model.md
Documentation describes the prepare-time flow, guards, implementation slices, gating, and threat-model constraints.

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

Possibly related PRs

  • mitos-run/mitos#770 — Provides multi-VM husk-stub scaffolding extended here with prepared egress-link state.
  • mitos-run/mitos#772 — Established the multi-VM lifecycle extended here with dormant tap tracking and activation policy replacement.
  • mitos-run/mitos#847 — Overlaps with this PR’s egress filter command-sequencing refactor.

Sequence Diagram(s)

sequenceDiagram
  participant Controller
  participant SandboxPoolReconciler
  participant HuskStub
  participant Netfilter
  Controller->>SandboxPoolReconciler: enable PrepareEgressLink
  SandboxPoolReconciler->>HuskStub: pass prepare-egress-link arguments
  HuskStub->>Netfilter: create dormant tap and default-deny policy
  Controller->>HuskStub: send Activate request
  HuskStub->>Netfilter: atomically replace policy with tenant rules
  HuskStub-->>Controller: return activation result
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 72.97% 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 concise, conventional, and accurately summarizes the main husk egress refactor.
Description check ✅ Passed The description follows the template with Thinking Path, linked issue handling, changes, verification, risks, model, and checklist.
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-egress-link

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

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

🤖 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 `@bench/peer-tti.py`:
- Line 37: Replace all three percent-format calls in the benchmark script with
equivalent f-strings, including the call printing data["timestamp"] and
data["config"], to resolve Ruff UP031 diagnostics without changing the output.
- Around line 40-41: Update the `tti` comprehension in the iteration percentile
calculation to first require each iteration to be a dictionary, then accept only
numeric `ttiMs` values that are finite and non-negative; explicitly exclude
booleans, NaN, and infinities before computing percentiles.

In `@cmd/controller/main.go`:
- Line 240: Validate the resolved run-mode prerequisites after resolveRunMode:
reject configurations where PrepareEgressLink (including
--husk-prepare-egress-link) is enabled while MultiVMFork is disabled, or derive
a single effective value that is passed consistently to both the pool builder
and claim reconciler wiring at PrepareEgressLink and MultiVMFork.

In `@docs/threat-model.md`:
- Line 1222: Update the Husk egress enforcement row to distinguish the
activation-time path, which remains IMPLEMENTED and KVM-VERIFIED, from the
experimental prepare-time path. In the prepare-time section referencing
“--husk-prepare-egress-link” and “--prepare-egress-link,” explicitly state that
it is default-off and pending KVM/canary validation, removing any implication
that this path is already verified.

In `@internal/controller/huskpod_test.go`:
- Around line 1510-1538: Extend TestBuildHuskPodThreadsPrepareEgressLink with a
PrepareEgressLink: true and MultiVM: false case. Assert the generated Husk pod
omits --prepare-egress-link, --in-pod-guest-ip, and --in-pod-gateway-ip,
preserving the contract that preparation is only supported for MultiVM.

In `@internal/controller/huskpod.go`:
- Around line 203-207: Enforce the MultiVM prerequisite for PrepareEgressLink in
both pod argument construction and reconciliation. Update the relevant checks
around the PrepareEgressLink argument block and the reconciliation forwarding of
r.PrepareEgressLink so the option is enabled only when both PrepareEgressLink
and MultiVM are true, preventing unsupported pod shapes.
🪄 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: 206f6a09-867f-442f-b036-2c5f7edc74e5

📥 Commits

Reviewing files that changed from the base of the PR and between d3ef408 and c4fc27d.

📒 Files selected for processing (19)
  • bench/peer-tti.py
  • cmd/controller/main.go
  • cmd/husk-stub/main.go
  • docs/superpowers/plans/2026-07-10-prepare-time-restore.md
  • docs/threat-model.md
  • internal/controller/husk_activation_test.go
  • internal/controller/huskpod.go
  • internal/controller/huskpod_test.go
  • internal/controller/metrics.go
  • internal/controller/metrics_internal_test.go
  • internal/controller/sandboxclaim_controller.go
  • internal/controller/sandboxpool_controller.go
  • internal/controller/suite_test.go
  • internal/controller/testsupport_test.go
  • internal/husk/multivm.go
  • internal/husk/multivm_network_test.go
  • internal/husk/netfilter.go
  • internal/husk/netfilter_test.go
  • internal/husk/stub.go

Comment thread bench/peer-tti.py
Comment thread bench/peer-tti.py Outdated
Comment thread cmd/controller/main.go
Comment thread docs/threat-model.md Outdated
Comment thread internal/controller/huskpod_test.go
Comment thread internal/controller/huskpod.go
@stubbi stubbi enabled auto-merge (squash) July 10, 2026 10:49
stubbi and others added 3 commits July 10, 2026 15:18
…me egress link

Review found that PrepareEgressLink was documented as requiring MultiVM and then trusted
to the caller. With --husk-prepare-egress-link set and --multi-vm-fork unset, the pool
builder received MultiVM=false while the claim reconciler still received
PrepareEgressLink=true, and the stub got flags it can never act on: only the multi-VM
Prepare path reaches prepareInstance, so a single-VM stub never brings its tap up, and
its Activate does not consult preparedLinkTap either. The operator asked for a latency
optimization and would have got a pod shape that cannot deliver one, silently.

Enforced at all three layers, because each is a boundary someone can reach on its own:

  - cmd/controller refuses the combination at startup rather than running degraded;
  - the pod builder emits the flags only when MultiVM is also set;
  - the stub states the !multiVM guard instead of relying on the fact that only the
    multi-VM Prepare currently reaches it.

Also from review, the threat model: the husk egress row is marked IMPLEMENTED and
KVM-VERIFIED end to end, and the prepare-time paragraph sat inside it, so an experimental
default-off path inherited a verification it has not had. It now says plainly that the
KVM verification covered the activation-time filter, and that the prepare-time path needs
a production canary and its own KVM gate before it may be called verified.

TestBuildHuskPodOmitsPrepareEgressLinkWithoutMultiVM pins the pod-builder gate.

Verified: go test -race ./internal/husk/, go test ./internal/controller/ ./cmd/husk-stub/,
and golangci-lint clean for both GOOS=darwin and GOOS=linux.

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

stubbi commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

All addressed in 5e40a1de.

Validate the prepare-egress prerequisites (Major). Correct, and worse than inconsistent: it was silently useless. Only the multi-VM Prepare reaches prepareInstance, so a single-VM stub never brings its tap up, and its Activate does not consult preparedLinkTap either. An operator setting --husk-prepare-egress-link without --multi-vm-fork would have got a pod carrying flags it can never act on, and no latency benefit, with nothing to tell them.

Enforced at all three layers, because each is a boundary someone can reach independently:

  • cmd/controller refuses the combination at startup rather than running degraded;
  • buildHuskPod emits the flags only when MultiVM is also set;
  • the stub now states the !multiVM guard rather than relying on the fact that only the multi-VM Prepare currently reaches it.

Cover the unsupported MultiVM combination (Trivial). Added TestBuildHuskPodOmitsPrepareEgressLinkWithoutMultiVM, asserting the contract you suggested: the prepare arguments are omitted.

Do not label the experimental path KVM-verified (Minor). Well caught, and this is the kind of thing that quietly becomes false documentation. The row is titled "IMPLEMENTED and KVM-VERIFIED end to end" and my paragraph sat inside it, so a default-off experimental path inherited a verification it has not had. It now says plainly that the KVM verification covered the activation-time filter, and that the prepare-time link needs a production canary and its own KVM gate before it may be called verified. The plan document says the same.

bench/peer-tti.py (percent-format, and ttiMs validation). Both are real, and both are already fixed on main as of #877 and #879: samples must now be finite, non-negative, non-bool reals, errored iterations are excluded, and a provider whose iterations all failed is reported rather than dropped (an errored iteration carries ttiMs: 0.0, which was giving superserve a reported minimum of 0.0 ms). This branch predates that; it will pick the fixes up on the next rebase, and it touches none of those lines. On UP031 specifically: ruff is not wired into CI here (python-test runs pytest only), so I have left the percent-format alone rather than churn unrelated lines.

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

🤖 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 `@cmd/controller/main.go`:
- Around line 143-151: Reject --husk-prepare-egress-link when raw-forkd or mock
mode is enabled, since resolveRunMode disables husk pods and the option
otherwise has no effect. Extend the validation around the existing
prepareEgressLink and multiVMFork check in main, using the resolved run-mode
values (or equivalent raw-forkd/mock flags), and fail fast with a clear
logger.Error message before continuing.
🪄 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: afb9f305-ca5e-4be2-b5ea-ace0bdfd6d0b

📥 Commits

Reviewing files that changed from the base of the PR and between fce19fd and 5e40a1d.

📒 Files selected for processing (6)
  • cmd/controller/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/husk/multivm.go

Comment thread cmd/controller/main.go
…ess-link

# Conflicts:
#	bench/peer-tti.py
#	internal/controller/sandboxclaim_controller.go
#	internal/controller/testsupport_test.go

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/controller/sandboxclaim_controller.go (1)

993-1001: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

activate_rpc stage is recorded even when the TLS dial fails and Activate is skipped.

When huskDialTLS returns an error, activate is not invoked, yet logClaimStage(..., "activate_rpc", ...) still fires with a near-zero duration. During a husk outage (mass dial failures) this floods the activate_rpc histogram with ~0 samples, making activation latency look healthy exactly when it is broken. Only observe/log the stage when the RPC actually ran.

📊 Only record activate_rpc when the RPC executes
 	var res husk.ActivateResult
-	mark = time.Now()
 	if err == nil {
+		mark = time.Now()
 		res, err = activate(ctx, addr, tlsConf, req)
+		logClaimStage(logger, claim.Name, "activate_rpc", time.Since(mark))
 	}
-	logClaimStage(logger, claim.Name, "activate_rpc", time.Since(mark))
🤖 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 `@internal/controller/sandboxclaim_controller.go` around lines 993 - 1001, Only
call logClaimStage for the "activate_rpc" stage when activate actually executes
successfully past the TLS error check. Update the flow around huskDialTLS and
activate so the timing mark and activate_rpc observation are conditional on err
== nil, while preserving the existing error propagation when TLS dialing fails.
🤖 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.

Outside diff comments:
In `@internal/controller/sandboxclaim_controller.go`:
- Around line 993-1001: Only call logClaimStage for the "activate_rpc" stage
when activate actually executes successfully past the TLS error check. Update
the flow around huskDialTLS and activate so the timing mark and activate_rpc
observation are conditional on err == nil, while preserving the existing error
propagation when TLS dialing fails.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: d6eab41a-cfc6-49fd-a81d-a63be86c60cf

📥 Commits

Reviewing files that changed from the base of the PR and between 5e40a1d and 15235ef.

📒 Files selected for processing (1)
  • internal/controller/sandboxclaim_controller.go

Review: the startup guard checked --multi-vm-fork but not the resolved run path, so
--husk-prepare-egress-link --multi-vm-fork --enable-raw-forkd (or --mock) was accepted.
On the raw-forkd path resolveRunMode sets enableHuskPods false, so no husk pod is ever
created and the requested optimization is silently inert.

The check now runs against the RESOLVED enableHuskPods and requires it, extracted into
validatePrepareFlags so it is unit-tested directly (five cases, including the raw-forkd
combinations) rather than only reachable through os.Exit.

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

stubbi commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

Fixed in c4e5c07a. The guard checked --multi-vm-fork but not the resolved run path, so --husk-prepare-egress-link --multi-vm-fork --enable-raw-forkd (or --mock) was accepted while enableHuskPods was false, leaving the feature silently inert.

It now runs against the RESOLVED enableHuskPods and requires it:

func validatePrepareFlags(prepareEgressLink, multiVMFork, enableHuskPods bool) error {
	if prepareEgressLink && (!multiVMFork || !enableHuskPods) {
		return fmt.Errorf("--husk-prepare-egress-link requires --multi-vm-fork AND the husk-pods run path ...")
	}
	return nil
}

Extracted into validatePrepareFlags so it is unit-tested directly (TestValidatePrepareFlags, five cases including the raw-forkd and single-VM combinations) rather than only reachable through os.Exit. The stacked slice-2 PR (#892) extends the same function for --husk-prepare-restore.

@stubbi stubbi merged commit ad9e518 into main Jul 10, 2026
35 checks passed
@stubbi stubbi deleted the perf/husk-prepare-egress-link branch July 10, 2026 21:28
stubbi pushed a commit that referenced this pull request Jul 10, 2026
… 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 added a commit that referenced this pull request Jul 10, 2026
… a tenant waits (#892)

* refactor(husk): restore and resume the guest while dormant, not while 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>

* fix(husk): refuse a mismatched pre-restored snapshot before any side 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>

---------

Signed-off-by: Jannes Stubbemann <jannes@openclaw.rocks>
Co-authored-by: Jannes Stubbemann <jannes@openclaw.rocks>
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