Skip to content

fix(sessions): close the snapshot check/use race and guard damaged state_meta - #71779

Merged
teknium1 merged 3 commits into
mainfrom
fix/71770-followup
Jul 26, 2026
Merged

fix(sessions): close the snapshot check/use race and guard damaged state_meta#71779
teknium1 merged 3 commits into
mainfrom
fix/71770-followup

Conversation

@teknium1

Copy link
Copy Markdown
Contributor

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 ran shutil.copy2() over the bundle. A tracked connection could open in that window, and the copy's close() cancels its POSIX advisory locks — the exact class #71724 closed.

Measured on merged main, racer thread opening a connection mid-copy:

before after
connection opened during copy yes, blocked 0.000s blocked 10.0s until copy released
race window open closed

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_salvage assumed a key column

A damaged state_meta can retain 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.

before: ValueError: tuple.index(x): x not in tuple   <- aborts recovery
after:  {'status': 'missing'}                        <- matches _copy_state_meta

Now requires both columns, matching the non-partial path, so an unusable optional table is recorded as missing/failed and --allow-partial still recovers sessions and messages.

Validation

  • 937 targeted tests green (recovery, lock-safety, state, backup, WAL gate, kanban, malformed-repair, WAL fallback); ruff clean.
  • Sabotage-verified: reinstating the bare pre-check fails the race test; removing the key/value requirement fails the state_meta test.
  • Original reproduction script re-run post-fix: both findings closed.

Credit to @helix4u for catching both.

@github-actions

github-actions Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

૮ >ﻌ< ა ci review

ran on 8e74db6

all good!

@helix4u

helix4u commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

@teknium1
The production fix for the check/use race looks correct. offline_file_access() holds the same lifecycle lock used by connect_tracked() across the complete main/WAL/SHM/journal copy, so another tracked connection cannot open between the check and the raw file closes.

Two things are still left:

  1. Damaged state_meta is not reported as data loss.

When state_meta exists but lacks key or value, _copy_state_meta_salvage() returns status: "missing". _verify_recovered_database() only turns failed and partial statuses into warnings and loss_detected; it ignores missing.

The current reproduction can therefore recover sessions and messages while reporting:

  • no warning about the lost metadata
  • loss_detected = false
  • partial = false
  • potentially complete = true

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 status: "failed". The existing optional-table verification path will then keep the output structurally verified while setting a warning, loss_detected=true, partial=true, and complete=false.

The regression should assert those report fields rather than accepting either missing or failed.

  1. The new race test has its own scheduling race.

After _copy_source_bundle() releases the lifecycle lock, the racer can acquire it before the main test thread calls release_copy.set(). In that schedule, copy_finished_first becomes false even though the production guard worked. The test also waits ten seconds on the successful path.

Please run the copy in a worker thread, pause it inside the patched copy operation, attempt connect_tracked() from the test thread or a second worker, assert that it remains blocked, then release the copy and assert that the connection opens afterward. That directly tests the lock ordering without relying on which thread runs immediately after the guard releases.

The underlying lock fix is right. These are the remaining reporting and regression-test issues I found.

@alt-glitch alt-glitch added type/bug Something isn't working comp/cli CLI entry point, hermes_cli/, setup wizard area/sessions Session lifecycle, resume, persistence, history P2 Medium — degraded but workaround exists sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state labels Jul 26, 2026
teknium1 added a commit that referenced this pull request Jul 26, 2026
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.
@teknium1

Copy link
Copy Markdown
Contributor Author

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:

state_meta copy status : missing
verification warnings  : []
loss_detected          : False
partial                : False
complete               : True     <- claimed clean after dropping real metadata

A ValueError that aborts recovery is bad. Reporting complete=True on a database that quietly lost its metadata table is worse — the user has no signal at all. You called the mechanism exactly: verification only escalates failed/partial, so missing was invisible to it.

Now split by cause rather than symptom:

  • present but unusablefailedwarnings=['state_meta copy status is failed'], loss_detected=True, partial=True, complete=False, verified=True (structurally sound, installable-with-review)
  • genuinely absentmissing, no warning — nothing was lost

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 release_copy, failing on a correct implementation. Now the copy runs in a worker parked inside the patched operation, a second worker attempts connect_tracked(), the test asserts it stays blocked, releases, then asserts it opens. Deterministic, and ~1.1s instead of 10s. Ran it 12× — 12/12 stable.

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: recover_session_database short-circuits on the inspection result when state_meta is entirely absent, so the helper's absent-branch is never reached through the public path and a regression there is invisible end-to-end. Added a unit-level test calling _copy_state_meta_salvage directly to pin both statuses. Now all three sabotages fail as they should.

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.

@helix4u

helix4u commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

That addresses both findings. The state_meta reporting and assertions look right now, and the revised race test is a substantial improvement.

There is technically still a small scheduler-dependent false-pass window because connection_opened.wait(1.0) does not prove the connector reached the lock attempt. Given that the removed-lock sabotage failed consistently across 12 runs, I would not hold this PR up over it. Just noting it in case the concurrency test is hardened later.

@teknium1
teknium1 force-pushed the fix/71770-followup branch from d6c7a9b to 2a6d0b1 Compare July 26, 2026 05:43
@teknium1

Copy link
Copy Markdown
Contributor Author

Thanks — and I took the false-pass window anyway, since it was two lines.

You were right that connection_opened.wait(1.0) proved the connection hadn't opened but not that the connector had reached connect_tracked(); an unscheduled thread produces the same observation. The connector now sets a connect_attempted event 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, and the branch is rebased onto current main (it had drifted 4 commits behind during the review rounds).

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 ValueError that aborted recovery; then my fix for that one silently reporting complete=True after dropping real metadata. The last was the most dangerous and the least visible.

@helix4u

helix4u commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Lol, fair enough. Technically, the connector could still be preempted in the tiny interval between connect_attempted.set() and actually entering connect_tracked(), so this is not a mathematical proof that it has reached the lock. That window is narrow enough that replacing the lock with an instrumented test wrapper would just be test-lawyering at this point.

This closes the meaningful false-pass case I was pointing at. Between this, the corrected state_meta reporting and assertions, and the rebase onto current main, I do not have anything else. Looks good from my side pending CI.

teknium1 added 3 commits July 25, 2026 22:54
…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.
@teknium1
teknium1 force-pushed the fix/71770-followup branch from 2a6d0b1 to 8e74db6 Compare July 26, 2026 05:55
@teknium1
teknium1 merged commit 36926af into main Jul 26, 2026
37 checks passed
teknium1 added a commit that referenced this pull request Jul 26, 2026
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.
@teknium1
teknium1 deleted the fix/71770-followup branch July 26, 2026 06:05
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
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.
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
… 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.
33hodl pushed a commit to 33hodl/hermes-agent that referenced this pull request Aug 12, 2026
… 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/sessions Session lifecycle, resume, persistence, history comp/cli CLI entry point, hermes_cli/, setup wizard P2 Medium — degraded but workaround exists sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants