fix(db): stop losing the database pointer, and never lose it silently

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]>
This commit is contained in:
2026-09-10 00:14:08 +02:00
co-authored by Claude Opus 5
parent e2fe406445
commit b0a973d390
5 changed files with 247 additions and 4 deletions
+118 -4
View File
@@ -1,6 +1,7 @@
package main
import (
"bytes"
"context"
"database/sql"
"encoding/json"
@@ -950,6 +951,7 @@ type App struct {
pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted
pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle
pttGen int64 // bumped on every key; a delayed unkey only fires if unchanged (guards against a stale release cutting a new transmission)
startupWarn string // a non-fatal warning worth putting in front of the operator at launch
startupErr string // captured for surfacing to the frontend
settingsScoped atomic.Bool // true once a.settings is scoped to the active profile — GetUIPref/SetUIPref (per-profile) must wait for it, else an early call reads the wrong scope and e.g. resets the theme
dbPath string // settings/config database file (settings + profiles); may be a user-chosen location
@@ -1194,6 +1196,25 @@ func (a *App) startup(ctx context.Context) {
boot.DeletePending = ""
_ = writeBootstrap(dataDir, boot)
}
// About to create a database in a folder that already has one.
//
// This is the shape of every "everything I set up is gone" report: the
// pointer was lost or the default moved, OpsLog opened a NEW empty database
// beside the full one, and the operator was shown a program that had
// forgotten them. Nothing is changed here — guessing which file is theirs is
// how the wrong one gets picked — but it is said loudly, in the startup log
// and on screen, while the old file is still sitting there untouched.
if !fileExists(a.dbPath) {
if others := otherDatabasesIn(dataDir, a.dbPath); len(others) > 0 {
msg := fmt.Sprintf("OpsLog is starting a NEW, EMPTY settings database (%s) although this folder already holds %s. "+
"Nothing has been deleted. If your settings have gone, that other file is where they are: "+
"Settings ▸ Database ▸ open an existing database, and pick it.",
filepath.Base(a.dbPath), strings.Join(others, ", "))
bootLog("%s", msg)
fmt.Println("OpsLog:", msg)
a.startupWarn = msg
}
}
if err := os.MkdirAll(filepath.Dir(a.dbPath), 0o755); err != nil {
a.startupErr = "cannot create db folder: " + err.Error()
fmt.Println("OpsLog:", a.startupErr)
@@ -1741,6 +1762,10 @@ type StartupStatus struct {
OK bool `json:"ok"`
Err string `json:"err"`
DBPath string `json:"db_path"`
// Warn is not a failure and must not be swallowed: OpsLog started, and
// something about WHERE it started is worth an operator seeing before they
// conclude their configuration has been thrown away.
Warn string `json:"warn"`
}
// GetStartupStatus exposes whatever happened during startup so the UI
@@ -1771,6 +1796,7 @@ func (a *App) GetStartupStatus() StartupStatus {
OK: a.startupErr == "",
Err: a.startupErr,
DBPath: a.dbPath,
Warn: a.startupWarn,
}
}
@@ -2405,11 +2431,37 @@ func overlapsEnough(x, y, w, h, vx, vy, vw, vh int) bool {
// value if the file is missing/unreadable.
func readBootstrap(dataDir string) dbPointer {
var c dbPointer
b, err := os.ReadFile(dbPointerPath(dataDir))
path := dbPointerPath(dataDir)
b, err := os.ReadFile(path)
if err != nil {
// No pointer at all: a first run, or a folder that never had one. The
// caller falls back to this folder's own default, which is correct.
return c
}
_ = json.Unmarshal(b, &c)
if uerr := json.Unmarshal(b, &c); uerr != nil || len(bytes.TrimSpace(b)) == 0 {
// A pointer that EXISTS and cannot be read is not the same thing as no
// pointer, and treating it as one is how an operator loses an evening:
// the database moves back to this folder's default, the default is
// empty, and the program opens having forgotten everything. Reported
// twice, both times just after an update.
//
// The previous contents are kept beside it for exactly this, so the
// answer is usually one file away.
bootLog("config.json is unreadable (%v, %d bytes) — trying the backup", uerr, len(b))
var prev dbPointer
if bk, berr := os.ReadFile(path + ".bak"); berr == nil && json.Unmarshal(bk, &prev) == nil {
bootLog("config.json restored from its backup (database %q)", prev.DBPath)
c = prev
// Put it back, so the next launch does not have to do this again.
_ = os.WriteFile(path, bk, 0o644)
} else {
// Nothing to restore from. Keep the broken file rather than
// overwriting it — it is evidence, and it may still be readable by
// hand.
_ = os.Rename(path, path+".broken")
bootLog("no usable backup — the broken config.json was kept as config.json.broken")
}
}
// Stored relative when it lives inside the app folder, so the pointer follows
// the folder from C:OpsLog to D:OpsLog or to a stick.
c.DBPath = resolvePath(dataDir, c.DBPath)
@@ -2417,11 +2469,51 @@ func readBootstrap(dataDir string) dbPointer {
return c
}
// writeBootstrap saves the pointer ATOMICALLY, and keeps the previous one.
//
// os.WriteFile truncates the file and then fills it, so a process that stops
// in between — a crash, a power cut, an update's watchdog force-exiting the old
// instance — leaves an empty or half-written config.json. That file is the only
// record of WHERE the database is, and losing it moved an operator's whole
// station back to an empty default. It has happened twice.
//
// A temporary file renamed into place cannot do that: on Windows and on Unix
// alike the rename is atomic within a volume, so config.json is either entirely
// the old contents or entirely the new. The previous contents are kept as a
// .bak because a pointer is a few dozen bytes and an evening of configuration
// is not.
func writeBootstrap(dataDir string, c dbPointer) error {
c.DBPath = portablePath(c.DBPath)
c.DeletePending = portablePath(c.DeletePending)
b, _ := json.MarshalIndent(c, "", " ")
return os.WriteFile(dbPointerPath(dataDir), b, 0o644)
b, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
path := dbPointerPath(dataDir)
if old, rerr := os.ReadFile(path); rerr == nil && len(bytes.TrimSpace(old)) > 0 {
_ = os.WriteFile(path+".bak", old, 0o644)
}
tmp := path + ".tmp"
f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
return err
}
if _, err := f.Write(b); err != nil {
f.Close()
_ = os.Remove(tmp)
return err
}
// On disk before the rename, or the rename can publish an empty file.
if err := f.Sync(); err != nil {
f.Close()
_ = os.Remove(tmp)
return err
}
if err := f.Close(); err != nil {
_ = os.Remove(tmp)
return err
}
return os.Rename(tmp, path)
}
// readDBPointer returns the user-chosen DB path, or "" for the default.
@@ -22560,3 +22652,25 @@ func absInt(v int) int {
}
return v
}
// otherDatabasesIn lists the settings databases sitting in the data folder that
// are NOT the one about to be opened.
//
// Only the two names OpsLog itself ever uses, and only files with something in
// them: a stray .db from another program is not evidence, and a zero-byte file
// is not a lost configuration. The point is to recognise the one situation that
// matters — a full database next to a new empty one — and to say so before the
// operator concludes their evening is gone.
func otherDatabasesIn(dataDir, chosen string) []string {
var out []string
for _, name := range []string{"settings.db", "opslog.db"} {
p := filepath.Join(dataDir, name)
if p == chosen {
continue
}
if fi, err := os.Stat(p); err == nil && fi.Size() > 0 {
out = append(out, name)
}
}
return out
}