Skip to content

Windows: fix daemon wedge from blocking flock in hippo lock conversion#49

Open
danielhertz1999-bit wants to merge 2 commits into
CodeAbra:mainfrom
danielhertz1999-bit:fix/hippo-downgrade-nonblocking-flock
Open

Windows: fix daemon wedge from blocking flock in hippo lock conversion#49
danielhertz1999-bit wants to merge 2 commits into
CodeAbra:mainfrom
danielhertz1999-bit:fix/hippo-downgrade-nonblocking-flock

Conversation

@danielhertz1999-bit

Copy link
Copy Markdown
Contributor

Problem

On Windows the daemon periodically wedges: the liveness watchdog fires pre_kill_forensic_dump + kill, and the blackbox thread-dump shows the main thread stuck in hippo/_db.py … downgrade_to_shared_filelock.py … flock. This has caused real capture outages (the daemon comes back only after a manual zombie-tree kill + restart).

Root cause

HippoDB.downgrade_to_shared acquired the base lock with a blocking flock(base_fd, LOCK_SH) — the only lock site in _db.py without LOCK_NB (compare the shared-acquire and escalate_to_exclusive paths, both non-blocking + deadline-bounded).

On Windows, _filelock.py has no shared locks, so it services LOCK_SH as an exclusive msvcrt byte-range lock and has no atomic EX→SH conversion (its own docstring flags this hazard). A blocking LOCK_SH issued on an fd that already holds LOCK_EX makes the shim poll LK_NBLCK forever against the daemon's own range — Windows won't re-lock an already-locked region — so every thread stalls under the process-locks guard until the watchdog kills the process.

The daemon boots holding EXCLUSIVE and downgrades on its first WAKE tick, so this fires whenever it genuinely holds EXCLUSIVE at that point (intermittent: it no-ops early when the lock entry is already absent).

Fix

  • downgrade_to_shared: use flock(base_fd, LOCK_SH | LOCK_NB) and treat EAGAIN/EWOULDBLOCK as success. On Windows SH == EX, so the fd already owns the lock (relabel-only, no OS re-acquire); on POSIX an EX→SH downgrade never blocks anyway, so behavior is identical.
  • Sibling self-conflict in escalate_to_exclusive (SH→EX on an already-held fd) — bounded by a 4s deadline so it degraded (raised HippoLockHeldError) rather than wedging, but still wrong on Windows. Fixed by exposing SHARED_IS_EXCLUSIVE from _filelock.py (True on Windows, False on POSIX) and short-circuiting to relabel-only when this fd already holds the lock. POSIX shared locks stay genuinely contended.

Test

tests/test_hippo_lock_cycle_no_wedge.py runs the full daemon lock cycle (EXCLUSIVE → downgrade → escalate → downgrade), with every lock call — including close() — bounded by a watchdog-thread join timeout, so a reintroduced blocking flock fails the assertion instead of hanging the suite. It deliberately does not skip on Windows (unlike test_hippo_concurrency.py), which is exactly why this wedge went uncaught. Verified both ways: passes on the fix; on pre-fix code the downgrade test fails with the blocking-flock wedge.

Windows-only behavior change; POSIX paths are byte-for-byte equivalent.

danielhertz1999-bit and others added 2 commits July 5, 2026 04:14
downgrade_to_shared() called a *blocking* flock(base_fd, LOCK_SH) -- the
only lock site in _db.py without LOCK_NB. On Windows _filelock.py services
LOCK_SH as an exclusive msvcrt byte-range lock with no atomic EX->SH
conversion (documented in its own module docstring), so a blocking LOCK_SH
on a fd that already holds LOCK_EX polls LK_NBLCK forever against the
daemon's own range. That loop runs under _PROCESS_LOCKS_GUARD, stalling
every thread until the liveness watchdog force-kills the daemon -- the
recurring pre_kill_forensic_dump at _db.py downgrade_to_shared.

The daemon downgrades on its first WAKE tick, so this wedged the daemon
whenever it genuinely held EXCLUSIVE then (intermittent: no-ops out early
when the _PROCESS_LOCKS entry is already absent).

Fix, per the "conversion-free protocol" the shim docstring prescribes:
- downgrade_to_shared: use LOCK_SH | LOCK_NB and treat EAGAIN/EWOULDBLOCK
  as success. On Windows SH==EX so the fd already owns the lock (relabel
  only); on POSIX an EX->SH downgrade never blocks, so behavior is
  unchanged.
- escalate_to_exclusive: same self-conflict (SH->EX on an already-held fd)
  was bounded by the 4s deadline so it degraded to HippoLockHeldError
  rather than wedging, silently running sleep consolidation without the
  EXCLUSIVE label on Windows. Expose SHARED_IS_EXCLUSIVE from _filelock
  (True on Windows, False on POSIX) and short-circuit to relabel when this
  fd already holds the lock. POSIX shared locks stay genuinely contended.

Verified with a timed before/after: old blocking path still wedged after
3s; patched full cycle (EXCLUSIVE -> downgrade -> escalate -> downgrade)
completes in 0ms with correct access-mode transitions. Existing
lock/concurrency/consolidation tests unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Guards the fix in 1a2b528. Runs the exact daemon sequence (EXCLUSIVE ->
downgrade -> escalate -> downgrade) with every lock call -- including close()
teardown -- bounded by a watchdog-thread join timeout, so a reintroduced
blocking flock FAILS the assertion within the timeout instead of hanging the
suite (a wedged thread holds _PROCESS_LOCKS_GUARD, which would otherwise
deadlock an un-bounded close()).

Crucially these tests do NOT skip on Windows -- unlike test_hippo_concurrency.py,
which is skipif(Windows) and therefore never exercised the platform where this
wedge lived. That skip is exactly why the bug went uncaught.

Verified: passes on the fix; on the pre-fix blocking-flock code the downgrade
test fails with "the blocking-flock wedge is back" (thread never returns).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant