fix(sessions): close the snapshot check/use race and guard damaged state_meta - #71779
Conversation
૮ >ﻌ< ა ci reviewran on 8e74db6 all good! |
|
@teknium1 Two things are still left:
When The current reproduction can therefore recover sessions and messages while reporting:
The crash and key-only copy are fixed, but the report can now claim completeness after dropping an existing damaged metadata table. Since the table exists but is unusable, this should return The regression should assert those report fields rather than accepting either
After Please run the copy in a worker thread, pause it inside the patched copy operation, attempt The underlying lock fix is right. These are the remaining reporting and regression-test issues I found. |
Second round of @helix4u review on #71779. Both findings reproduced before fixing. 1. My previous fix turned a crash into SILENT DATA LOSS. Returning status="missing" for a present-but-unusable state_meta looked like a safe degrade, but _verify_recovered_database only escalates "failed"/"partial" into a warning + loss_detected. Measured on the branch: a run that dropped a real metadata table reported warnings=[], loss_detected=False, partial=False, complete=True. Strictly worse than the ValueError it replaced -- that at least failed loudly. Now "failed" when the table exists but lacks key/value, "missing" only when genuinely absent. The damaged case yields warnings=['state_meta copy status is failed'], loss_detected=True, partial=True, complete=False, while staying verified=True so the output is still installable-with-review. 2. The race test I wrote had its own scheduling race: after the guard released the lock, the racer could win before the main thread set the release event, failing on a correct implementation. Rewritten per helix4u's design -- copy runs in a worker parked inside the patched copy, a second worker attempts connect_tracked(), assert it stays blocked, release, assert it then opens. Deterministic and ~1.1s instead of 10s; 12/12 stable. Sabotage-verified. Note the third scenario only failed after adding a unit-level test: recover_session_database short-circuits on the inspection result when state_meta is entirely absent, so the helper's absent-branch is unreachable end-to-end and a regression there was invisible. Both statuses are now pinned directly. 939 targeted tests green.
|
Both correct again, and the first one is worse than you flagged — my "fix" converted a loud crash into silent data loss. Measured on the branch before changing anything: A Now split by cause rather than symptom:
2. Race test rewritten to your design. You were right that it was schedule-dependent: after the guard released the lock, the racer could acquire it before the main thread set One thing worth flagging from the sabotage run. I verify new tests by reinstating the defect and confirming they fail. The damaged-table and race sabotages both failed correctly. The absent-table one passed — meaning that test proved nothing. Cause: That's the second time this round that a test of mine looked fine and was hollow. Worth naming since it's the same class of thing you caught in the race test. 939 targeted tests green, ruff clean. Thanks — three real defects across two rounds, and the reporting one would have been genuinely harmful to someone recovering a damaged database. |
|
That addresses both findings. The There is technically still a small scheduler-dependent false-pass window because |
d6c7a9b to
2a6d0b1
Compare
|
Thanks — and I took the false-pass window anyway, since it was two lines. You were right that 15/15 stable at ~1.1s, removed-lock sabotage still fails, and the branch is rebased onto current Appreciate the three rounds. Recap of what you caught, since it's a good sequence: a check/use race I introduced while fixing a check/use race; a |
|
Lol, fair enough. Technically, the connector could still be preempted in the tiny interval between This closes the meaningful false-pass case I was pointing at. Between this, the corrected |
…ate_meta Post-merge follow-up to #71770. Both defects were found by @helix4u in review and reproduced against merged main before fixing. 1. Check/use race in _copy_source_bundle (my bug, from the #71770 follow-up commit). It called has_live_connection(), released the registry lock, and only then ran shutil.copy2() over the bundle. A tracked connection could open in that window; the copy's close() then cancels its POSIX advisory locks -- the exact class #71724 closed. Measured on main: a racer thread opened a connection mid-copy after blocking 0.000s. Adds sqlite_safe_read.offline_file_access(), a context manager that holds the connection-lifecycle lock across an entire multi-step raw access, and routes the bundle copy through it. Same racer now blocks 10.0s until every raw descriptor is closed. Any future raw read of a database file (hashing, moving a bundle aside) should use this rather than a bare pre-check. 2. _copy_state_meta_salvage assumed a 'key' column. A damaged state_meta can keep 'value' and lose 'key'; columns.index("key") then raised ValueError and aborted the whole partial recovery. The mirror case (key without value) would have copied key-only rows and reported the table complete. Now requires both, matching the non-partial _copy_state_meta, so an unusable optional table is recorded as missing/failed and --allow-partial still recovers sessions and messages. Both regression tests verified by sabotage: reinstating the bare pre-check fails the race test, removing the key/value requirement fails the other. 937 targeted tests green.
Second round of @helix4u review on #71779. Both findings reproduced before fixing. 1. My previous fix turned a crash into SILENT DATA LOSS. Returning status="missing" for a present-but-unusable state_meta looked like a safe degrade, but _verify_recovered_database only escalates "failed"/"partial" into a warning + loss_detected. Measured on the branch: a run that dropped a real metadata table reported warnings=[], loss_detected=False, partial=False, complete=True. Strictly worse than the ValueError it replaced -- that at least failed loudly. Now "failed" when the table exists but lacks key/value, "missing" only when genuinely absent. The damaged case yields warnings=['state_meta copy status is failed'], loss_detected=True, partial=True, complete=False, while staying verified=True so the output is still installable-with-review. 2. The race test I wrote had its own scheduling race: after the guard released the lock, the racer could win before the main thread set the release event, failing on a correct implementation. Rewritten per helix4u's design -- copy runs in a worker parked inside the patched copy, a second worker attempts connect_tracked(), assert it stays blocked, release, assert it then opens. Deterministic and ~1.1s instead of 10s; 12/12 stable. Sabotage-verified. Note the third scenario only failed after adding a unit-level test: recover_session_database short-circuits on the inspection result when state_meta is entirely absent, so the helper's absent-branch is unreachable end-to-end and a regression there was invisible. Both statuses are now pinned directly. 939 targeted tests green.
… blocked Closes the last false-pass window @helix4u flagged on #71779. He explicitly said not to hold the PR for it; it is two lines, so worth doing rather than leaving a known-soft assertion in a concurrency test. connection_opened.wait(1.0) proved the connection had not opened, but not that the connector thread had actually reached connect_tracked() -- an unscheduled thread produces the same observation. The connector now sets connect_attempted immediately before the blocking call, and the test waits on that first, so "still blocked" means blocked at the lock rather than not yet started. 15/15 stable at ~1.1s. Removed-lock sabotage still fails.
2a6d0b1 to
8e74db6
Compare
Second round of @helix4u review on #71779. Both findings reproduced before fixing. 1. My previous fix turned a crash into SILENT DATA LOSS. Returning status="missing" for a present-but-unusable state_meta looked like a safe degrade, but _verify_recovered_database only escalates "failed"/"partial" into a warning + loss_detected. Measured on the branch: a run that dropped a real metadata table reported warnings=[], loss_detected=False, partial=False, complete=True. Strictly worse than the ValueError it replaced -- that at least failed loudly. Now "failed" when the table exists but lacks key/value, "missing" only when genuinely absent. The damaged case yields warnings=['state_meta copy status is failed'], loss_detected=True, partial=True, complete=False, while staying verified=True so the output is still installable-with-review. 2. The race test I wrote had its own scheduling race: after the guard released the lock, the racer could win before the main thread set the release event, failing on a correct implementation. Rewritten per helix4u's design -- copy runs in a worker parked inside the patched copy, a second worker attempts connect_tracked(), assert it stays blocked, release, assert it then opens. Deterministic and ~1.1s instead of 10s; 12/12 stable. Sabotage-verified. Note the third scenario only failed after adding a unit-level test: recover_session_database short-circuits on the inspection result when state_meta is entirely absent, so the helper's absent-branch is unreachable end-to-end and a regression there was invisible. Both statuses are now pinned directly. 939 targeted tests green.
Second round of @helix4u review on NousResearch#71779. Both findings reproduced before fixing. 1. My previous fix turned a crash into SILENT DATA LOSS. Returning status="missing" for a present-but-unusable state_meta looked like a safe degrade, but _verify_recovered_database only escalates "failed"/"partial" into a warning + loss_detected. Measured on the branch: a run that dropped a real metadata table reported warnings=[], loss_detected=False, partial=False, complete=True. Strictly worse than the ValueError it replaced -- that at least failed loudly. Now "failed" when the table exists but lacks key/value, "missing" only when genuinely absent. The damaged case yields warnings=['state_meta copy status is failed'], loss_detected=True, partial=True, complete=False, while staying verified=True so the output is still installable-with-review. 2. The race test I wrote had its own scheduling race: after the guard released the lock, the racer could win before the main thread set the release event, failing on a correct implementation. Rewritten per helix4u's design -- copy runs in a worker parked inside the patched copy, a second worker attempts connect_tracked(), assert it stays blocked, release, assert it then opens. Deterministic and ~1.1s instead of 10s; 12/12 stable. Sabotage-verified. Note the third scenario only failed after adding a unit-level test: recover_session_database short-circuits on the inspection result when state_meta is entirely absent, so the helper's absent-branch is unreachable end-to-end and a regression there was invisible. Both statuses are now pinned directly. 939 targeted tests green.
… blocked Closes the last false-pass window @helix4u flagged on NousResearch#71779. He explicitly said not to hold the PR for it; it is two lines, so worth doing rather than leaving a known-soft assertion in a concurrency test. connection_opened.wait(1.0) proved the connection had not opened, but not that the connector thread had actually reached connect_tracked() -- an unscheduled thread produces the same observation. The connector now sets connect_attempted immediately before the blocking call, and the test waits on that first, so "still blocked" means blocked at the lock rather than not yet started. 15/15 stable at ~1.1s. Removed-lock sabotage still fails.
… blocked Closes the last false-pass window @helix4u flagged on NousResearch#71779. He explicitly said not to hold the PR for it; it is two lines, so worth doing rather than leaving a known-soft assertion in a concurrency test. connection_opened.wait(1.0) proved the connection had not opened, but not that the connector thread had actually reached connect_tracked() -- an unscheduled thread produces the same observation. The connector now sets connect_attempted immediately before the blocking call, and the test waits on that first, so "still blocked" means blocked at the lock rather than not yet started. 15/15 stable at ~1.1s. Removed-lock sabotage still fails.
Summary
Post-merge follow-up to #71770. @helix4u posted review findings 47 seconds before I merged; I missed them. Both reproduced against merged
main, both fixed here.1. Check/use race in
_copy_source_bundle(my bug)The guard I added in #71770 called
has_live_connection(), released the registry lock, and then ranshutil.copy2()over the bundle. A tracked connection could open in that window, and the copy'sclose()cancels its POSIX advisory locks — the exact class #71724 closed.Measured on merged
main, racer thread opening a connection mid-copy:Adds
sqlite_safe_read.offline_file_access()— a context manager holding the connection-lifecycle lock across an entire multi-step raw access — and routes the bundle copy through it. Future raw reads of a database file (hashing, moving a bundle aside) should use this instead of a bare pre-check.2.
_copy_state_meta_salvageassumed akeycolumnA damaged
state_metacan retainvalueand losekey;columns.index("key")then raisedValueErrorand aborted the whole partial recovery. The mirror case (keywithoutvalue) would have copied key-only rows and reported the table complete.Now requires both columns, matching the non-partial path, so an unusable optional table is recorded as missing/failed and
--allow-partialstill recovers sessions and messages.Validation
state_metatest.Credit to @helix4u for catching both.