fix(verifier): don't crash on throwing JS predicates (#21)

* fix(verifier): don't crash on throwing JS predicates

formulaThunk used to panic whenever goja returned an error from a
predicate callable, and nothing on the LTL -> runner path recovered, so
a malformed spec (e.g. a property whose body throws or touches an
undefined field) would kill the verifier process.

Latch the first error on formulaState, return false so LTL marks the
property violated, and expose PredicateError(name) that walks the
property's formula-spec tree and surfaces the latched cause.

* fix(runner): log predicate errors alongside violations

For each violated property, surface the verifier's latched predicate
error via logger.Warn so operators can distinguish a genuine false
verdict from a malformed spec. Add a runner-level test asserting that a
throwing predicate no longer crashes the run and that the error message
appears in the log.
This commit is contained in:
pj authored and GitHub committed 2026-04-20 16:55:10 +07:00
1 parent a2e96af1af
commit 323878c34a
5 files changed
+113 -2

No files matched your search

+42 -1
View File
@@ -42,6 +42,10 @@ type harness struct {
}
func newHarness(t *testing.T, snapshots []map[string]json.RawMessage) *harness {
return newHarnessWithSpec(t, snapshots, fixtureSpec)
}
func newHarnessWithSpec(t *testing.T, snapshots []map[string]json.RawMessage, spec string) *harness {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
@@ -57,7 +61,7 @@ func newHarness(t *testing.T, snapshots []map[string]json.RawMessage) *harness {
if err != nil {
t.Fatal(err)
}
if err := verifierInstance.Load(fixtureSpec); err != nil {
if err := verifierInstance.Load(spec); err != nil {
t.Fatal(err)
}
state := &harness{
@@ -188,6 +192,43 @@ func TestRunner_ViolationSurfacesInSummary(t *testing.T) {
}
}
func TestRunner_ThrowingPredicateIsLoggedNotPanic(t *testing.T) {
const throwingSpec = `
globalThis.properties = {
broken: __uatu__.always(() => { throw new Error("bad predicate"); }),
};
globalThis.actions = __uatu__.actions(() => [__uatu__.tap({ on: "id:next" })]);
`
state := newHarnessWithSpec(t, []map[string]json.RawMessage{{}, {}}, throwingSpec)
state.startSDK(t)
state.acceptConnection(t)
var buffer bytes.Buffer
logger := slog.New(slog.NewTextHandler(&buffer, &slog.HandlerOptions{Level: slog.LevelWarn}))
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
summary, err := Run(ctx, Options{
Duration: 100 * time.Millisecond,
SnapshotTimeout: 2 * time.Second,
IdleTimeout: 50 * time.Millisecond,
Connection: state.conn,
Driver: state.mock,
Verifier: state.verifier,
TraceWriter: state.writer,
Logger: logger,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
if !containsProperty(summary.Violations, "broken") {
t.Errorf("expected broken in violations: %v", summary.Violations)
}
if !strings.Contains(buffer.String(), "bad predicate") {
t.Errorf("expected predicate error in log, got %q", buffer.String())
}
}
func TestRunner_RejectsMissingFields(t *testing.T) {
_, err := Run(context.Background(), Options{Duration: time.Second})
if err == nil || !strings.Contains(err.Error(), "Connection") {