Repository navigation
fix(daemon): settings startup survives busy and interrupted writes - #2650
Merged
Merged
Conversation
Nondeterministic Windows reds came from races on _config.db:
test-windows-guards section_start_status_port / daemon_lifecycle ("the
daemon did not accept the UI configuration"), section_churn_stability
("could not load saved runtime settings"), the windows-2025 release
smoke whose `--ui=true --port` server exited about 5 s after start
(Phase 15, read as "no embedded UI assets"), and section_crash_recovery
("the stale daemon state never cleared").
- Every _config.db connection now waits out a committing writer
(cbm_runtime_settings_wait_for_writers: one busy handler, logs
config_db.busy_wait once per busy wait, a 3 s bound for a wedged
holder). Startup's 50 ms window let a daemon's routine commit end
unrelated CLI starts; cbm_config_open and the UI load had no wait at
all, so a concurrent commit failed the daemon's UI save and made the
load keep defaults.
- A process killed mid-write (crash, taskkill /F, power loss) left a hot
rollback journal that the read-only settings reads cannot roll back
(SQLITE_READONLY_ROLLBACK). Every later command then failed with
"could not load saved runtime settings", permanently. Settings startup
and the UI load now recover once through a read-write open that rolls
the interrupted write back (config_db.hot_journal_recovered); a failed
recovery is reported with the journal path and stays fatal.
- write_value no longer rewrites an unchanged value. The upsert fired the
revision trigger, so every `daemon start` on a UI build committed and
locked readers out although nothing changed.
- `daemon start` and the stdio server wait for the set_context and
set_ui_config replies within their startup budget instead of the 1 s
probe budget (MAIN_CONNECT_TIMEOUT_MS) that decided the command
whenever the daemon's save ran long.
Tests, red on 8df0639 and green here:
- runtime_settings suite: three single-threaded lock tests (a log sink
commits the held lock when the waiter logs config_db.busy_wait, so no
test depends on a duration) and two hot-journal tests (a portable
fixture snapshots the database and its journal mid-transaction and
checks the journal is hot before the code under test runs).
- tests/windows/test_daemon_stability.py: new section
start_port_waits_for_settings_writer holds the lock against a real
`daemon start --port` and releases it on events; the crash section
now prints the status output it saw.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Four nondeterministic Windows reds and one permanent failure mode share one area: how startup reads
_config.dbwhile another process writes it, or after a writer died.test-windows-guardssection_start_status_port/daemon_lifecycle(#2643 rounds, #2648, the v0.12.0 matrix)error: the daemon did not accept the UI configurationtest-windows-guardssection_churn_stabilitycodebase-memory-mcp: could not load saved runtime settings--ui=true --port=Nserver exited about 5 s after start, read as "no embedded UI assets"test-windows-guardssection_crash_recovery, and any real crash,taskkill /For power loss during a settings writeRoot cause:
daemon startand the stdio server gave theset_contextandset_ui_configreplies the 1 s probe budget (MAIN_CONNECT_TIMEOUT_MS). The daemon answersset_ui_configonly after journaled commits to_config.db. With--porta miss is fatal.daemon startcommitted even when nothing changed: the upsert rewrote equal values and fired the revision trigger.cbm_runtime_settings_init, at the top ofmain(), gave a committing writer 50 ms and then exited the process.cbm_config_openand the UI load had no busy wait at all, so a concurrent commit failed the daemon's own UI save at once and made the load silently keep the defaults._config.dbread-only, and SQLite cannot roll a hot journal back on a read-only connection (SQLITE_READONLY_ROLLBACK, not BUSY). Nothing in the product opened it read-write first, so the install stayed broken. New since v0.11.0.Change
cbm_runtime_settings_wait_for_writers(): one busy handler for every_config.dbconnection. It waits out a committing writer, logsconfig_db.busy_waitonce per busy wait, and gives up only after 3 s (a wedged holder). It is used by settings startup,settings_open, the UI load,cbm_config_openand the recovery below.settings_read_snapshot(): the read-only snapshot used by settings startup and the UI load. OnSQLITE_READONLY_ROLLBACKit recovers ONCE through a read-write open that reads the schema, so SQLite rolls the interrupted write back (config_db.hot_journal_recovered), then retries read-only. A journal is hot only while no live writer holds its lock, so this never undoes a write in progress. If the recovery itself fails (read-only volume, ACL),config_db.hot_journal_unrecoverednames the-journalpath, and startup stays fatal: reading the settings without the rollback would be inconsistent.write_value:ON CONFLICT ... DO UPDATE ... WHERE value IS NOT excluded.value. An unchanged value is not rewritten, so no commit and no revision bump.daemon start(MAIN_DAEMON_CTL_START_TIMEOUT_MS) and the stdio server (its bootstrap startup budget) wait for the two handshake replies. The connection is up, so the reply is an event that comes unless the daemon dies.Tests (red on 8df0639, green on this branch)
runtime_settingssuite:config_db.busy_wait; no test depends on a duration.startup_waits_out_a_committing_writer: baseASSERT(initialized)fails.ui_save_and_load_wait_out_a_committing_writer: baseASSERT(saved)fails.unchanged_ui_save_does_not_rewrite: base revision 6, expected 2.startup_recovers_a_hot_journal: baseASSERT(cbm_runtime_settings_init(...))fails. Fixed: the pre-crash value is read and the journal is gone.ui_load_recovers_a_hot_journal: base falls back to the legacy defaults (ASSERT(loaded.ui_enabled)).tests/windows/test_daemon_stability.py:start_port_waits_for_settings_writer. It holdsBEGIN IMMEDIATEagainst a realdaemon start --portand releases on events: client exit, or 1.5 s after the daemon logged the wait. It releases by closing the holder connection, so the OS drops the lock with the handle. Base:exited_under_lock=True rc=1, "the daemon did not accept the UI configuration". Fix: PASS. Its cleanup always runsdaemon stopfor its cache, so a failure cannot leave a daemon that blocks the next run's residue cleanup.section_crash_recoverynow prints the lastdaemon statusoutput instead of only "kept reporting it", which hid the settings error.Proof on real binaries (stub-UI builds of 8df0639 and this branch)
config listwhile a second process holdsBEGIN EXCLUSIVEdaemon start --port=Nwhile a second process holdsBEGIN IMMEDIATEdaemon: started (permanent ...)--ui=true --port=NunderBEGIN IMMEDIATE(smoke Phase 15)config listtwiceconfig_db.hot_journal_recovered), then rc 0 with nothing left to recover (macOS and Windows)Verification
cli runtime_settings ui httpd daemon daemon_application daemon_bootstrap daemon_runtime daemon_frontend userconfig: 706 passed, 1 skipped (runtime_settings14/14).--with-ui TEST_SEAMS=1, as_test.ymlbuilds it) of this tree. All 8 Windows guards are GREEN in 4 consecutive full runs:daemon_lifecycleincluding both UI-refusal checks, anddaemon_stabilityincluding the new section, kill -9 recovery, churn and cold storm.runtime_settingsis 14/14. The real-binary lock scenarios and the hot-journal scenario PASS.src/main.creverted:config liststill passes, butdaemon start --portand--ui=true --portfail under the held lock while the daemon logs that it is waiting. The new guard section fails withdaemon_waited=True. Every part of the change is needed.Release relevance
The maintainer decided this ships in v0.12.0. The binaries change, so the release re-dispatch runs the full test matrix (
skip_tests=false).Found, not fixed here
test-infrastructure/vm/win.sh guardsbuilds withoutTEST_SEAMS=1, unlike CI, sotest_daemon_lifecycle.py's refusal check is red on the VM for any tree.win_repair_runtime_children), including files other processes are writing.set_contextcallers (one-shot CLI and hook paths) keep the 1 s bound; no failure was observed there.BEGIN IMMEDIATEtransaction on the test's own Python connection (SQLite 3.53.3) returned plainSQLITE_BUSY(code 5, no journal) while another process's connection retried the lock. That happened in 3 of 5 full guard runs and 0 of 9 standalone runs. The section therefore releases by closing the connection. The SQLite-internal path that returns BUSY there is not pinned. An empty_config.dbat BEGIN, where page 1 would be written and COMMIT would need EXCLUSIVE, is excluded: the section's warm-updaemon start/daemon stopcreates the database first. This is not a product gap: after this PR every product connection to_config.db(settings_open, startup, the UI load,cbm_config_openand the hot-journal recovery) carries the busy handler, and SQLite invokes it for the lock waits at commit. A product commit therefore waits there instead of failing.main.cis exercised only by the Windows guard section. The POSIX legs never run it (the guard runs on POSIX only locally).