fix(demo): the janitor re-entry guard actually works now (#902) - #909
Merged
Merged
Conversation
The guard in startup_events() read:
if getenv_compat("FEEDBACK_DEMO_MODE") or getenv_compat("FEEDBACK_DEMO_MODE") == "1" \
and not _DEMO_JANITOR_STARTED:
`and` binds tighter than `or`, so that is `A or (B and C)`. The not-already-started half
never ran when the env var was truthy — the only case that reaches it at all. A second
startup started a SECOND janitor thread, overwrote the handle, and shutdown then joined
only the last: the first leaked and kept firing registered hooks hourly, forever.
The guard now lives INSIDE start_janitor(). A caller cannot get operator precedence wrong
if there is nothing left for it to get wrong.
━━━ THREE WAYS TO WRITE THIS GUARD WRONG. I HIT ALL THREE. ━━━
1. NO GUARD — the original bug. Double-start, orphaned thread.
2. GUARD ON THE FLAG (`if _DEMO_JANITOR_STARTED: return`). Codex [P2]. stop_janitor()
DELIBERATELY leaves that flag True when a hook outruns its join timeout, so that a later
startup cannot spawn a janitor beside a live one. But the hook usually finishes a moment
later: the thread exits and the flag is stale. A flag-keyed guard then refuses to start a
replacement for the rest of the process — demo cleanup silently dead. (The original bug
accidentally MASKED this by always starting.)
3. GUARD ON LIVENESS ALONE (`if thread.is_alive(): return`). Codex [P2], second pass. A
timed-out stop leaves the old thread ALIVE BUT DOOMED — its stop event is set and it
exits as soon as its current hook returns. Treating that as a running janitor skips the
replacement, and we are back at (2) a second later.
So: a janitor counts as running only if its thread is alive AND it has not been told to stop.
━━━ AND EACH JANITOR NOW OWNS ITS STOP EVENT ━━━
start_janitor() used to `_DEMO_JANITOR_STOP.clear()` a single SHARED Event. Start a
replacement while a doomed thread is still finishing a hook and that clear RESURRECTS it: it
loops back to wait(), sees the flag cleared, and carries on. Two janitors — the exact bug we
started from. A fresh Event per janitor makes it impossible; the old thread waits on its own
event, which stays set, so it can only exit.
Env semantics UNCHANGED, verified across every value ("", "1", "0", "true", "false", "off"):
the old expression and demo_mode_enabled() agree on all of them. The only behavioural change
is the idempotency fix.
FOUR tests, and each of the three wrong guards fails a different subset:
no guard -> 2 fail (double start; orphaned thread)
guard on the flag -> 2 fail (never restarts after a timed-out stop)
liveness alone -> 1 fail (no replacement for a doomed janitor)
liveness + not-stopping -> all pass
pytest 2416, pyflakes 0, Codex 0.
Closes #902
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Contributor
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (3)
📝 WalkthroughWalkthroughDemo janitor startup now prevents duplicate live threads, replaces stale or stopped janitors with fresh stop events, and avoids startup re-entry issues. Regression tests cover repeated starts, timed-out stops, and overlapping replacement shutdown. ChangesDemo janitor lifecycle
Estimated code review effort: 3 (Moderate) | ~20 minutes 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
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.
Closes #902.
The guard in
startup_events()read:andbinds tighter thanor, so that'sA or (B and C). The not-already-started half never ran when the env var was truthy — the only case that reaches it at all.A second startup started a second janitor thread, overwrote the handle, and shutdown then joined only the last: the first leaked and kept firing registered hooks hourly, forever.
The guard now lives inside
start_janitor(). A caller cannot get operator precedence wrong if there's nothing left for it to get wrong.Three ways to write this guard wrong. I hit all three.
if _DEMO_JANITOR_STARTED: returnstop_janitor()deliberately leaves that flag True when a hook outruns its join timeout, so a later startup can't spawn one beside a live thread. But the hook usually finishes a moment later — thread exits, flag is stale, and a flag-keyed guard then refuses to start a replacement for the rest of the process. Demo cleanup silently dead. (The original bug accidentally masked this by always starting.)if thread.is_alive(): returnBoth of the last two were Codex [P2]s, on successive passes. The answer: a janitor counts as running only if its thread is alive and it hasn't been told to stop.
And each janitor now owns its stop event
start_janitor()used to_DEMO_JANITOR_STOP.clear()a single sharedEvent.Start a replacement while a doomed thread is still finishing a hook, and that
clear()resurrects it — it loops back towait(), sees the flag cleared, and carries on. Two janitors, which is the exact bug we started from.A fresh
Eventper janitor makes it impossible: the old thread waits on its own event, which stays set, so it can only exit.Behaviour
Env semantics unchanged, verified across every value (
"","1","0","true","false","off") — the old expression anddemo_mode_enabled()agree on all of them. The only behavioural change is the idempotency fix.Four tests, and each wrong guard fails a different subset:
pytest 2416 · pyflakes 0 · Codex 0.
🤖 Generated with Claude Code
Summary by CodeRabbit
Bug Fixes
Tests