From 666792376e3d738677d40119df140039061264af Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 25 Oct 2026 09:19:01 +0100 Subject: [PATCH] fix(state): durable config writes wait for another writer SaveDriverSecret, SaveConfiguration and ReserveVehicleWake read before they write inside a deferred transaction. SQLite does not wait out busy_timeout for a transaction that holds a read lock and then asks to write; it returns SQLITE_BUSY at once while another connection writes. On the home box that refused a rotated MyUplink refresh token. Take the write lock with the first statement, so the 5 s busy timeout applies. Co-Authored-By: Claude Opus 5.5 --- .changeset/durable-config-write-lock-first.md | 5 +++ go/internal/state/configuration.go | 9 ++++ go/internal/state/configuration_test.go | 42 +++++++++++++++++++ 3 files changed, 56 insertions(+) create mode 100644 .changeset/durable-config-write-lock-first.md diff --git a/.changeset/durable-config-write-lock-first.md b/.changeset/durable-config-write-lock-first.md new file mode 100644 index 000000000..796cd5d47 --- /dev/null +++ b/.changeset/durable-config-write-lock-first.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +A driver's rotated sign-in token, a settings save and a vehicle wake reservation no longer fail at once with "database is locked (SQLITE_BUSY)" while another part of FTW writes to the database. They now wait up to five seconds for it, as other writes already did. A MyUplink token lost this way could stop the driver signing in after the next restart. diff --git a/go/internal/state/configuration.go b/go/internal/state/configuration.go index 050d35827..91b87f24d 100644 --- a/go/internal/state/configuration.go +++ b/go/internal/state/configuration.go @@ -145,6 +145,15 @@ func (s *Store) durableConfigWriteContext(parent context.Context, write func(*sq return err } defer tx.Rollback() + // Take the write lock before write reads anything. SQLite does not wait + // out busy_timeout for a transaction that read first and then writes: it + // returns SQLITE_BUSY at once while another connection is writing, since + // waiting there could deadlock. SaveDriverSecret and SaveConfiguration + // both read first, so a rotated OAuth token was refused by any + // concurrent write. The statement changes no row. + if _, err := tx.ExecContext(ctx, `UPDATE config SET value = value WHERE 0`); err != nil { + return err + } err = write(tx) if err == nil { err = tx.Commit() diff --git a/go/internal/state/configuration_test.go b/go/internal/state/configuration_test.go index b4d30ab2f..26c9c6b2e 100644 --- a/go/internal/state/configuration_test.go +++ b/go/internal/state/configuration_test.go @@ -1,10 +1,12 @@ package state import ( + "context" "database/sql" "errors" "path/filepath" "testing" + "time" ) func TestConfigurationCommitIsDurableAndAtomic(t *testing.T) { @@ -103,3 +105,43 @@ func TestBackupExportsTheCapturedConfiguration(t *testing.T) { t.Fatalf("current revision: %+v %v", current, err) } } + +// SaveDriverSecret and SaveConfiguration read before they write. SQLite then +// skips busy_timeout and refuses the write at once while another connection +// writes. A rotated OAuth token lost that way can lock a driver out at its +// next restart, so both must wait for the other writer instead. +func TestDurableConfigWriteWaitsForAnotherWriter(t *testing.T) { + s := freshStore(t) + rev, err := s.SaveConfiguration([]byte(`{"config":{"drivers":[{"name":"myuplink"}]}}`), 0, nil) + if err != nil { + t.Fatal(err) + } + holdWriteLock := func(hold time.Duration) { + t.Helper() + ctx := context.Background() + conn, err := s.db.Conn(ctx) + if err != nil { + t.Fatal(err) + } + if _, err := conn.ExecContext(ctx, `BEGIN IMMEDIATE`); err != nil { + t.Fatal(err) + } + time.AfterFunc(hold, func() { + _, _ = conn.ExecContext(ctx, `COMMIT`) + conn.Close() + }) + } + + holdWriteLock(300 * time.Millisecond) + if err := s.SaveDriverSecret("myuplink", "refresh_token", "RT-rotated"); err != nil { + t.Fatalf("rotated token refused while another connection wrote: %v", err) + } + if got, ok := s.LoadConfig(DriverSecretKey("myuplink", "refresh_token")); !ok || got != "RT-rotated" { + t.Fatalf("stored token = %q, %v", got, ok) + } + + holdWriteLock(300 * time.Millisecond) + if _, err := s.SaveConfiguration([]byte(`{"config":{"drivers":[{"name":"myuplink"}]}}`), rev, nil); err != nil { + t.Fatalf("settings save refused while another connection wrote: %v", err) + } +}