Skip to content

fix(agent): fix AF_PACKET wake hook so scale-to-zero actually wakes - #35

Merged
syscod3 merged 18 commits into
mainfrom
spike/datapath-e2e-validate
Jul 29, 2026
Merged

fix(agent): fix AF_PACKET wake hook so scale-to-zero actually wakes#35
syscod3 merged 18 commits into
mainfrom
spike/datapath-e2e-validate

Conversation

@syscod3

@syscod3 syscod3 commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Summary

ScaleToZero's wake-on-traffic path has never actually worked: a VM suspended for going idle could not be woken by an inbound packet to its overlay IP. Nothing errored — the wake hook simply never saw the traffic, silently, so a suspended VM just stayed suspended forever regardless of demand. This PR fixes the real bug and, for the first time, proves the whole suspend→wake→resume cycle end-to-end against a real KVM-backed Firecracker agent in CI — not stubs.

Root causes

Wake hook bound to the wrong packet-type registration. afpacketSource.Run opened its raw capture socket with a specific EtherType (ETH_P_IP), which the kernel only delivers to for locally-destined traffic (ptype_base, invoked only if a receiving device's rx_handler returns RX_HANDLER_PASS). A Linux bridge's rx_handler returns RX_HANDLER_CONSUMED for every frame it forwards to another port — which is every ordinary VM-to-VM frame on imp's overlay bridge. So the hook could never see the exact traffic it exists to detect, regardless of whether the target VM's TAP existed. Fixed by capturing unconditionally with ETH_P_ALL (the same registration tcpdump uses) and filtering to IPv4 in userspace instead.

FDB sync silently blocked from ever adding real VTEP entries. When the VXLAN interface joins the bridge, the kernel auto-creates a "self" FDB entry for the port's own MAC with no tunnel destination. SyncFDB's stale-entry cleanup wasn't skipping it, tried to delete it every reconcile, and errored (EAFNOSUPPORT from a zero-length netlink attribute) before the delete loop could finish — meaning the add loop for real VTEP entries may never have run. Fixed by skipping entries with no resolvable destination IP, alongside the existing all-zeros-MAC skip.

Both root causes were isolated by adding temporary, low-noise diagnostic logging to the wake path (kept on this branch) and running the fix candidates against real hardware-backed CI rather than guessing from code inspection alone.

Validated end-to-end for the first time

New CI job "E2E Datapath (Kind, real agent)" (workflow_dispatch-only — real KVM boots are slower and noisier than the existing Kind smoke suite) drives a full real Firecracker agent through: node boot → guest kernel boot → idle-detector auto-suspend → wake-on-traffic → resume, and asserts the phase transitions end-to-end. Confirmed via log evidence, not just a green checkmark: wake registry: frame matched, wake reconcile enqueued fires for the suspended VM's exact overlay IP, and VM suspendedVM resumed completes in ~3 seconds.

Also fixed along the way

Getting a real agent running in CI at all surfaced several independent bugs nobody had hit before, because nothing had ever run a real Firecracker agent in this pipeline:

Area Bug
capability.Check Verified /dev/kvm presence via os.Stat, not whether it's actually openable
Agent DaemonSet Guest kernel was never hostPath-mounted into the container — FC_KERNEL pointed at a path that only existed on the host node
Agent memory limit Default 128Mi limit OOM-kills the container almost immediately once a real Firecracker guest's RAM (counted against the agent's own cgroup) is added
Guest exec (internal/guest/server.go) Commands resolve via the process's own PATH, which is never set in the guest's PID1 init environment — any bare command name silently fails
e2e node scheduling Real-agent e2e never labeled the Kind node imp/enabled=true or removed the control-plane taint, so the scheduler could never place a VM

Test plan

  • New E2E Datapath (Kind, real agent) CI job, run repeatedly against real KVM until green (workflow_dispatch on this branch)
  • Confirmed the wake match fires for the correct overlay IP and the suspend→wake→resume cycle completes (~3s)
  • Confirmed the SyncFDB EAFNOSUPPORT error, present on every prior run, is now absent
  • go build/go vet for linux (the affected files are Linux-only) and existing unit tests unchanged and passing
  • Existing E2E Smoke (Kind) suite still green (control-plane-only, agent disabled — unaffected)

Compound Engineering
Claude Code

syscod3 added 18 commits July 28, 2026 22:35
capability.Check now opens /dev/kvm (O_RDWR) instead of stat, catching
permission-blocked-but-existing devices (#34).

e2e_suite_test.go conditionally drops the no-agent nodeSelector and sets
IMP_SCALE_TO_ZERO=true when IMP_E2E_REAL_AGENT=true, enabling a real
Firecracker agent in the suite.

scaletozero_datapath_test.go un-pends the wake-on-traffic datapath spec:
second always-on VM execs a ping into the suspended target's overlay IP
via the agent's vsock guest-exec API, asserting Suspended -> Resuming ->
Running.

New CI job "E2E Datapath (Kind, real agent)" (workflow_dispatch only):
Kind cluster with /dev/kvm passed through to the node, Firecracker binary
+ public quickstart kernel staged via docker cp (no imp-specific guest
kernel exists yet), runs the datapath-labeled spec.

UNVALIDATED end to end until this CI job actually runs green.
Helm --set types agent.extraEnv[0].value=true as bool via server-side
apply; DaemonSet env values must be strings. Found by the first real
CI run of the datapath e2e job (helm install imp failed:
"expected string, got &value.valueUnstructured{Value:true}").
First real run got the pinger ImpVM stuck at phase=Pending for the full
5m timeout with no visibility into why (operator/agent logs, events).
Cluster is deleted immediately after the job, so add a failure-only dump
step before teardown.
impvm_scheduler.go's schedule() filters candidate nodes by node label
imp/enabled=true before anything else runs. The datapath test never set
it (nor removed the Kind control-plane taint), so both VMs sat
Unschedulable ("No eligible node with available capacity") for the
entire 5m Eventually window. The existing "Scheduling filter" smoke
test already does this same node prep; datapath test now mirrors it.
Both pinger and target VMs reached Starting then their Firecracker
process exited ("VM process exited and was marked Failed") on the first
real-agent run, but the CI-level diagnostics step ran after go test
already returned, by which point Ginkgo's own AfterSuite had already
helm-uninstalled the chart and killed the agent/operator pods — no logs
captured. Dump agent+operator logs from inside AfterSuite instead, before
the uninstall, whenever IMP_E2E_REAL_AGENT=true.
Ginkgo's default reporter only prints a suite node's GinkgoWriter buffer
when that node fails. AfterSuite succeeded, so the agent/operator log
dump from the previous commit never showed up in CI output even though
it ran. fmt.Println bypasses that suppression.
Agent logs (now visible after the previous fix) show the actual root
cause of every prior failure: ghcr.io/syscode-labs/test:latest is a
placeholder used across e2e fixtures that was never actually pulled
before, because every prior e2e run disabled the agent. With a real
agent it fails outright: "DENIED: requested access to the resource is
denied". Switch to public docker.io/library/busybox:latest — imp
synthesizes /sbin/init from the image's CMD/ENTRYPOINT itself
(internal/agent/rootfs/init.go), so any real image with a CMD works;
busybox's default CMD (sh) blocks on stdin, keeping PID1 alive, and it
ships a ping applet the pinger VM needs anyway.
Agent logs from the real-agent e2e run show the actual blocker: "start
machine: failed to stat kernel image path, \"/var/lib/imp/vmlinux\":
stat /var/lib/imp/vmlinux: no such file or directory". FC_KERNEL was
always set as an env var, but unlike FC_BIN (firecracker-bin hostPath
mount, fixed for #33) there was never a matching hostPath volume for
the kernel — the path only ever existed on the node, never inside the
agent container. Every real deployment would hit this; it just never
surfaced because the agent has never run for real until this e2e run.
Mirrors the firecracker-bin mount exactly. Chart unit tests pass
(`helm unittest charts/imp`, 51/51); added a case for the new mount.
busybox's default CMD is a bare "sh" — with no interactive console
attached it hits EOF on stdin almost immediately and exits, so the
guest VM finished (ephemeral lifecycle default) within seconds of
boot: agent logs show finishFailed/finishSucceeded firing right after
Running, clearing the driver's process-map entry, so the later idle
suspend's Snapshot() found nothing ("VM ... is not running on this
node") and retried in a loop until timeout. nginx:alpine's default CMD
(nginx -g "daemon off;") blocks forever without needing stdin, and its
Alpine base still ships busybox ping for the guest-exec step.
Latest run: idle detection correctly transitioned target to Suspending,
but Snapshot() immediately failed with "VM ... is not running on this
node" and never recovered. Root cause: the imp-agent pod's container
actually restarted right at that moment (kubectl events show a second
Created/Started for the same pod, timestamped exactly when the errors
begin), wiping the driver's in-memory procs map — a real reliability
gap, not a test fixture issue. kubectl logs (current container) can't
show why, since the crash was in the container that got replaced. Add
pod status (RESTARTS column) + --previous logs to the AfterSuite dump
so the next run captures the actual crash/exit reason.
Found the actual restart cause: previous-container agent logs end
abruptly mid-stream with no panic/fatal/shutdown line — the signature
of an external SIGKILL, not a Go crash. Firecracker runs as a child
process of the agent, so guest RAM counts against the agent
container's own cgroup. Chart default agent.resources.limits.memory
is 128Mi, assuming no hosted VMs; two 256MiB-class VMs alone blow
past that, so kubelet OOM-kills the container almost immediately
after a real microVM boots, wiping the driver's in-memory state.
Override to 1Gi for the real-agent e2e path. (The 128Mi chart default
is itself a real footgun for any actual Firecracker deployment — worth
its own follow-up, not fixed here to keep this change scoped.)
Suspend/resume itself now works end to end (OOM fix held) — the run
got past suspend and failed on the next step: vmNodeName() returned
empty. status.NodeName exists on the ImpVMStatus type with a doc
comment claiming it's "the node where the VM is running", but nothing
in the codebase actually writes it — only spec.nodeName is set, by the
scheduler (impvm_controller.go). My own test bug, not a product bug.
Diagnosed the actual open research question: the wake ping never
resolves because the target's TAP is torn down on suspend
(handleSuspending frees memory; only the VTEP is kept, for cross-node
routing). The AF_PACKET wake hook is also bound to ETH_P_IP only, not
ARP — so even if it were listening for ARP it wouldn't see it. A
same-node sender with no prior ARP entry can never emit the IP frame
in the first place; there's nothing to catch. This matches the real
intended use case: a client with an already-warm ARP entry (from
earlier traffic) sends the IP packet directly on the next attempt, no
fresh ARP round-trip needed. Ping the target once while still Running
to warm the pinger's neighbor cache, matching that scenario, before
letting it idle-suspend.

Also refactors the raw net/http exec call into execInVM(), decoding
the NDJSON response to an exit code + combined output, so both the
warm-up ping (asserted) and the wake ping (logged for diagnostics) can
inspect actual ping results, not just the HTTP transport status.
Failed instantly (11ms) after both VMs hit Running: status.phase
reflects the host starting Firecracker, not that the guest kernel has
finished booting and brought its network up. The single-shot warm-up
ping fired before the guest was ready. Wrap in Eventually (up to 1m,
3s interval) instead — the original code never hit this race because
it always waited through the full 15s+ idle-suspend window first, by
which point the guest was long since up.
Warm-up ping failed consistently for the full 60s retry window with
exit=1 and empty stdout/stderr — not a timeout pattern. Root cause:
internal/guest/server.go's Exec RPC calls exec.CommandContext(command[0], ...)
directly, which Go resolves via the guest-agent process's own PATH env
var. The guest-agent is launched by /.imp/init as "/.imp/guest-agent &"
(absolute path, no PATH lookup needed) from a PID1 shell that never
had PATH set (WithEnv only writes /.imp/env when explicit env vars are
configured, which this test doesn't set) — so "ping" by bare name can
never resolve, hitting the *exec.Error branch that sets exitCode=1
with no output before the process ever starts. Use /bin/ping (present
via alpine's busybox) instead of relying on PATH.
The scaletozero e2e run got all the way to the wake ping (suspend
worked, target reached Suspended cleanly) but the target never
transitioned to Resuming within the test's 2m window — and this code
path had zero logging anywhere, so there was no way to tell from
cluster logs whether the AF_PACKET hook saw nothing, saw traffic that
didn't match, or something else. Purely additive, no logic change:

- afpacketSource.Run: logs socket open/failure, then a throttled
  liveness line (first frame + every 10s) with frame count and last
  dst IP, so a validation run can tell "the socket sees nothing" from
  "the socket sees traffic, none of it matched."
- wakeRegistry.register/onDstIP: logs VM registration, and each of the
  three onDstIP outcomes (matched+enqueued, matched+already-signalled,
  matched+channel-full) at Info; a match against nothing registered
  stays silent by design, since unrelated overlay traffic would
  otherwise flood this log continuously in a busy cluster.

internal/agent unit tests (scaletozero_test.go, scaletozero_reconciler_test.go)
pass unchanged — no behavior touched, existing coverage still green.
Root-caused via a real-KVM CI run + Fable investigation: the socket
opened with a specific protocol (ETH_P_IP) registers into the kernel's
ptype_base hash, which __netif_receive_skb_core only invokes if the
receiving device's rx_handler returns RX_HANDLER_PASS. A bridge port's
rx_handler (br_handle_frame) returns RX_HANDLER_CONSUMED for every
switched/flooded frame -- i.e. every ordinary VM-to-VM frame on an
impbr-* bridge -- so ptype_base delivery never happens for exactly the
traffic this hook exists to see. This exactly matched the evidence: the
diagnostic run captured thousands of frames on lo/the pod's own CNI IP
(neither bridged, no rx_handler) but zero frames from imp's own overlay
subnet, and zero wake matches, across the whole run.

Fix: bind with ETH_P_ALL (registers into ptype_all, invoked before
rx_handler -- same mechanism tcpdump uses to see switched traffic) and
filter to IPv4 in userspace via the EtherType field instead of at
socket() time.
Root-caused via Fable investigation, confirmed against real CI job
logs (job 90608981396): "delete FDB entry <mac> ...: address family
not supported by protocol" is EAFNOSUPPORT, hit deterministically
every run. When the VXLAN interface attaches to the bridge
(attachToBridge), Linux auto-creates a "self permanent" FDB entry for
the port's own MAC with no tunnel destination. SyncFDB's stale-entry
sweep isn't caught by the existing all-zeros-MAC skip, tries to delete
it anyway, and NeighDel serializes a 0-length NDA_DST from the nil IP,
which the kernel's VXLAN driver rejects.

Not just log noise: the delete loop runs before the add loop and
returns on first error, so SyncFDB could never reach NeighAdd once the
interface was bridged -- meaning real VTEP entries plausibly never got
added either. Not a Kind/nested-container artifact: NeighAdd,
NeighList, and bridge attach all succeed in the same environment: the
failure is data-shape driven (empty attribute), not a permission or
capability gap, and would reproduce identically on Talos nodes.

Fix: skip entries with no resolvable destination IP in the delete
sweep, alongside the existing all-zeros-broadcast-MAC skip.
@syscod3
syscod3 merged commit a0ccf8c into main Jul 29, 2026
23 checks passed
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