mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
re-evaluates each trace offline under the full engine, a crash-only detector, a single-state check and a single-step property triple, and reports what each refutes: the oracles vary while the traces stay fixed, which separates a defect an oracle cannot express from one an explorer never reached. a disagreement with the verdicts a run recorded exits nonzero rather than being counted as a finding.
224 lines
6.3 KiB
Go
224 lines
6.3 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
|
|
"github.com/priyanshujain/sanderling/internal/trace"
|
|
"github.com/priyanshujain/sanderling/internal/verifier"
|
|
)
|
|
|
|
const maxStepBytes = 64 * 1024 * 1024
|
|
|
|
// loadedRun is one run directory: its meta, the steps in file order, and the
|
|
// synthetic end-of-run record the runner writes when Finalize convicted a
|
|
// liveness obligation.
|
|
type loadedRun struct {
|
|
Directory string
|
|
Meta trace.Meta
|
|
Steps []trace.Step
|
|
Finalize *trace.Step
|
|
}
|
|
|
|
// loadRun reads a run directory and refuses anything E3 cannot replay. A step
|
|
// written before the format change carries no element depths, so its hierarchy
|
|
// decodes with a nil root and every selector resolves to nothing: the refusal
|
|
// has to name the version rather than let the replay report an empty screen.
|
|
func loadRun(directory string) (loadedRun, error) {
|
|
metaBody, err := os.ReadFile(filepath.Join(directory, "meta.json"))
|
|
if err != nil {
|
|
return loadedRun{}, fmt.Errorf("read meta: %w", err)
|
|
}
|
|
var meta trace.Meta
|
|
if err := json.Unmarshal(metaBody, &meta); err != nil {
|
|
return loadedRun{}, fmt.Errorf("decode meta: %w", err)
|
|
}
|
|
|
|
file, err := os.Open(filepath.Join(directory, "trace.jsonl"))
|
|
if err != nil {
|
|
return loadedRun{}, fmt.Errorf("open trace: %w", err)
|
|
}
|
|
defer file.Close()
|
|
|
|
run := loadedRun{Directory: directory, Meta: meta}
|
|
scanner := bufio.NewScanner(file)
|
|
scanner.Buffer(make([]byte, 0, 1024*1024), maxStepBytes)
|
|
line := 0
|
|
for scanner.Scan() {
|
|
line++
|
|
if len(scanner.Bytes()) == 0 {
|
|
continue
|
|
}
|
|
var step trace.Step
|
|
if err := json.Unmarshal(scanner.Bytes(), &step); err != nil {
|
|
return loadedRun{}, fmt.Errorf(
|
|
"decode step on line %d: %w",
|
|
line,
|
|
err,
|
|
)
|
|
}
|
|
if step.TraceVersion != trace.TraceVersion {
|
|
return loadedRun{}, fmt.Errorf(
|
|
"step %d is trace_version %d, and E3 replays version %d only: "+
|
|
"an older step stores no element depths, so its hierarchy decodes "+
|
|
"with a nil root and every selector resolves to nothing on replay",
|
|
step.Index, step.TraceVersion, trace.TraceVersion)
|
|
}
|
|
run.Steps = append(run.Steps, step)
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
return loadedRun{}, fmt.Errorf("read trace: %w", err)
|
|
}
|
|
if len(run.Steps) == 0 {
|
|
return loadedRun{}, fmt.Errorf("trace has no steps")
|
|
}
|
|
if last := run.Steps[len(run.Steps)-1]; len(run.Steps) > 1 &&
|
|
last.Hierarchy == nil &&
|
|
len(last.Violations) > 0 {
|
|
run.Finalize = &last
|
|
run.Steps = run.Steps[:len(run.Steps)-1]
|
|
}
|
|
if meta.Seed == 0 {
|
|
return loadedRun{}, fmt.Errorf(
|
|
"meta records no seed, so the run's bundle cannot be reproduced",
|
|
)
|
|
}
|
|
return run, nil
|
|
}
|
|
|
|
// finalizeIndex is the step index the runner gives its end-of-run record: one
|
|
// past the last step it wrote.
|
|
func (r loadedRun) finalizeIndex() int {
|
|
return r.Steps[len(r.Steps)-1].Index + 1
|
|
}
|
|
|
|
// extractorFold reconstructs every extractor's value at each step from the
|
|
// recorded per-step diffs. A run's trace stores what changed, so the value an
|
|
// extractor held at a step is the last change at or before it; an extractor
|
|
// that never changed held JSON null throughout, which is what an unwritten
|
|
// diff means.
|
|
func extractorFold(
|
|
steps []trace.Step,
|
|
names []string,
|
|
) ([]map[int]json.RawMessage, error) {
|
|
index := make(map[string]int, len(names))
|
|
for position, name := range names {
|
|
index[name] = position
|
|
}
|
|
current := make(map[int]json.RawMessage, len(names))
|
|
for position := range names {
|
|
current[position] = json.RawMessage("null")
|
|
}
|
|
folded := make([]map[int]json.RawMessage, len(steps))
|
|
for step := range steps {
|
|
for name, change := range steps[step].ExtractorChanges {
|
|
position, ok := index[name]
|
|
if !ok {
|
|
return nil, fmt.Errorf(
|
|
"step %d records extractor %q, which the spec does not register; "+
|
|
"the trace and the spec are not the same bundle",
|
|
steps[step].Index,
|
|
name,
|
|
)
|
|
}
|
|
current[position] = change.Curr
|
|
}
|
|
snapshot := make(map[int]json.RawMessage, len(current))
|
|
for position, value := range current {
|
|
snapshot[position] = value
|
|
}
|
|
folded[step] = snapshot
|
|
}
|
|
return folded, nil
|
|
}
|
|
|
|
// lastActionFor rebuilds the action the runner had applied before the next
|
|
// step observed. An action the runner chose but never dispatched left
|
|
// state.lastAction null, and the recorded skip reason is what says so.
|
|
func lastActionFor(step trace.Step) *verifier.Action {
|
|
if step.NextAction == nil || step.ActionSkipped != "" {
|
|
return nil
|
|
}
|
|
recorded := *step.NextAction
|
|
action := verifier.Action{
|
|
Kind: verifier.ActionKind(recorded.Kind),
|
|
On: recorded.Selector,
|
|
Text: recorded.Text,
|
|
X: recorded.X,
|
|
Y: recorded.Y,
|
|
FromX: recorded.FromX,
|
|
FromY: recorded.FromY,
|
|
ToX: recorded.ToX,
|
|
ToY: recorded.ToY,
|
|
Key: recorded.Key,
|
|
DurationMillis: recorded.DurationMillis,
|
|
}
|
|
return &action
|
|
}
|
|
|
|
func traceLogs(entries []trace.LogEntry) []verifier.LogEntry {
|
|
if len(entries) == 0 {
|
|
return nil
|
|
}
|
|
logs := make([]verifier.LogEntry, 0, len(entries))
|
|
for _, entry := range entries {
|
|
logs = append(logs, verifier.LogEntry{
|
|
UnixMillis: entry.UnixMillis,
|
|
Level: entry.Level,
|
|
Tag: entry.Tag,
|
|
Message: entry.Message,
|
|
})
|
|
}
|
|
return logs
|
|
}
|
|
|
|
func traceExceptions(entries []trace.Exception) []verifier.Exception {
|
|
if len(entries) == 0 {
|
|
return nil
|
|
}
|
|
exceptions := make([]verifier.Exception, 0, len(entries))
|
|
for _, entry := range entries {
|
|
exceptions = append(exceptions, verifier.Exception{
|
|
Class: entry.Class,
|
|
Message: entry.Message,
|
|
StackTrace: entry.StackTrace,
|
|
UnixMillis: entry.UnixMillis,
|
|
})
|
|
}
|
|
return exceptions
|
|
}
|
|
|
|
// discoverRuns finds every run directory at or below root, a run directory
|
|
// being one holding both meta.json and trace.jsonl.
|
|
func discoverRuns(root string) ([]string, error) {
|
|
var directories []string
|
|
err := filepath.Walk(
|
|
root,
|
|
func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !info.IsDir() {
|
|
return nil
|
|
}
|
|
if _, statErr := os.Stat(filepath.Join(path, "trace.jsonl")); statErr != nil {
|
|
return nil
|
|
}
|
|
if _, statErr := os.Stat(filepath.Join(path, "meta.json")); statErr != nil {
|
|
return nil
|
|
}
|
|
directories = append(directories, path)
|
|
return nil
|
|
},
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sort.Strings(directories)
|
|
return directories, nil
|
|
}
|