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:
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -950,6 +951,7 @@ type App struct {
|
|||||||
pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted
|
pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted
|
||||||
pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle
|
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)
|
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
|
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
|
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
|
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 = ""
|
boot.DeletePending = ""
|
||||||
_ = writeBootstrap(dataDir, boot)
|
_ = 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 {
|
if err := os.MkdirAll(filepath.Dir(a.dbPath), 0o755); err != nil {
|
||||||
a.startupErr = "cannot create db folder: " + err.Error()
|
a.startupErr = "cannot create db folder: " + err.Error()
|
||||||
fmt.Println("OpsLog:", a.startupErr)
|
fmt.Println("OpsLog:", a.startupErr)
|
||||||
@@ -1741,6 +1762,10 @@ type StartupStatus struct {
|
|||||||
OK bool `json:"ok"`
|
OK bool `json:"ok"`
|
||||||
Err string `json:"err"`
|
Err string `json:"err"`
|
||||||
DBPath string `json:"db_path"`
|
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
|
// GetStartupStatus exposes whatever happened during startup so the UI
|
||||||
@@ -1771,6 +1796,7 @@ func (a *App) GetStartupStatus() StartupStatus {
|
|||||||
OK: a.startupErr == "",
|
OK: a.startupErr == "",
|
||||||
Err: a.startupErr,
|
Err: a.startupErr,
|
||||||
DBPath: a.dbPath,
|
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.
|
// value if the file is missing/unreadable.
|
||||||
func readBootstrap(dataDir string) dbPointer {
|
func readBootstrap(dataDir string) dbPointer {
|
||||||
var c dbPointer
|
var c dbPointer
|
||||||
b, err := os.ReadFile(dbPointerPath(dataDir))
|
path := dbPointerPath(dataDir)
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
if err != nil {
|
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
|
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
|
// 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.
|
// the folder from C:OpsLog to D:OpsLog or to a stick.
|
||||||
c.DBPath = resolvePath(dataDir, c.DBPath)
|
c.DBPath = resolvePath(dataDir, c.DBPath)
|
||||||
@@ -2417,11 +2469,51 @@ func readBootstrap(dataDir string) dbPointer {
|
|||||||
return c
|
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 {
|
func writeBootstrap(dataDir string, c dbPointer) error {
|
||||||
c.DBPath = portablePath(c.DBPath)
|
c.DBPath = portablePath(c.DBPath)
|
||||||
c.DeletePending = portablePath(c.DeletePending)
|
c.DeletePending = portablePath(c.DeletePending)
|
||||||
b, _ := json.MarshalIndent(c, "", " ")
|
b, err := json.MarshalIndent(c, "", " ")
|
||||||
return os.WriteFile(dbPointerPath(dataDir), b, 0o644)
|
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.
|
// readDBPointer returns the user-chosen DB path, or "" for the default.
|
||||||
@@ -22560,3 +22652,25 @@ func absInt(v int) int {
|
|||||||
}
|
}
|
||||||
return v
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
"version": "0.27.22",
|
"version": "0.27.22",
|
||||||
"date": "",
|
"date": "",
|
||||||
"en": [
|
"en": [
|
||||||
|
"If OpsLog is about to create a new, empty settings database in a folder that already holds a full one, it says so — in the startup log and on screen — instead of opening quietly as though nothing were configured. Nothing is deleted and nothing is guessed: the message names the other file, which is where your settings still are.",
|
||||||
|
"config.json — the one file that records WHERE your database is — is now written atomically, and a copy of the previous one is kept beside it. It was written by truncating the file and then filling it, so a process that stopped in between (a crash, a power cut, an update closing the old instance) left it empty; OpsLog then read \"no database chosen\", opened a new empty one, and started having forgotten everything. An unreadable config.json is now restored from its backup, and one that cannot be restored is KEPT as config.json.broken rather than silently replaced.",
|
||||||
"The antenna readout no longer flickers in and out during a pass. The rotator is asked where it is every three seconds, but the tracking status was rebuilt from scratch every second and dropped the answer in between — so the antenna appeared for one second in three, which reads as a rotator that keeps disconnecting.",
|
"The antenna readout no longer flickers in and out during a pass. The rotator is asked where it is every three seconds, but the tracking status was rebuilt from scratch every second and dropped the answer in between — so the antenna appeared for one second in three, which reads as a rotator that keeps disconnecting.",
|
||||||
"While tracking, the two frequencies and the antenna bearing sit beside the Tracking button. During a pass an operator watches the radio and the antenna, not a column on the far side of the window — and that column is the first thing hidden to get the map full width. The compass spins while the antenna is still slewing: a mast takes tens of seconds to cross a pass, and the difference between \"on its way\" and \"stuck\" is the whole reason to look at it.",
|
"While tracking, the two frequencies and the antenna bearing sit beside the Tracking button. During a pass an operator watches the radio and the antenna, not a column on the far side of the window — and that column is the first thing hidden to get the map full width. The compass spins while the antenna is still slewing: a mast takes tens of seconds to cross a pass, and the difference between \"on its way\" and \"stuck\" is the whole reason to look at it.",
|
||||||
"Satellite frequencies are shown to a hundred hertz instead of one. The Doppler moves about sixty hertz a second on 70 cm, so the last two digits changed every tick and the display was a blur of numbers nobody could read and nobody needed. The radio still gets the whole figure — this is only how much of it is worth putting in front of you. The shift beside it now reads \"+9.7 kHz\" rather than \"+9741 Hz\".",
|
"Satellite frequencies are shown to a hundred hertz instead of one. The Doppler moves about sixty hertz a second on 70 cm, so the last two digits changed every tick and the display was a blur of numbers nobody could read and nobody needed. The radio still gets the whole figure — this is only how much of it is worth putting in front of you. The shift beside it now reads \"+9.7 kHz\" rather than \"+9741 Hz\".",
|
||||||
@@ -10,6 +12,8 @@
|
|||||||
"FlexRadio, satellite: the uplink slice is properly armed. Creating a slice is asynchronous — the radio reports its number afterwards — and OpsLog carried on without waiting, so everything meant for the uplink went nowhere: it was never tuned (it sat at the 435.100 it was created with), never got its sideband, its antenna or its CTCSS tone, and never became the transmitter, leaving the radio transmitting on the DOWNLINK slice. Arming now waits for both slices, adopts one that the radio announces without a reply of its own, and gives a late-arriving uplink everything it was owed."
|
"FlexRadio, satellite: the uplink slice is properly armed. Creating a slice is asynchronous — the radio reports its number afterwards — and OpsLog carried on without waiting, so everything meant for the uplink went nowhere: it was never tuned (it sat at the 435.100 it was created with), never got its sideband, its antenna or its CTCSS tone, and never became the transmitter, leaving the radio transmitting on the DOWNLINK slice. Arming now waits for both slices, adopts one that the radio announces without a reply of its own, and gives a late-arriving uplink everything it was owed."
|
||||||
],
|
],
|
||||||
"fr": [
|
"fr": [
|
||||||
|
"Si OpsLog s’apprête à créer une base de réglages neuve et vide dans un dossier qui en contient déjà une pleine, il le dit — dans le journal de démarrage et à l’écran — au lieu de s’ouvrir sans bruit comme si rien n’était configuré. Rien n’est supprimé et rien n’est deviné : le message nomme l’autre fichier, là où vos réglages sont toujours.",
|
||||||
|
"config.json — le seul fichier qui note OÙ se trouve votre base — est désormais écrit de façon atomique, avec une copie de la version précédente conservée à côté. Il était écrit en tronquant le fichier puis en le remplissant : un processus interrompu entre les deux (plantage, coupure de courant, mise à jour fermant l’ancienne instance) le laissait vide. OpsLog lisait alors « aucune base choisie », en ouvrait une neuve et vide, et démarrait en ayant tout oublié. Un config.json illisible est maintenant restauré depuis sa sauvegarde, et celui qu’on ne peut pas restaurer est CONSERVÉ sous le nom config.json.broken au lieu d’être remplacé en silence.",
|
||||||
"L’affichage de l’antenne ne clignote plus pendant un passage. Le rotor est interrogé toutes les trois secondes, mais l’état du suivi était reconstruit de zéro chaque seconde et perdait la réponse entre-temps — l’antenne apparaissait donc une seconde sur trois, ce qui se lit comme un rotor qui se déconnecte sans arrêt.",
|
"L’affichage de l’antenne ne clignote plus pendant un passage. Le rotor est interrogé toutes les trois secondes, mais l’état du suivi était reconstruit de zéro chaque seconde et perdait la réponse entre-temps — l’antenne apparaissait donc une seconde sur trois, ce qui se lit comme un rotor qui se déconnecte sans arrêt.",
|
||||||
"Pendant le suivi, les deux fréquences et le cap de l’antenne sont affichés à côté du bouton Tracking. Pendant un passage, on regarde la radio et l’antenne, pas une colonne à l’autre bout de la fenêtre — et c’est la première chose qu’on masque pour avoir la carte en pleine largeur. La boussole tourne tant que l’antenne est en mouvement : un pylône met des dizaines de secondes à traverser un passage, et distinguer « en route » de « bloqué » est toute la raison de la regarder.",
|
"Pendant le suivi, les deux fréquences et le cap de l’antenne sont affichés à côté du bouton Tracking. Pendant un passage, on regarde la radio et l’antenne, pas une colonne à l’autre bout de la fenêtre — et c’est la première chose qu’on masque pour avoir la carte en pleine largeur. La boussole tourne tant que l’antenne est en mouvement : un pylône met des dizaines de secondes à traverser un passage, et distinguer « en route » de « bloqué » est toute la raison de la regarder.",
|
||||||
"Les fréquences satellite sont affichées à la centaine de hertz au lieu du hertz. Le Doppler se déplace d’environ soixante hertz par seconde en 70 cm : les deux derniers chiffres changeaient à chaque tick et l’affichage était une bouillie de chiffres illisible et inutile. La radio reçoit toujours la valeur complète — il ne s’agit que de ce qui vaut la peine d’être mis sous vos yeux. Le décalage à côté indique désormais « +9,7 kHz » plutôt que « +9741 Hz ».",
|
"Les fréquences satellite sont affichées à la centaine de hertz au lieu du hertz. Le Doppler se déplace d’environ soixante hertz par seconde en 70 cm : les deux derniers chiffres changeaient à chaque tick et l’affichage était une bouillie de chiffres illisible et inutile. La radio reçoit toujours la valeur complète — il ne s’agit que de ce qui vaut la peine d’être mis sous vos yeux. Le décalage à côté indique désormais « +9,7 kHz » plutôt que « +9741 Hz ».",
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3530,6 +3530,11 @@ export default function App() {
|
|||||||
try {
|
try {
|
||||||
const st = await GetStartupStatus();
|
const st = await GetStartupStatus();
|
||||||
if (!st.ok) { setError(`Startup failed: ${st.err}\nDB path: ${st.db_path}`); return; }
|
if (!st.ok) { setError(`Startup failed: ${st.err}\nDB path: ${st.db_path}`); return; }
|
||||||
|
// Started, but somewhere that deserves saying out loud — a new, empty
|
||||||
|
// settings database opened beside a full one. An operator who is not
|
||||||
|
// told this concludes their configuration was thrown away, when the
|
||||||
|
// file holding it is sitting right there.
|
||||||
|
if (st.warn) setError(st.warn);
|
||||||
// First launch (or a never-configured profile): collect the mandatory
|
// First launch (or a never-configured profile): collect the mandatory
|
||||||
// station identity before anything else.
|
// station identity before anything else.
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -4578,6 +4578,7 @@ export namespace main {
|
|||||||
ok: boolean;
|
ok: boolean;
|
||||||
err: string;
|
err: string;
|
||||||
db_path: string;
|
db_path: string;
|
||||||
|
warn: string;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new StartupStatus(source);
|
return new StartupStatus(source);
|
||||||
@@ -4588,6 +4589,7 @@ export namespace main {
|
|||||||
this.ok = source["ok"];
|
this.ok = source["ok"];
|
||||||
this.err = source["err"];
|
this.err = source["err"];
|
||||||
this.db_path = source["db_path"];
|
this.db_path = source["db_path"];
|
||||||
|
this.warn = source["warn"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class StationDevice {
|
export class StationDevice {
|
||||||
|
|||||||
Reference in New Issue
Block a user