test(plugin): lock CPU-budget overrun recovery (W1-1)

Hand-assembled wasm fixture whose command_dispatch spins forever only for
payloads over 100 bytes: baseline dispatch succeeds, an over-budget dispatch
surfaces the budget error, and the next dispatch on the same plugin succeeds
again via lazy re-instantiation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
J3vb
2026-07-19 08:21:55 +02:00
co-authored by Claude Fable 5
parent b13adf23aa
commit a3f7d63f7d
+113
View File
@@ -23,6 +23,7 @@ import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/owncord/server/store"
@@ -211,6 +212,118 @@ func TestWazeroDisablePluginFreesModule(t *testing.T) {
}
}
// spinWASM implements the command-dispatch ABI with input-dependent runtime:
//
// (module
// (memory (export "memory") 1)
// (func (export "allocate") (param i32) (result i32) i32.const 8)
// (func (export "deallocate") (param i32 i32))
// (func (export "command_dispatch") (param i32 i32) (result i32 i32)
// local.get 1 ;; payload length
// i32.const 100
// i32.gt_u
// if (loop br 0 end) end ;; payloads over 100 bytes spin forever
// i32.const 0 i32.const 0))
//
// A dispatch with no args stays under 100 payload bytes and returns
// immediately; long args push the JSON payload over 100 bytes and trigger an
// infinite loop, which the CPU budget must interrupt.
var spinWASM = []byte{
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, // \0asm v1
// type section: (i32)->i32, (i32,i32)->(), (i32,i32)->(i32,i32)
0x01, 0x12, 0x03,
0x60, 0x01, 0x7f, 0x01, 0x7f,
0x60, 0x02, 0x7f, 0x7f, 0x00,
0x60, 0x02, 0x7f, 0x7f, 0x02, 0x7f, 0x7f,
// function section: 3 funcs using types 0,1,2
0x03, 0x04, 0x03, 0x00, 0x01, 0x02,
// memory section: 1 page, no max
0x05, 0x03, 0x01, 0x00, 0x01,
// export section: memory, allocate, deallocate, command_dispatch
0x07, 0x35, 0x04,
0x06, 0x6d, 0x65, 0x6d, 0x6f, 0x72, 0x79, 0x02, 0x00,
0x08, 0x61, 0x6c, 0x6c, 0x6f, 0x63, 0x61, 0x74, 0x65, 0x00, 0x00,
0x0a, 0x64, 0x65, 0x61, 0x6c, 0x6c, 0x6f, 0x63, 0x61, 0x74, 0x65, 0x00, 0x01,
0x10, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x5f, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x00, 0x02,
// code section
0x0a, 0x1e, 0x03,
// allocate: return 8
0x04, 0x00, 0x41, 0x08, 0x0b,
// deallocate: nop
0x02, 0x00, 0x0b,
// command_dispatch: spin if len>100 else return (0,0)
0x14, 0x00,
0x20, 0x01, // local.get 1
0x41, 0xe4, 0x00, // i32.const 100
0x4b, // i32.gt_u
0x04, 0x40, // if
0x03, 0x40, // loop
0x0c, 0x00, // br 0
0x0b, // end loop
0x0b, // end if
0x41, 0x00, // i32.const 0
0x41, 0x00, // i32.const 0
0x0b, // end
}
// TestWazeroCPUBudgetOverrunDoesNotBrickPlugin locks in the W1-1 fix: an
// over-budget command must return the budget error, and the SAME plugin must
// serve the next command via lazy re-instantiation — not stay dead until an
// admin disable/enable cycle or server restart.
func TestWazeroCPUBudgetOverrunDoesNotBrickPlugin(t *testing.T) {
dir := t.TempDir()
manifest := `{"name":"spinner","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"]}`
writeTestPlugin(t, dir, "spinner", manifest, spinWASM)
reg, mem := newWazeroTestRegistry(t, dir)
ctx := context.Background()
if err := reg.LoadAll(ctx); err != nil {
t.Fatal(err)
}
rows, _ := mem.ListPlugins(ctx)
if err := reg.EnablePlugin(ctx, rows[0].ID); err != nil {
t.Fatalf("EnablePlugin: %v", err)
}
reg.mu.RLock()
inst := reg.plugins[rows[0].ID]
reg.mu.RUnlock()
if err := reg.RegisterCommand("spin", inst); err != nil {
t.Fatalf("RegisterCommand: %v", err)
}
// Baseline: a small payload dispatches fine.
result, ok := reg.DispatchCommand(ctx, 1, 2, "spin", nil)
if !ok || result == nil {
t.Fatalf("baseline dispatch failed: ok=%v result=%+v", ok, result)
}
if strings.Contains(result.Reply, "CPU budget") {
t.Fatalf("baseline dispatch should not hit the budget: %q", result.Reply)
}
// Overrun: a long arg pushes the payload over the spin threshold; the
// 100ms budget must interrupt it and surface the budget error.
result, ok = reg.DispatchCommand(ctx, 1, 2, "spin", []string{strings.Repeat("x", 200)})
if !ok || result == nil {
t.Fatalf("overrun dispatch returned no result: ok=%v", ok)
}
if !strings.Contains(result.Reply, "CPU budget") {
t.Fatalf("expected CPU budget error, got %q", result.Reply)
}
// The plugin must still work: the next small dispatch re-instantiates the
// module lazily instead of dispatching into the closed one forever.
result, ok = reg.DispatchCommand(ctx, 1, 2, "spin", nil)
if !ok || result == nil {
t.Fatalf("post-overrun dispatch failed: ok=%v result=%+v", ok, result)
}
if strings.Contains(result.Reply, "CPU budget") || strings.Contains(result.Reply, "module closed") {
t.Fatalf("plugin still bricked after overrun: %q", result.Reply)
}
if !inst.Enabled {
t.Fatal("overrun must not disable the plugin")
}
}
func TestWazeroInvalidWASMFailsActivation(t *testing.T) {
dir := t.TempDir()
manifest := `{"name":"brokey","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"]}`