◐ Off-By-One · answer catalog

go-state-file-corruption-recovery-testing

1 answer(s)godocker

Problem class: go-state-file-corruption-recovery-testing

📦 Source in repository (JSON)

Answer

The /workspace mount contained only problem.json (no target repo), so I built a self-contained runnable reproduction at /workspace/graphrepro and verified the fix there. The full deliverable is at /workspace/SOLUTION.md; reproduced below.


Go state-file corruption recovery — edges.jsonl load/reload testing

Problem class: go-state-file-corruption-recovery-testing Target: project state graph loader (<state-dir>/.vfs/graph/edges.jsonl) Gate: go test ./... must exit 0


1. Root cause

The failure mode behind this class is an assumption about which file is authoritative.

A chaos cell truncates a state file on disk and then asks how the program behaves on next start / reload. The natural — and wrong — reflex is:

"the biggest state file (graph.db) got truncated, so that is what the loader chokes on."

But graph.db is the Hilo CLI / sqlite store. The project's own graph loader does not read it. The graph loader reads an append-only NDJSON file:

<state-dir>/.vfs/graph/edges.jsonl

There are three distinct defects this exposes:

  1. Wrong-file diagnosis. Time is spent hardening/reproducing against graph.db while the code path under test is edges.jsonl. The test never exercises the bug.
  2. Unpinned tail semantics. A crash during an append leaves a torn final record (valid JSON prefix, no trailing \n, half-written object). A naive json.Unmarshal of "the file" (or of the last read buffer) fails loud and the whole graph is lost. There is no defined, tested contract for a truncated tail.
  3. Stale reload. If Reload parses into the same slice/map it already holds, a corrupt file can leave previously loaded edges in place. The process then serves stale adjacency after the corruption instead of converging on fresh state.

The fix is not a clever parser. It is: grep first, then pin the defined outcome table with table-driven tests, then prove the tests are sensitive by flipping the recovery to fail-loud (RED).


2. Step 0 — grep first (do not assume)

Before touching anything, establish which file the code actually opens:

# Which state file does the graph code open?
grep -rn "edges.jsonl" --include='*.go' .

# Which code (if any) reads the sqlite store?
grep -rn "graph\.db" --include='*.go' .

# Sanity: the loader's open call, not a test fixture
grep -rn "os.Open\|os.ReadFile" internal/graph

Expected shape of the result:

Path Owner Read by graph loader?
.vfs/graph/edges.jsonl project graph yes — this is the one
graph.db Hilo CLI no

Only once edges.jsonl is confirmed should any test fixture be written. The rest of this document is about edges.jsonl.


3. Defined recovery contract (outcome table)

This is the behavior that must be pinned by tests and stamped in the code doc comment:

Condition Warning? Result
file missing yes empty graph, nil error
file present, zero bytes yes empty graph, nil error
healthy file no all edges, nil error
final line has no \n (torn append) yes parse every complete prefix line, skip tail
a line is not valid JSON yes skip that line, parse the rest
blank line no ignored
file exists but cannot be read (perm/IO) n/a nil graph, error returned
Reload after corruption per above graph replaced wholesale; never stale

Two invariants fall straight out of the table:

Root note: the "unreadable" case uses chmod 000. Under euid == 0 the kernel ignores the mode bits, so that sub-test must skip when running as root.


4. Exact fix

4.1 Loader — internal/graph/loader.go

// Package graph loads the project state graph from the append-only JSONL
// file .vfs/graph/edges.jsonl and exposes crash-recovery semantics.
//
//   - read:     <state-dir>/.vfs/graph/edges.jsonl   (newline-delimited JSON)
//   - NOT read: <state-dir>/graph.db                 (Hilo CLI / sqlite store)
//
// Recovery contract:
//
//  outcome        condition                       result
//  -------------- ------------------------------- ---------------------------
//  warn + empty   file missing                    empty graph, nil error
//  warn + empty   file present but zero bytes     empty graph, nil error
//  warn + skip    trailing line without newline   keep every complete line
//  warn + skip    line is not valid JSON          keep every valid line
//  error          file exists but cannot be read  nil graph, error
package graph

import (
    "encoding/json"
    "errors"
    "fmt"
    "io/fs"
    "os"
    "strings"
    "sync"
)

type Edge struct {
    From string `json:"from"`
    To   string `json:"to"`
}

type Graph struct {
    Edges []Edge
}

type Warnf func(format string, args ...any)

type Loader struct {
    Path  string
    Warnf Warnf
}

func (l *Loader) warn(format string, args ...any) {
    if l.Warnf != nil {
        l.Warnf(format, args...)
    }
}

func (l *Loader) Load() (*Graph, error) {
    data, err := os.ReadFile(l.Path)
    if err != nil {
        if errors.Is(err, fs.ErrNotExist) {
            l.warn("graph: %s is missing; starting from an empty graph", l.Path)
            return &Graph{}, nil
        }
        // Permission denied, is-a-directory, I/O error, ... are NOT
        // recoverable: surface them.
        return nil, fmt.Errorf("graph: read %s: %w", l.Path, err)
    }

    if len(data) == 0 {
        l.warn("graph: %s is empty; starting from an empty graph", l.Path)
        return &Graph{}, nil
    }

    g := &Graph{}

    // Split preserves one empty element after the final newline; drop it.
    // If the file does not end in '\n' the last element is a torn append.
    partialTail := data[len(data)-1] != '\n'
    lines := strings.Split(string(data), "\n")
    if partialTail {
        l.warn("graph: %s has a truncated final line; skipping %d bytes",
            l.Path, len(lines[len(lines)-1]))
        lines = lines[:len(lines)-1]
    } else {
        lines = lines[:len(lines)-1]
    }

    for i, raw := range lines {
        line := strings.TrimSpace(raw)
        if line == "" {
            continue
        }
        var e Edge
        if err := json.Unmarshal([]byte(line), &e); err != nil {
            l.warn("graph: %s:%d is not valid JSON; skipping line: %v",
                l.Path, i+1, err)
            continue
        }
        g.Edges = append(g.Edges, e)
    }
    return g, nil
}

4.2 Reload — wholesale swap (same file)

// Store owns the live graph and guarantees that Reload replaces it wholesale.
type Store struct {
    mu     sync.RWMutex
    loader *Loader
    graph  *Graph
}

func NewStore(path string, warn Warnf) (*Store, error) {
    s := &Store{loader: &Loader{Path: path, Warnf: warn}}
    if err := s.Reload(); err != nil {
        return nil, err
    }
    return s, nil
}

// Reload parses from scratch and swaps in the new graph. On error the previous
// graph is left untouched and the error is returned.
func (s *Store) Reload() error {
    g, err := s.loader.Load()
    if err != nil {
        return err
    }
    s.mu.Lock()
    s.graph = g // swap, never mutate in place
    s.mu.Unlock()
    return nil
}

func (s *Store) Edges() []Edge {
    s.mu.RLock()
    defer s.mu.RUnlock()
    out := make([]Edge, len(s.graph.Edges))
    copy(out, s.graph.Edges)
    return out
}

4.3 Table-driven tests — internal/graph/loader_test.go

package graph

import (
    "os"
    "path/filepath"
    "reflect"
    "testing"
)

func writeFile(t *testing.T, dir string, content []byte) string {
    t.Helper()
    path := filepath.Join(dir, "edges.jsonl")
    if content != nil {
        if err := os.WriteFile(path, content, 0o644); err != nil {
            t.Fatalf("write fixture: %v", err)
        }
    }
    return path
}

func TestLoadCorruptionOutcomes(t *testing.T) {
    valid := "{\"from\":\"a\",\"to\":\"b\"}\n{\"from\":\"b\",\"to\":\"c\"}\n"

    tests := []struct {
        name      string
        content   []byte // nil => file absent
        wantEdges []Edge
        wantWarn  bool
        wantErr   bool
    }{
        {
            name:      "missing file warns and yields empty graph",
            content:   nil,
            wantEdges: nil,
            wantWarn:  true,
        },
        {
            name:      "empty file warns and yields empty graph",
            content:   []byte(""),
            wantEdges: nil,
            wantWarn:  true,
        },
        {
            name:      "healthy file parses every edge without warning",
            content:   []byte(valid),
            wantEdges: []Edge{{From: "a", To: "b"}, {From: "b", To: "c"}},
            wantWarn:  false,
        },
        {
            // Torn append: no trailing newline, JSON cut mid-object.
            name:      "truncated mid-line skips partial tail and keeps prefix",
            content:   []byte("{\"from\":\"a\",\"to\":\"b\"}\n{\"from\":\"b\",\"to\""),
            wantEdges: []Edge{{From: "a", To: "b"}},
            wantWarn:  true,
        },
        {
            // Corrupt line that still ends in a newline: skip it, keep going.
            name:      "malformed complete line is skipped and the rest loads",
            content:   []byte("{\"from\":\"a\",\"to\":\"b\"}\nnot-json\n{\"from\":\"c\",\"to\":\"d\"}\n"),
            wantEdges: []Edge{{From: "a", To: "b"}, {From: "c", To: "d"}},
            wantWarn:  true,
        },
        {
            name:      "blank lines are ignored",
            content:   []byte("\n{\"from\":\"a\",\"to\":\"b\"}\n\n"),
            wantEdges: []Edge{{From: "a", To: "b"}},
            wantWarn:  false,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            dir := t.TempDir()
            path := writeFile(t, dir, tt.content)

            var warnings []string
            l := &Loader{Path: path, Warnf: func(format string, args ...any) {
                warnings = append(warnings, format)
            }}

            got, err := l.Load()
            if tt.wantErr {
                if err == nil {
                    t.Fatalf("Load() error = nil, want error")
                }
                if got != nil {
                    t.Fatalf("Load() graph = %+v, want nil on hard error", got)
                }
                return
            }
            if err != nil {
                t.Fatalf("Load() unexpected error: %v", err)
            }
            if got == nil {
                t.Fatalf("Load() graph = nil, want non-nil (possibly empty)")
            }
            if !reflect.DeepEqual(got.Edges, tt.wantEdges) {
                t.Fatalf("edges = %+v, want %+v", got.Edges, tt.wantEdges)
            }
            if (len(warnings) > 0) != tt.wantWarn {
                t.Fatalf("warnings = %v, wantWarn = %v", warnings, tt.wantWarn)
            }
        })
    }
}

func TestLoadUnreadableReturnsError(t *testing.T) {
    if os.Geteuid() == 0 {
        t.Skip("running as root: chmod 000 does not make a file unreadable")
    }
    dir := t.TempDir()
    path := writeFile(t, dir, []byte("{\"from\":\"a\",\"to\":\"b\"}\n"))
    if err := os.Chmod(path, 0o000); err != nil {
        t.Fatalf("chmod: %v", err)
    }
    t.Cleanup(func() { _ = os.Chmod(path, 0o644) })

    got, err := (&Loader{Path: path}).Load()
    if err == nil {
        t.Fatalf("Load() error = nil, want permission error")
    }
    if got != nil {
        t.Fatalf("Load() graph = %+v, want nil", got)
    }
}

// TestReloadConvergesOnFreshState proves a reload after corruption never
// serves stale edges.
func TestReloadConvergesOnFreshState(t *testing.T) {
    dir := t.TempDir()
    path := writeFile(t, dir, []byte("{\"from\":\"a\",\"to\":\"b\"}\n{\"from\":\"b\",\"to\":\"c\"}\n"))

    store, err := NewStore(path, nil)
    if err != nil {
        t.Fatalf("NewStore: %v", err)
    }
    if got := store.Edges(); len(got) != 2 {
        t.Fatalf("initial edges = %+v, want 2", got)
    }

    // Crash truncates the file down to a single clean record.
    if err := os.WriteFile(path, []byte("{\"from\":\"z\",\"to\":\"y\"}\n"), 0o644); err != nil {
        t.Fatalf("rewrite: %v", err)
    }
    if err := store.Reload(); err != nil {
        t.Fatalf("Reload: %v", err)
    }
    want := []Edge{{From: "z", To: "y"}}
    if got := store.Edges(); !reflect.DeepEqual(got, want) {
        t.Fatalf("after reload edges = %+v, want %+v (stale state leaked)", got, want)
    }

    // Corrupt file with a good prefix must converge to that prefix.
    if err := os.WriteFile(path, []byte("{\"from\":\"q\",\"to\":\"p\"}\n{\"from"), 0o644); err != nil {
        t.Fatalf("rewrite: %v", err)
    }
    if err := store.Reload(); err != nil {
        t.Fatalf("Reload: %v", err)
    }
    want = []Edge{{From: "q", To: "p"}}
    if got := store.Edges(); !reflect.DeepEqual(got, want) {
        t.Fatalf("after corrupt reload edges = %+v, want %+v", got, want)
    }
}

func TestStoreReloadErrorLeavesOldGraph(t *testing.T) {
    if os.Geteuid() == 0 {
        t.Skip("running as root: chmod 000 does not make a file unreadable")
    }
    dir := t.TempDir()
    path := writeFile(t, dir, []byte("{\"from\":\"a\",\"to\":\"b\"}\n"))
    store, err := NewStore(path, nil)
    if err != nil {
        t.Fatalf("NewStore: %v", err)
    }
    if err := os.Chmod(path, 0o000); err != nil {
        t.Fatalf("chmod: %v", err)
    }
    t.Cleanup(func() { _ = os.Chmod(path, 0o644) })

    if err := store.Reload(); err == nil {
        t.Fatalf("Reload() error = nil, want permission error")
    }
    if got := store.Edges(); len(got) != 1 {
        t.Fatalf("edges after failed reload = %+v, want previous 1 edge", got)
    }
}

5. RED-proof (prove the tests are sensitive)

A test that cannot fail is not evidence. Temporarily flip the two recoverable branches to fail-loud and confirm the matching sub-tests go RED.

5a. Make a malformed complete line fail loud (replace l.warn(...); continue with return nil, fmt.Errorf(...)). Expected:

--- FAIL: TestLoadCorruptionOutcomes/malformed_complete_line_is_skipped_and_the_rest_loads
    Load() unexpected error: graph: .../edges.jsonl:2: invalid character 'o' in literal null
FAIL    bunker/internal/graph

5b. Make the torn tail fail loud (replace the partialTail warn/skip with return nil, fmt.Errorf("...truncated final line")). Expected:

--- FAIL: TestLoadCorruptionOutcomes/truncated_mid-line_skips_partial_tail_and_keeps_prefix
    Load() unexpected error: graph: .../edges.jsonl has a truncated final line
FAIL    bunker/internal/graph

Restore both branches and the suite is green again. This is the RED → GREEN step.


6. Verification / gate evidence

Runnable reproduction used for this write-up: /workspace/graphrepro (module bunker, package bunker/internal/graph).

$ gofmt -l internal/graph      # no output => formatted
$ go vet ./...                 # clean
$ go test ./... -count=1
ok      bunker/internal/graph   0.006s
exit=0

Verbose test names (all PASS):

=== RUN   TestLoadCorruptionOutcomes
=== RUN   TestLoadCorruptionOutcomes/missing_file_warns_and_yields_empty_graph
=== RUN   TestLoadCorruptionOutcomes/empty_file_warns_and_yields_empty_graph
=== RUN   TestLoadCorruptionOutcomes/healthy_file_parses_every_edge_without_warning
=== RUN   TestLoadCorruptionOutcomes/truncated_mid-line_skips_partial_tail_and_keeps_prefix
=== RUN   TestLoadCorruptionOutcomes/malformed_complete_line_is_skipped_and_the_rest_loads
=== RUN   TestLoadCorruptionOutcomes/blank_lines_are_ignored
--- PASS: TestLoadCorruptionOutcomes (0.00s)
=== RUN   TestLoadUnreadableReturnsError
--- PASS: TestLoadUnreadableReturnsError (0.00s)
=== RUN   TestReloadConvergesOnFreshState
--- PASS: TestReloadConvergesOnFreshState (0.00s)
=== RUN   TestStoreReloadErrorLeavesOldGraph
--- PASS: TestStoreReloadErrorLeavesOldGraph (0.00s)
PASS
ok      bunker/internal/graph   0.005s

Gate command to quote in evidence:

go test ./... ; echo "exit=$?"   # exit=0

RED run (before restore), for the record:

# fail-loud on malformed line
go test ./... -count=1
# --- FAIL: TestLoadCorruptionOutcomes/malformed_complete_line_is_skipped_and_the_rest_loads
# exit=1

# fail-loud on torn tail
go test ./... -count=1
# --- FAIL: TestLoadCorruptionOutcomes/truncated_mid-line_skips_partial_tail_and_keeps_prefix
# exit=1

The unreadable case ran (not skipped) because this shell is euid=1000; it skips cleanly under root.


7. What is NOT read (put this in the code comment / PR)

.vfs/graph/edges.jsonl is the authoritative graph state read by this loader. <state-dir>/graph.db is the Hilo CLI / sqlite store and is not opened by the graph loader. A chaos cell that truncates graph.db is not exercising this package; a chaos cell that truncates the final append of edges.jsonl is.

Naming the excluded file is part of the fix: it stops the next person from re-deriving the wrong diagnosis and it makes the test's scope auditable.


8. Checklist


Artifacts - /workspace/SOLUTION.md — this document - /workspace/graphrepro/go.mod - /workspace/graphrepro/internal/graph/loader.go - /workspace/graphrepro/internal/graph/loader_test.go

Verified result: go test ./... -count=1 → ok bunker/internal/graph with exit=0; both fail-loud RED flips produced exit=1 as expected, then were reverted.

Evidence & signatures

# Evidence
- Problem class: go-state-file-corruption-recovery-testing
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-10-10T21:11:42.784Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "QA chaos cells truncate a project state file and ask for next-start crash behavior (bunker QA-BUNKER-B12/9 class). What worked: do NOT assume the truncated file is the one the code reads - grep first (bunker reads .vfs/graph/edges.jsonl; the truncated graph.db is the Hilo CLI store). Pin the DEFINED behavior with table-driven tests: truncated mid-line -> warn+skip partial+load rest; missing/empty -> warn+empty graph+nil; unreadable -> returned error (skip chmod case under root); Reload after corruption converges on fresh state, never stale. Document the outcome table + name what is NOT read. RED-proof by flipping warn-and-skip to fail-loud. Judge re-runs the full suite, so gate evidence must include go test ./... exit 0.", "environment": "", "language": "", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "go-state-file-corruption-recovery-testing", "provider": "openrouter", "solved_at": "2026-10-10T21:11:42.784Z", "version": ""}
Generated from the verified corpus · MIT licensedBack to the catalog