An operator spent three hours setting up, accepted the update, and reopened a
program that had forgotten everything. It is the second such report.
The updater is not the culprit — it touches its own exe and nothing else. The
pointer is. config.json is the only record of WHERE the database is, and it was
written with os.WriteFile: truncate, then fill. A process that stops between
those two steps — a crash, a power cut, an update's watchdog force-exiting the
old instance — leaves the file empty. readBootstrap then swallowed the parse
error, returned an empty pointer, and startup read that as "no database chosen":
it created a NEW one at the default path and opened it. Three hours of work
still on disk, and an application presenting itself as freshly installed.
Three changes, in the order they defend:
- The write is atomic. A temporary file, fsync'd, renamed into place — and a
rename within a volume cannot publish half a file. The previous contents are
kept as config.json.bak, because a pointer is a few dozen bytes and an
evening of configuration is not.
- A pointer that EXISTS and cannot be read is no longer treated as no pointer.
It is restored from the backup, and when there is nothing to restore from
the broken file is KEPT as config.json.broken — it is evidence, and it may
still be readable by hand.
- Creating a new, empty settings database in a folder that already holds a
full one is now said out loud, in the startup log and on screen. Nothing is
deleted and nothing is guessed — guessing which file is theirs is how the
wrong one gets opened — but the message names the other file, which is where
their settings still are.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
119 lines
4.3 KiB
Go
119 lines
4.3 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
// config.json is the only record of WHERE the database is. Losing it moves an
|
|
// operator's whole station back to an empty default, and it has happened twice.
|
|
// These are the two ways it was lost.
|
|
|
|
// A half-written file must never be publishable. os.WriteFile truncates and
|
|
// then fills, so a process that stops in between leaves an empty pointer; the
|
|
// rename cannot.
|
|
func TestWriteBootstrapIsAtomicAndKeepsABackup(t *testing.T) {
|
|
dir := t.TempDir()
|
|
// Absolute and OUTSIDE the application folder, so portablePath stores them
|
|
// verbatim — the round trip is what is under test, not the re-rooting.
|
|
first := filepath.Join(t.TempDir(), "first", "one.db")
|
|
second := filepath.Join(t.TempDir(), "second", "two.db")
|
|
if err := writeBootstrap(dir, dbPointer{DBPath: first}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := writeBootstrap(dir, dbPointer{DBPath: second}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// No temporary left lying around to be mistaken for the real thing.
|
|
if _, err := os.Stat(dbPointerPath(dir) + ".tmp"); err == nil {
|
|
t.Error("the temporary file was left behind")
|
|
}
|
|
// The previous contents are still there.
|
|
var prev dbPointer
|
|
b, err := os.ReadFile(dbPointerPath(dir) + ".bak")
|
|
if err != nil {
|
|
t.Fatalf("no backup was kept: %v", err)
|
|
}
|
|
if err := json.Unmarshal(b, &prev); err != nil {
|
|
t.Fatalf("the backup does not parse: %v", err)
|
|
}
|
|
if prev.DBPath != first {
|
|
t.Errorf("the backup holds %q, want the previous pointer", prev.DBPath)
|
|
}
|
|
if got := readBootstrap(dir); got.DBPath != second {
|
|
t.Errorf("read back %q, want the current pointer", got.DBPath)
|
|
}
|
|
}
|
|
|
|
// A pointer that EXISTS and cannot be read is not the same thing as no pointer.
|
|
// Treating it as one is what opened an empty database and presented an operator
|
|
// with a program that had forgotten them.
|
|
func TestReadBootstrapRecoversFromABrokenPointer(t *testing.T) {
|
|
dir := t.TempDir()
|
|
mine := filepath.Join(t.TempDir(), "mine", "station.db")
|
|
if err := writeBootstrap(dir, dbPointer{DBPath: mine}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Two writes, so there is a backup of the good one to fall back to.
|
|
if err := writeBootstrap(dir, dbPointer{DBPath: mine}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Now truncate it, exactly as an interrupted write would.
|
|
if err := os.WriteFile(dbPointerPath(dir), nil, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := readBootstrap(dir)
|
|
if got.DBPath != mine {
|
|
t.Fatalf("read %q — the database the operator chose was lost", got.DBPath)
|
|
}
|
|
// And it is put back, so the next launch does not have to recover again.
|
|
if again := readBootstrap(dir); again.DBPath != mine {
|
|
t.Errorf("the restored pointer did not stick: %q", again.DBPath)
|
|
}
|
|
}
|
|
|
|
// With nothing to restore from, the broken file is KEPT. It is evidence, and it
|
|
// may still be readable by hand.
|
|
func TestReadBootstrapKeepsAnUnrecoverablePointer(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if err := os.WriteFile(dbPointerPath(dir), []byte("{oops"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := readBootstrap(dir); got.DBPath != "" {
|
|
t.Errorf("invented a path out of a broken file: %q", got.DBPath)
|
|
}
|
|
if _, err := os.Stat(dbPointerPath(dir) + ".broken"); err != nil {
|
|
t.Errorf("the broken pointer was not kept: %v", err)
|
|
}
|
|
}
|
|
|
|
// The warning that turns a silent loss into a sentence: a new empty database
|
|
// about to be created in a folder that already holds a full one.
|
|
func TestOtherDatabasesInSpotsTheFullOneNextDoor(t *testing.T) {
|
|
dir := t.TempDir()
|
|
full := filepath.Join(dir, "opslog.db")
|
|
if err := os.WriteFile(full, []byte("not empty"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
chosen := filepath.Join(dir, "settings.db")
|
|
if got := otherDatabasesIn(dir, chosen); len(got) != 1 || got[0] != "opslog.db" {
|
|
t.Errorf("got %v, want the full database next door", got)
|
|
}
|
|
// A zero-byte file is not a lost configuration.
|
|
if err := os.WriteFile(full, nil, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := otherDatabasesIn(dir, chosen); len(got) != 0 {
|
|
t.Errorf("an empty file was reported as a database: %v", got)
|
|
}
|
|
// And the one being opened is never reported against itself.
|
|
if err := os.WriteFile(chosen, []byte("in use"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := otherDatabasesIn(dir, chosen); len(got) != 0 {
|
|
t.Errorf("the chosen database was reported as another: %v", got)
|
|
}
|
|
}
|