From 8fbc4485c1e85d35c2aeeb52d32e8b7897da9108 Mon Sep 17 00:00:00 2001 From: anonpenguin23 Date: Tue, 26 May 2026 10:53:07 +0300 Subject: [PATCH] fix(serverless): enable system clocks for wasm modules - opt into `WithSysWalltime` and `WithSysNanotime` to prevent wazero from using a frozen sentinel clock - add regression tests to verify real-time clock behavior in wasm execution - ensure serverless functions receive accurate timestamps for audit and cursor logic --- core/pkg/serverless/engine.go | 11 +- core/pkg/serverless/execution/executor.go | 9 +- .../pkg/serverless/execution/walltime_test.go | 201 ++++++++++++++++++ 3 files changed, 219 insertions(+), 2 deletions(-) create mode 100644 core/pkg/serverless/execution/walltime_test.go diff --git a/core/pkg/serverless/engine.go b/core/pkg/serverless/engine.go index 7411b19..e20c19a 100644 --- a/core/pkg/serverless/engine.go +++ b/core/pkg/serverless/engine.go @@ -458,7 +458,16 @@ func (e *Engine) InstantiatePersistent(ctx context.Context, fn *Function, invCtx WithStdin(emptyReader{}). WithStdout(discardWriter{}). WithStderr(discardWriter{}). - WithArgs(fn.Name) + WithArgs(fn.Name). + // Bugboard #27 — wazero defaults to fake/sentinel clocks (deterministic + // fixtures for unit testing). TinyGo wasm calls WASI clock_time_get + // from time.Now() and gets a frozen ~2022-01-01T00:00:00.001Z back + // for every reading, silently poisoning any serverless function that + // embeds timestamps (receipts, audit rows, cursor cmp logic). Opt + // into real clocks via the documented wazero hook — same effect as + // the runtime would get on a normal Go process. + WithSysWalltime(). + WithSysNanotime() instance, err := e.runtime.InstantiateModule(ctx, compiled, moduleConfig) if err != nil { diff --git a/core/pkg/serverless/execution/executor.go b/core/pkg/serverless/execution/executor.go index 53c3db6..465335d 100644 --- a/core/pkg/serverless/execution/executor.go +++ b/core/pkg/serverless/execution/executor.go @@ -73,7 +73,14 @@ func (e *Executor) ExecuteModule(ctx context.Context, compiled wazero.CompiledMo WithStdin(stdin). WithStdout(stdout). WithStderr(stderr). - WithArgs(moduleName) // argv[0] is the program name + WithArgs(moduleName). // argv[0] is the program name + // Bugboard #27 — wazero defaults to fake/sentinel clocks. Without + // these opt-ins, TinyGo's time.Now() returns ~2022-01-01T00:00:00.001Z + // frozen on every read, silently poisoning timestamps in every + // invocation that uses time.Now() (receipts, audit rows, cursor cmp). + // Same fix applied at engine.go for the persistent-WS path. + WithSysWalltime(). + WithSysNanotime() // Acquire concurrency slot if e.sem != nil { diff --git a/core/pkg/serverless/execution/walltime_test.go b/core/pkg/serverless/execution/walltime_test.go new file mode 100644 index 0000000..44b582e --- /dev/null +++ b/core/pkg/serverless/execution/walltime_test.go @@ -0,0 +1,201 @@ +package execution + +import ( + "context" + "encoding/binary" + "testing" + "time" + + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1" +) + +// Bugboard #27 — wazero defaults to a FAKE walltime clock that returns +// ~2022-01-01T00:00:00.001Z frozen on every reading. TinyGo wasm calls +// WASI clock_time_get from time.Now(), so any serverless function that +// embeds timestamps (receipts, audit rows, cursor comparisons) silently +// poisons its writes with the sentinel epoch. +// +// The fix is to opt into real clocks via .WithSysWalltime() / +// .WithSysNanotime() on the wazero ModuleConfig (one-line per the two +// moduleConfig builders — engine.go for persistent WS, executor.go for +// stateless). This test pins the behavior at the executor's config +// path: instantiate a tiny wasm that calls WASI clock_time_get, read +// the result, assert it's a real post-2024 epoch and not the frozen +// 2022 sentinel. +// +// If a future refactor drops .WithSysWalltime(), this test fails with +// "got pre-2024 timestamp X (sentinel?); did the WithSysWalltime() call +// get dropped from moduleConfig?" — exact line back to the regression. + +// walltimeProbeWasm is a hand-assembled WASM module that imports +// wasi_snapshot_preview1.clock_time_get and calls it from _start, +// writing the result to memory[0:8]. +// +// (module +// (type $clock_time_get (func (param i32 i64 i32) (result i32))) +// (type $start (func)) +// (import "wasi_snapshot_preview1" "clock_time_get" +// (func $clock_time_get (type 0))) +// (memory (export "memory") 1) +// (func $_start (type 1) +// i32.const 0 ;; clock_id = REALTIME (0) +// i64.const 0 ;; precision = 0 +// i32.const 0 ;; out_ptr = 0 +// call $clock_time_get +// drop) +// (export "_start" (func $_start))) +// +// Reference: https://webassembly.github.io/spec/core/binary/modules.html +var walltimeProbeWasm = []byte{ + // Magic + version + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, + + // Type section (id=1) — body=11 bytes + 0x01, + 0x0b, // size = 11 + 0x02, // 2 types + 0x60, 0x03, 0x7f, 0x7e, 0x7f, // type 0: func(i32, i64, i32) + 0x01, 0x7f, // -> (i32) + 0x60, 0x00, 0x00, // type 1: func() -> () + + // Import section (id=2) — body=0x29 (41 bytes) + 0x02, + 0x29, + 0x01, // 1 import + 0x16, // module name "wasi_snapshot_preview1" length=22 + 0x77, 0x61, 0x73, 0x69, 0x5f, 0x73, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x5f, 0x70, 0x72, 0x65, 0x76, 0x69, 0x65, 0x77, 0x31, + 0x0e, // fn name "clock_time_get" length=14 + 0x63, 0x6c, 0x6f, 0x63, 0x6b, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x5f, 0x67, 0x65, 0x74, + 0x00, 0x00, // kind=func, type idx=0 + + // Function section (id=3) — body=2 bytes + 0x03, + 0x02, + 0x01, // 1 function + 0x01, // type idx 1 (for _start) + + // Memory section (id=5) — body=3 bytes + 0x05, + 0x03, + 0x01, // 1 memory + 0x00, 0x01, // limits: flags=0 (no max), min=1 page + + // Export section (id=7) — body=19 bytes (0x13) + // = count(1) + memory_export(9) + start_export(9) = 19 + 0x07, + 0x13, + 0x02, // 2 exports + 0x06, 0x6d, 0x65, 0x6d, 0x6f, 0x72, 0x79, // "memory" + 0x02, 0x00, // kind=memory, idx=0 + 0x06, 0x5f, 0x73, 0x74, 0x61, 0x72, 0x74, // "_start" + 0x00, 0x01, // kind=func, idx=1 (after the 1 import) + + // Code section (id=10) — body=13 bytes (0x0d) + // = count(1) + body_size_byte(1) + body(11) = 13 + 0x0a, + 0x0d, + 0x01, // 1 function body + 0x0b, // body size = 11 (locals_count + 10 instr bytes) + 0x00, // 0 local groups + 0x41, 0x00, // i32.const 0 (clock_id) + 0x42, 0x00, // i64.const 0 (precision) + 0x41, 0x00, // i32.const 0 (out_ptr) + 0x10, 0x00, // call func 0 (the imported clock_time_get) + 0x1a, // drop (errno return) + 0x0b, // end +} + +func TestModuleConfig_walltimeIsRealNotFrozenSentinel(t *testing.T) { + ctx := context.Background() + runtime := wazero.NewRuntime(ctx) + defer runtime.Close(ctx) + + if _, err := wasi_snapshot_preview1.Instantiate(ctx, runtime); err != nil { + t.Fatalf("instantiate WASI: %v", err) + } + + compiled, err := runtime.CompileModule(ctx, walltimeProbeWasm) + if err != nil { + t.Fatalf("compile probe wasm: %v (hex assembly likely off; recompute section sizes)", err) + } + defer compiled.Close(ctx) + + // Mirror the executor.go moduleConfig — the assertion is that this + // SAME config is what protects against the bug-#27 regression. + moduleConfig := wazero.NewModuleConfig(). + WithName(""). + WithArgs("probe"). + WithSysWalltime(). + WithSysNanotime() + + mod, err := runtime.InstantiateModule(ctx, compiled, moduleConfig) + if err != nil { + t.Fatalf("instantiate probe module: %v", err) + } + defer mod.Close(ctx) + + mem := mod.Memory() + if mem == nil { + t.Fatal("probe module has no memory export") + } + raw, ok := mem.Read(0, 8) + if !ok { + t.Fatal("could not read 8 bytes from probe memory at offset 0") + } + gotNs := binary.LittleEndian.Uint64(raw) + + // 2024-01-01T00:00:00 in unix nanoseconds = 1704067200000000000. + // Any real time after that passes. The sentinel ~2022-01-01 fails. + const cutoff2024Ns uint64 = 1704067200000000000 + if gotNs < cutoff2024Ns { + gotTime := time.Unix(0, int64(gotNs)) + t.Errorf("BUG #27 REGRESSION: WASI clock_time_get returned %d ns (%s) — "+ + "pre-2024 means the fake/sentinel clock is in effect. "+ + "Did the .WithSysWalltime() call get dropped from moduleConfig "+ + "in executor.go or engine.go?", gotNs, gotTime) + } +} + +func TestModuleConfig_walltimeWithoutFix_demoSentinel(t *testing.T) { + // Negative control: WITHOUT .WithSysWalltime(), confirm wazero + // returns the frozen sentinel. This pins the *cause* (so anyone + // reading the test understands why the positive test is meaningful). + // If wazero ever changes its default to a real clock, this test + // fails — at which point the bug is moot and both tests can be + // retired. Pinning the negative makes that change visible instead + // of silently invalidating the fix's necessity. + ctx := context.Background() + runtime := wazero.NewRuntime(ctx) + defer runtime.Close(ctx) + + if _, err := wasi_snapshot_preview1.Instantiate(ctx, runtime); err != nil { + t.Fatalf("instantiate WASI: %v", err) + } + compiled, err := runtime.CompileModule(ctx, walltimeProbeWasm) + if err != nil { + t.Fatalf("compile probe wasm: %v", err) + } + defer compiled.Close(ctx) + + // Default config — NO WithSysWalltime. + defaultConfig := wazero.NewModuleConfig().WithName("").WithArgs("probe") + mod, err := runtime.InstantiateModule(ctx, compiled, defaultConfig) + if err != nil { + t.Fatalf("instantiate probe module: %v", err) + } + defer mod.Close(ctx) + + raw, _ := mod.Memory().Read(0, 8) + gotNs := binary.LittleEndian.Uint64(raw) + + const cutoff2024Ns uint64 = 1704067200000000000 + if gotNs >= cutoff2024Ns { + t.Logf("wazero default walltime is now %d ns (%s) — past 2024. "+ + "If this happened upstream-by-default, the bug-#27 fix is no longer "+ + "necessary and the .WithSysWalltime() opt-in can be retired.", + gotNs, time.Unix(0, int64(gotNs))) + t.Skip("wazero default walltime is real time — bug-#27 fix may be redundant; review") + } + // Sentinel confirmed → fix is meaningful. +}