From 221b688a89e84f2d06414d6f61d8038fe4f083b4 Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Wed, 17 Jun 2026 19:26:47 +1000 Subject: [PATCH 1/2] feat(host): detect CPU steal at callback-deadline kills (detection only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CPU bounding is wall-clock only, and a true fuel/instruction budget is impossible on this stack (wazero v1.12.0 / extism v1.7.1 expose no fuel/epoch/gas API). A CPU-steal-throttled VM can therefore blow the per-callback deadline on a guest running pure, well-behaved guest code, and that kill is booked as a fault that can quarantine a healthy game. Measure host-stolen CPU at the kill site and record it ALONGSIDE the existing GameCallbackDeadline metric. Detection only: no change to fault(), quarantine, End(), or any kill/condemn decision; no exoneration yet. - Build-tagged, failure-tolerant steal sampler (steal_linux.go reads the /proc/stat aggregate hypervisor-STEAL field; steal_other.go is a no-op ok=false stub). Sampled at callback BOUNDARIES, never per-instruction. Injectable via a package var so tests can stub it. - Single signal: ONLY /proc/stat steal. cgroup throttle is deliberately not read or OR'd in — steal blames the host (the eventual exonerate case), cgroup throttle blames the guest (a runaway you must not exonerate). - Non-breaking optional StealMetrics extension interface, recorded via a type assertion at the wall-clock-kill site so existing Metrics implementers compile and run unchanged. Co-Authored-By: Claude Opus 4.8 --- .changeset/host-steal-detection.md | 24 +++++ host/gameabi/host.go | 19 ++++ host/gameabi/steal.go | 40 ++++++++ host/gameabi/steal_linux.go | 74 ++++++++++++++ host/gameabi/steal_linux_test.go | 100 +++++++++++++++++++ host/gameabi/steal_other.go | 12 +++ host/gameabi/steal_test.go | 151 +++++++++++++++++++++++++++++ 7 files changed, 420 insertions(+) create mode 100644 .changeset/host-steal-detection.md create mode 100644 host/gameabi/steal.go create mode 100644 host/gameabi/steal_linux.go create mode 100644 host/gameabi/steal_linux_test.go create mode 100644 host/gameabi/steal_other.go create mode 100644 host/gameabi/steal_test.go diff --git a/.changeset/host-steal-detection.md b/.changeset/host-steal-detection.md new file mode 100644 index 0000000..fa5d816 --- /dev/null +++ b/.changeset/host-steal-detection.md @@ -0,0 +1,24 @@ +--- +"kit": patch +--- + +host: detect CPU steal at callback-deadline kills (detection only) + +The per-callback kill switch is wall-clock only, and a true fuel/instruction +budget is impossible on this stack (wazero v1.12.0 / extism v1.7.1 expose no +fuel/epoch/gas metering API). So a CPU-steal-throttled VM can blow the deadline +on a guest running pure, well-behaved guest code, and that kill is booked as a +fault that can quarantine a healthy game. + +This change MEASURES host-stolen CPU at the kill site and records it alongside +the existing deadline metric — detection only, no behavior change: + +- A failure-tolerant, build-tagged steal sampler reads ONLY the /proc/stat + aggregate hypervisor-STEAL field (Linux; a no-op ok=false stub elsewhere), + sampled at callback BOUNDARIES, never per-instruction. cgroup throttling is + deliberately NOT read: steal blames the host (the eventual exonerate case), + whereas cgroup throttle blames the guest (a runaway you must not exonerate). +- A new NON-BREAKING optional `StealMetrics` extension interface is recorded via + a type assertion at the wall-clock-kill site, so existing Metrics implementers + compile and run unchanged. fault(), quarantine, and End() are untouched; the + existing GameCallbackDeadline record is never replaced or suppressed. diff --git a/host/gameabi/host.go b/host/gameabi/host.go index 0a49c89..bca9514 100644 --- a/host/gameabi/host.go +++ b/host/gameabi/host.go @@ -792,9 +792,18 @@ func (h *wasmHandler) invoke(r sdk.Room, name string, roster []sdk.Player, extra defer cancel() hostStart := h.cbHostNanos // per-callback host-time delta baseline h.hostIOExpired = false + // CPU-steal detection (detection only — see steal.go). Sample host-stolen + // CPU at the callback BOUNDARIES so the read is off the hot per-instruction + // path: once before the guest runs, once after it returns/is killed. If the + // counter advanced across the window, the VM was being stolen from while + // this callback ran. stealOK guards a host without the signal (non-Linux, + // or any /proc/stat read/parse failure) — then no steal metric is recorded. + stealPre, stealOK := stealReader() cbStart := time.Now() exit, _, err := h.inst.CallWithContext(ctx, name, payload) dur := time.Since(cbStart) + stealPost, stealPostOK := stealReader() + stealAdvanced := stealOK && stealPostOK && stealPost > stealPre h.cbTotalNanos += dur.Nanoseconds() hostDelta := h.cbHostNanos - hostStart h.lastExit, h.lastErr = exit, err @@ -815,6 +824,16 @@ func (h *wasmHandler) invoke(r sdk.Room, name string, roster []sdk.Player, extra m.GameHostIODeadline(h.game.meta.Slug, name) } else if deadlined { m.GameCallbackDeadline(h.game.meta.Slug, name) + // Detection only: alongside (never instead of) the deadline record, + // if host-stolen CPU advanced across this killed callback's window, + // surface it via the non-breaking optional StealMetrics seam. The + // kill still stands and still feeds the fault path below — this is a + // correlation signal for the future exonerate case, not a decision. + if stealAdvanced { + if sm, ok := m.(StealMetrics); ok { + sm.GameCallbackStealDeadline(h.game.meta.Slug, name) + } + } } } if err != nil || exit != 0 { diff --git a/host/gameabi/steal.go b/host/gameabi/steal.go new file mode 100644 index 0000000..39eaa6c --- /dev/null +++ b/host/gameabi/steal.go @@ -0,0 +1,40 @@ +package gameabi + +// CPU-steal DETECTION (detection only — no behavior change). +// +// Why this exists: the per-callback kill switch is WALL-CLOCK only. A true +// fuel/instruction budget is impossible on this stack — wazero v1.12.0 and +// extism v1.7.1 expose no fuel/epoch/gas metering API to bound a guest by work +// done rather than time elapsed — so a CPU-steal-throttled VM (a noisy neighbor +// on oversubscribed hardware) can blow the wall-clock deadline on a guest that +// is running PURE, well-behaved guest code, and that kill is booked as a fault +// that can quarantine a healthy game. (The slow-shared-Postgres analogue is +// already carved out via hostIOKill.) +// +// The only available move is to MEASURE host-stolen CPU at kill time. This file +// adds that measurement ALONGSIDE the existing GameCallbackDeadline record. It +// does NOT change fault(), quarantine, End(), or any kill/condemn decision: no +// exoneration is wired yet. See steal_linux.go for the single signal (/proc/stat +// hypervisor steal), its blame direction, and its coarseness caveats. + +// stealReader returns the cumulative host-stolen CPU time in jiffies and whether +// that figure is available. It is an INJECTABLE package var (defaulting to the +// real per-GOOS reader) so tests can stub steal without a real /proc/stat. The +// host reads it only at CALLBACK BOUNDARIES (before invoking the guest, and at +// the kill/return) — never on a hot per-instruction path. +var stealReader = readStealJiffiesLinux + +// StealMetrics is a NON-BREAKING optional extension of the Metrics surface. It +// is deliberately NOT folded into the Metrics interface: doing so would break +// every existing Metrics implementer (notably the platform's) on the next kit +// bump. The kill site type-asserts the configured Metrics to StealMetrics and +// records ONLY if the implementer opts in, so older implementers compile and +// run unchanged while the seam stays dormant until the platform implements it. +type StealMetrics interface { + // GameCallbackStealDeadline records one wall-clock callback-deadline kill + // during which host-stolen CPU advanced — i.e. the VM was being stolen from + // across the killed callback's window. Recorded ALONGSIDE (never instead of) + // GameCallbackDeadline. A correlation signal for the future exonerate case, + // not a kill/quarantine decision. + GameCallbackStealDeadline(slug, callback string) +} diff --git a/host/gameabi/steal_linux.go b/host/gameabi/steal_linux.go new file mode 100644 index 0000000..8bcd245 --- /dev/null +++ b/host/gameabi/steal_linux.go @@ -0,0 +1,74 @@ +//go:build linux + +package gameabi + +import ( + "os" + "strconv" + "strings" +) + +// procStatPath is the file the steal reader parses. A package var (not a const) +// so a //go:build linux test can point it at a synthetic fixture without a real +// /proc — the production default is the kernel's live aggregate CPU accounting. +var procStatPath = "/proc/stat" + +// readStealJiffies returns the cumulative hypervisor-STEAL time, in USER_HZ +// jiffies (~10ms each), that the kernel has accounted across ALL CPUs since +// boot — the 8th field of the aggregate "cpu " line in /proc/stat: +// +// cpu user nice system idle iowait irq softirq steal guest guest_nice +// 1 2 3 4 5 6 7 8 ... +// +// "Steal" is wall-clock time during which a runnable vCPU was NOT scheduled +// because the HYPERVISOR ran someone else — i.e. the host took CPU away from +// us. That is the ONLY signal this reader exposes, by deliberate choice: +// +// - Steal blames the HOST (a noisy-neighbor / oversubscribed VM), so a +// deadline kill that coincides with advancing steal is the future +// EXONERATE case — a well-behaved guest punished for the platform's +// scheduling, not a runaway. (No exoneration is wired yet; this is +// detection only.) +// - cgroup throttled_usec (CPU quota throttling) blames US: the guest +// exceeded its OWN cpu.max quota, which is exactly the runaway you must +// NOT exonerate. It is therefore intentionally NOT read and NOT OR'd in. +// +// Coarseness caveats, by construction: +// - HOST-WIDE: the aggregate "cpu " line counts steal across every vCPU and +// every tenant process on this VM, not steal attributable to this room's +// goroutine. It can only ever say "the VM was being stolen from around the +// time this callback ran", never "this callback's CPU was stolen". +// - JIFFY RESOLUTION: USER_HZ is typically 100Hz, so the counter advances in +// ~10ms steps — comparable to the default 100ms callback deadline. A short +// callback can be wholly stolen yet straddle no jiffy tick (false negative); +// a long one can tick over a jiffy boundary on unrelated steal (false +// positive). Sampling at callback boundaries (not per-instruction) keeps it +// cheap at the cost of this granularity. +// +// ok is false on ANY read or parse failure (missing file, short line, +// non-numeric field): the caller treats "no steal info" as the current +// behavior, recording no steal metric. A guest cannot influence this file. +func readStealJiffiesLinux() (steal uint64, ok bool) { + b, err := os.ReadFile(procStatPath) + if err != nil { + return 0, false + } + // The aggregate line is the first line and starts with "cpu " (note the + // trailing space — "cpu0", "cpu1", ... are the per-core lines we skip). + for _, line := range strings.Split(string(b), "\n") { + if !strings.HasPrefix(line, "cpu ") { + continue + } + fields := strings.Fields(line) // ["cpu", user, nice, system, idle, iowait, irq, softirq, steal, ...] + // fields[0] == "cpu"; steal is the 8th value AFTER the label => index 8. + if len(fields) <= 8 { + return 0, false + } + v, perr := strconv.ParseUint(fields[8], 10, 64) + if perr != nil { + return 0, false + } + return v, true + } + return 0, false +} diff --git a/host/gameabi/steal_linux_test.go b/host/gameabi/steal_linux_test.go new file mode 100644 index 0000000..0c03ae3 --- /dev/null +++ b/host/gameabi/steal_linux_test.go @@ -0,0 +1,100 @@ +//go:build linux + +package gameabi + +import ( + "os" + "path/filepath" + "testing" +) + +// TestReadStealJiffiesLinux parses the STEAL field (the 8th value after the +// "cpu" label) from a synthetic /proc/stat fed via the injectable procStatPath, +// and pins the failure-tolerant contract: ANY read/parse problem => ok=false. +func TestReadStealJiffiesLinux(t *testing.T) { + write := func(t *testing.T, content string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "stat") + if err := os.WriteFile(p, []byte(content), 0o600); err != nil { + t.Fatalf("write fixture: %v", err) + } + return p + } + setPath := func(t *testing.T, p string) { + t.Helper() + prev := procStatPath + procStatPath = p + t.Cleanup(func() { procStatPath = prev }) + } + + for _, tc := range []struct { + name string + content string + wantSteal uint64 + wantOK bool + }{ + { + name: "real-shaped line parses the 8th field", + // user nice system idle iowait irq softirq STEAL guest guest_nice + content: "cpu 100 20 30 40000 5 6 7 4242 9 10\ncpu0 1 2 3 4 5 6 7 8\nintr 0\n", + wantSteal: 4242, + wantOK: true, + }, + { + name: "zero steal (unstolen VM) is a valid reading", + content: "cpu 1 2 3 4 5 6 7 0 9 10\n", + wantSteal: 0, + wantOK: true, + }, + { + name: "short line (no steal field) => ok=false", + content: "cpu 1 2 3 4\n", + wantOK: false, + }, + { + name: "non-numeric steal field => ok=false", + content: "cpu 1 2 3 4 5 6 7 xx 9\n", + wantOK: false, + }, + { + name: "no aggregate cpu line => ok=false", + content: "cpu0 1 2 3 4 5 6 7 8\nintr 0\n", + wantOK: false, + }, + } { + t.Run(tc.name, func(t *testing.T) { + setPath(t, write(t, tc.content)) + got, ok := readStealJiffiesLinux() + if ok != tc.wantOK { + t.Fatalf("ok = %v, want %v", ok, tc.wantOK) + } + if ok && got != tc.wantSteal { + t.Fatalf("steal = %d, want %d", got, tc.wantSteal) + } + }) + } + + // Missing file => ok=false (failure-tolerant, never panics). + setPath(t, filepath.Join(t.TempDir(), "does-not-exist")) + if _, ok := readStealJiffiesLinux(); ok { + t.Fatal("missing /proc/stat reported ok=true, want false") + } +} + +// TestReadStealJiffiesMonotonic asserts the LIVE /proc/stat reader is available +// and non-decreasing on a real Linux host: steal is a cumulative-since-boot +// counter, so a later read can never be below an earlier one. +func TestReadStealJiffiesMonotonic(t *testing.T) { + // Default path (real /proc/stat) — procStatPath untouched. + a, okA := readStealJiffiesLinux() + if !okA { + t.Skip("live /proc/stat steal field unavailable on this host") + } + b, okB := readStealJiffiesLinux() + if !okB { + t.Fatal("second live read failed after a successful first read") + } + if b < a { + t.Fatalf("steal counter went backwards: %d -> %d (must be monotonic)", a, b) + } +} diff --git a/host/gameabi/steal_other.go b/host/gameabi/steal_other.go new file mode 100644 index 0000000..47a621b --- /dev/null +++ b/host/gameabi/steal_other.go @@ -0,0 +1,12 @@ +//go:build !linux + +package gameabi + +// readStealJiffiesLinux is the no-op stub on non-Linux hosts: there is no +// /proc/stat hypervisor-steal accounting to read, so it always reports no steal +// info (ok=false) and the caller records no steal metric — exactly the current +// behavior. Production runs on Linux; this keeps the package compiling and the +// cross-platform stubbed tests honest on darwin/etc. +func readStealJiffiesLinux() (steal uint64, ok bool) { + return 0, false +} diff --git a/host/gameabi/steal_test.go b/host/gameabi/steal_test.go new file mode 100644 index 0000000..1cc8968 --- /dev/null +++ b/host/gameabi/steal_test.go @@ -0,0 +1,151 @@ +package gameabi + +import ( + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/shellcade/kit/v2/host/sdk" +) + +// stealRecordingMetrics is the recording Metrics double that ALSO implements the +// optional StealMetrics extension — proving the kill site's type assertion finds +// and drives the steal record only when the implementer opts in. +type stealRecordingMetrics struct { + *recordingMetrics + mu sync.Mutex + stealKills map[string]int // slug/callback -> count + stealKillsN int +} + +func newStealRecordingMetrics() *stealRecordingMetrics { + return &stealRecordingMetrics{recordingMetrics: newRecordingMetrics(), stealKills: map[string]int{}} +} + +func (m *stealRecordingMetrics) GameCallbackStealDeadline(slug, callback string) { + m.mu.Lock() + defer m.mu.Unlock() + m.stealKills[slug+"/"+callback]++ + m.stealKillsN++ +} + +func (m *stealRecordingMetrics) totalStealKills() int { + m.mu.Lock() + defer m.mu.Unlock() + return m.stealKillsN +} + +// stubStealReader installs an injectable steal reader for the duration of a test +// and restores the previous one. delta is added to the cumulative counter on +// every read, so delta==0 models a quiet host (no steal advances across any +// callback) and delta>0 models a VM under steal (every callback's window +// advances). ok==false models a host without the signal (non-Linux / read fail). +func stubStealReader(t *testing.T, delta uint64, ok bool) { + t.Helper() + prev := stealReader + var ctr atomic.Uint64 + stealReader = func() (uint64, bool) { + return ctr.Add(delta), ok + } + t.Cleanup(func() { stealReader = prev }) +} + +// TestStealDeadlineDetection proves the DETECTION-ONLY steal seam: at a real +// wall-clock callback-deadline kill (a non-exempt fixture spinning on 'l'), the +// steal record is emitted ALONGSIDE GameCallbackDeadline iff host-stolen CPU +// advanced across the killed callback's window — and never on the host-I/O path. +// The kill/fault behavior itself is unchanged in every case. +func TestStealDeadlineDetection(t *testing.T) { + for _, tc := range []struct { + name string + stealDelta uint64 // per-read advance of the stubbed steal counter + stealOK bool // whether the stub reports the signal as available + wantDeadline int // GameCallbackDeadline records expected + wantStealKills int // GameCallbackStealDeadline records expected + }{ + {name: "steal unchanged => deadline only, no steal metric", stealDelta: 0, stealOK: true, wantDeadline: 1, wantStealKills: 0}, + {name: "steal advanced => both deadline and steal metric", stealDelta: 5, stealOK: true, wantDeadline: 1, wantStealKills: 1}, + {name: "no steal signal => deadline only (current behavior)", stealDelta: 9, stealOK: false, wantDeadline: 1, wantStealKills: 0}, + } { + t.Run(tc.name, func(t *testing.T) { + stubStealReader(t, tc.stealDelta, tc.stealOK) + + var faults atomic.Int32 + rec := newStealRecordingMetrics() + // NON-EXEMPT game: OnFault != nil, so it can actually fault/quarantine + // (the load-test game is QuarantineExempt and must never be used here). + g := loadFixture(t, Options{ + CallbackDeadline: 50 * time.Millisecond, + OnFault: func(string) { faults.Add(1) }, + Metrics: rec, + }) + cfg := sdk.RoomConfig{Mode: sdk.ModeSolo, Capacity: 1, MinPlayers: 1, Seed: 7, SeedSet: true} + tr := sdk.NewTestRoom(g, cfg, sdk.Services{Log: quietLog()}) + tr.Start() + tr.Join(p1) + + tr.Input(p1, runeIn('l')) // spin forever -> real wall-clock deadline kill + + if !tr.Ended { + t.Fatal("room did not settle after a spin-to-deadline") + } + // Kill/fault behavior is unchanged regardless of steal: still exactly + // one fault feeding quarantine. + if n := faults.Load(); n != 1 { + t.Fatalf("faults = %d, want 1 (kill behavior must be unchanged)", n) + } + if got := rec.totalDeadlines(); got != tc.wantDeadline { + t.Fatalf("GameCallbackDeadline = %d, want %d", got, tc.wantDeadline) + } + if got := rec.totalStealKills(); got != tc.wantStealKills { + t.Fatalf("GameCallbackStealDeadline = %d, want %d", got, tc.wantStealKills) + } + // The steal seam never reclassifies a kill as host-I/O. + if got := rec.totalHostIODeadlines(); got != 0 { + t.Fatalf("host-I/O deadline = %d, want 0 (the guest spun)", got) + } + }) + } +} + +// TestStealDeadlineNotRecordedOnHostIOKill proves the steal seam stays silent on +// the host-I/O kill path: a deadline that expired inside the host's OWN kv call +// (slow Postgres) records neither GameCallbackDeadline nor the steal metric, +// even with steal advancing — that path is already exempt and unchanged. +func TestStealDeadlineNotRecordedOnHostIOKill(t *testing.T) { + stubStealReader(t, 7, true) // steal advancing on every callback + + var faults atomic.Int32 + rec := newStealRecordingMetrics() + g := loadFixture(t, Options{ + CallbackDeadline: 50 * time.Millisecond, + OnFault: func(string) { faults.Add(1) }, + Metrics: rec, + }) + // A kv store slower than the deadline, with the guest blocked in the host's + // own kv call: the host-I/O carve-out fires (hostIOKill), not a fault. + svc := sdk.Services{Log: quietLog(), Accounts: fakeAccounts{kv: slowKV{d: 30 * time.Second}}} + cfg := sdk.RoomConfig{Mode: sdk.ModeSolo, Capacity: 1, MinPlayers: 1, Seed: 7, SeedSet: true} + tr := sdk.NewTestRoom(g, cfg, svc) + tr.Start() + tr.Join(p1) + + tr.Input(p1, runeIn('k')) // fixture's kv command: blocks in the host kv_set call + + if !tr.Ended { + t.Fatal("room did not settle after the host-I/O deadline") + } + if n := faults.Load(); n != 0 { + t.Fatalf("faults = %d, want 0 (host-I/O kill must not fault)", n) + } + if got := rec.totalHostIODeadlines(); got != 1 { + t.Fatalf("host-I/O deadline = %d, want 1", got) + } + if got := rec.totalDeadlines(); got != 0 { + t.Fatalf("GameCallbackDeadline = %d, want 0 on the host-I/O path", got) + } + if got := rec.totalStealKills(); got != 0 { + t.Fatalf("GameCallbackStealDeadline = %d, want 0 on the host-I/O path", got) + } +} From 64c623b4a7b409e7f18a11b8bfcb7f51b297f94b Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Wed, 17 Jun 2026 19:34:44 +1000 Subject: [PATCH 2/2] address review nits: gate steal sampling + changeset minor - Gate /proc/stat sampling on the metrics sink implementing StealMetrics. The seam is dormant in prod until the platform implements it, so a host with no consumer now pays ZERO /proc/stat reads (was 2 reads per callback, discarded on the happy path). When enabled: one read before the callback, and the second only at the kill site (not on every callback). - Changeset patch -> minor: this adds the exported StealMetrics interface to the public kit API, which is a minor bump by semver. Co-Authored-By: Claude Opus 4.8 --- .changeset/host-steal-detection.md | 2 +- host/gameabi/host.go | 40 ++++++++++++++++++------------ 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/.changeset/host-steal-detection.md b/.changeset/host-steal-detection.md index fa5d816..d2cb0bd 100644 --- a/.changeset/host-steal-detection.md +++ b/.changeset/host-steal-detection.md @@ -1,5 +1,5 @@ --- -"kit": patch +"kit": minor --- host: detect CPU steal at callback-deadline kills (detection only) diff --git a/host/gameabi/host.go b/host/gameabi/host.go index bca9514..555ac0b 100644 --- a/host/gameabi/host.go +++ b/host/gameabi/host.go @@ -792,18 +792,24 @@ func (h *wasmHandler) invoke(r sdk.Room, name string, roster []sdk.Player, extra defer cancel() hostStart := h.cbHostNanos // per-callback host-time delta baseline h.hostIOExpired = false - // CPU-steal detection (detection only — see steal.go). Sample host-stolen - // CPU at the callback BOUNDARIES so the read is off the hot per-instruction - // path: once before the guest runs, once after it returns/is killed. If the - // counter advanced across the window, the VM was being stolen from while - // this callback ran. stealOK guards a host without the signal (non-Linux, - // or any /proc/stat read/parse failure) — then no steal metric is recorded. - stealPre, stealOK := stealReader() + // CPU-steal detection (detection only — see steal.go). Only sample when a + // StealMetrics sink is actually wired: the seam is dormant in prod until the + // platform implements it, so until then a host pays ZERO /proc/stat reads. + // When enabled, sample at callback BOUNDARIES (off the hot per-instruction + // path): once before the guest runs, and — only if the callback is killed — + // once at the kill site below. If the host-stolen-CPU counter advanced across + // that window, the VM was being stolen from while this callback ran. stealOK + // additionally guards a host without the signal (non-Linux, or any /proc/stat + // read/parse failure) — then no steal metric is recorded. + stealSink, stealEnabled := h.game.opts.Metrics.(StealMetrics) + var stealPre uint64 + var stealOK bool + if stealEnabled { + stealPre, stealOK = stealReader() + } cbStart := time.Now() exit, _, err := h.inst.CallWithContext(ctx, name, payload) dur := time.Since(cbStart) - stealPost, stealPostOK := stealReader() - stealAdvanced := stealOK && stealPostOK && stealPost > stealPre h.cbTotalNanos += dur.Nanoseconds() hostDelta := h.cbHostNanos - hostStart h.lastExit, h.lastErr = exit, err @@ -825,13 +831,15 @@ func (h *wasmHandler) invoke(r sdk.Room, name string, roster []sdk.Player, extra } else if deadlined { m.GameCallbackDeadline(h.game.meta.Slug, name) // Detection only: alongside (never instead of) the deadline record, - // if host-stolen CPU advanced across this killed callback's window, - // surface it via the non-breaking optional StealMetrics seam. The - // kill still stands and still feeds the fault path below — this is a - // correlation signal for the future exonerate case, not a decision. - if stealAdvanced { - if sm, ok := m.(StealMetrics); ok { - sm.GameCallbackStealDeadline(h.game.meta.Slug, name) + // take the SECOND steal sample now — only on a kill, so the happy + // path pays nothing. If host-stolen CPU advanced across this killed + // callback's window, surface it via the non-breaking optional + // StealMetrics seam. The kill still stands and still feeds the fault + // path below — this is a correlation signal for the future exonerate + // case, not a decision. + if stealEnabled && stealOK { + if stealPost, ok := stealReader(); ok && stealPost > stealPre { + stealSink.GameCallbackStealDeadline(h.game.meta.Slug, name) } } }