Conversation
The `--follow` drain (`shelbi orchestrator events next --follow`) could hang past its `--max-lifetime` and permanently wedge on a poison cursor, silently blinding an orchestrator to its board (observed live on prbadge: cursor frozen 4h while events kept appending, `--max-lifetime 2s` hanging to an external kill). Root causes and fixes: 1. Hang past deadline/signal. The poll path acquired the hub-global `events.log.lock` via an unbounded blocking `flock(LOCK_EX)` that also re-issued LOCK_EX on EINTR, so once a tick entered flock neither the deadline nor SIGTERM could interrupt it — only SIGKILL, which never advances the cursor. Added `acquire_file_lock_deadline` (LOCK_EX|LOCK_NB in a short retry loop that re-checks a deadline and a cancel predicate, and does NOT swallow EINTR), plus deadline-aware `read_event_log_from_deadline` / `read_or_initialize_event_cursor_deadline`. The loop bounds every acquire by its remaining lifetime and threads the termination signal in as the cancel flag, so `--max-lifetime 2s` returns Expired within ~2s under contention and SIGTERM ends a lock-blocked drain promptly. 2. Global contention. Per-project cursor read/write moved off the hub-global lock onto a per-project `event-cursor.lock`, so a follower's cursor access no longer contends with unrelated projects or event writers. Safe because cursor writes stay atomic and rotation reads them via the same atomic snapshots (monotonic cursor => rotation still defers correctly). The one-time legacy-cursor migration is preserved: a cursor read still establishes the index (under the shared lock) while it is absent; once established the read stays on the uncontended cursor lock. 3. Poison cursor (the prbadge wedge). A cursor that predates the earliest retained generation now surfaces as data (`FeedRead::CursorExpired`) instead of a bare Err; the loop fast-forwards the durable cursor to the earliest retained position, emits a visible `resynchronized` notice, and continues — so a relaunch resumes healed rather than re-reading the wedged cursor and re-dying. 4. Never die silently. Any terminal error now emits a `failed` FeedNotice before propagating, so a dead drain is observable on the stream the supervisor already watches. 5. Poison-batch escape (the preferred optional): an unacked batch redelivered past FEED_MAX_REDELIVERIES (10) is quarantined — cursor advanced past it with a loud `quarantined` notice — so one un-ackable batch can't wedge the stream forever. A healthy consumer acks within one redelivery and never trips it. Ack semantics unchanged: the cursor still advances only on ack for normally-delivered batches; quarantine/resync are bounded escape hatches. Decisions made without asking: (a) cursor gets its OWN lock rather than plumbing a deadline through every shared-lock caller — matches the task's explicit guidance and keeps blast radius on the append/rotation paths at zero; (b) implemented the optional poison-batch quarantine (cap 10) since it closes case 3 of the wedge and the task prefers it; (c) resync fast-forwards to `earliest` (max events retained) rather than `current_base`. Follow-up (NOT in this change, to be filed separately): add a real supervisor for the consuming drain with crash-loop backoff, mirroring the pane supervisor in shelbi-orchestrator/src/supervision.rs. With the self-heal here that becomes a backstop rather than the only line of defense. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
jlong
deleted the
jlong/drain-consuming-event-drain-hangs-past-max-lifetime-and-permanently-wedges-on-a-poison-cursor-silent-death
branch
August 18, 2026 14:58
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.
Task
The orchestrator's consuming event drain (
shelbi orchestrator events next --follow) can (a) hang indefinitely past its--max-lifetime, and (b) permanently wedge so its per-projectevent-cursorfreezes and every relaunch instantly re-dies. Observed live: theprbadgeproject's drain died ~4h ago (cursor frozen while events kept appending) and never recovered;--max-lifetime 2sinvocations hang to an external 2-minute kill. This makes an orchestrator go silently blind to its board.Make the consuming drain honor its deadline and self-heal from a stale/poison cursor instead of dying silently.
Current Behavior
Root cause is in the
--followdrain path (crates/shelbi-cli/src/commands/orchestrator.rs→run_feed→feed_loop→feed_loop_with, ~lines 301-351) plus the shared lock primitive.Hang — deadline/signal only checked between ticks, then blocks in an uninterruptible lock.
feed_loop_withchecksmax_lifetime/signal at the top of each tick (~orchestrator.rs:328-334), then callsread_persisted_cursorandscan_feed_batch, both of which acquire the hub-global advisory lock~/.shelbi/events.log.lock(event_log.rs:278-280; reads atevent_log.rs:671and597). The lock primitiveacquire_file_lock(crates/shelbi-state/src/lib.rs:587-613) is an unbounded blockinglibc::flock(fd, LOCK_EX)(line 602) that re-issuesLOCK_EXonEINTR(607-609). So once a tick entersflock, neither the deadline nor SIGTERM can interrupt it — only SIGKILL, which never advances the cursor. Every project's 4 Hz follower poll and every event writer contend on this one global lock, soLOCK_EX(not FIFO-fair) can starve a follower well past its deadline under load.Permanent freeze — poison cursor after rotation, silent death. The cursor advances only via
events ack(ack_delivery→write_persisted_cursor, ~orchestrator.rs:529-535); the--followloop never writes it. Any death mid-batch freezes the cursor. The log rotates and retains only one prior generation, so once the frozen cursor falls >1 rotation behind,read_event_log_fromreturns Err "event cursor {c} predates earliest retained cursor {earliest}" (event_log.rs:605-610). That Err propagates throughscan_feed_batch(...)?(orchestrator.rs:335) andrun_feed'semit_feed_notice(project, &outcome?)(orchestrator.rs:246) as a bare Err to main with no notice (see doc at orchestrator.rs:399-400). Result: the drain dies silently and every relaunch re-reads the same wedged cursor and re-dies. This is the prbadge failure.Freeze-while-alive — unacked poison batch. An unacked batch is re-emitted every
FEED_VISIBILITY_TIMEOUT(90s, orchestrator.rs:172; redeliveryfeed_should_scanat 383-396) under a stable delivery id, never advancing the cursor; new events queue behind it forever. A single un-ackable batch wedges the stream.No supervisor for the drain.
crates/shelbi-orchestrator/src/supervision.rsis panes-only; nothing relaunchesrun_feedafter an Err. (Addressed as a follow-up — see below — not required by this task.)Expected Behavior
LOCK_EXwith a bounded, deadline-aware acquire:LOCK_EX | LOCK_NBin a short retry loop that re-checksstart.elapsed() >= limitand the termination signal between attempts (or plumb a deadline intoacquire_file_lock).--max-lifetime 2smust returnExpiredwithin ~2s even under lock contention, and SIGTERM must end the loop promptly (stop theEINTRloop from swallowing the term signal).~/.shelbi/projects/<p>/event-cursor.lock) so a follower's cursor access does not contend with unrelated projects/writers on the hub-globalevents.log.lock. (Log reads may still use the shared lock, but should be deadline-aware per above.)read_event_log_fromreports the cursor predates the earliest retained cursor, the drain must fast-forward the cursor toearliest(orcurrent_base), emit a distinct terminalFeedNotice(e.g. "cursor expired, resynchronized to , skipped events"), and continue — never propagate a silent Err that kills the process and re-dies on restart.FeedNotice/log line the orchestrator can see, so a dead drain is observable rather than a silent stall.Acceptance Criteria
shelbi orchestrator events next --follow --max-lifetime 2sreturns within a small multiple of 2s even while another process holdsevents.log.lock(add a test that holds the lock and asserts timelyExpired).--followdrain that is blocked on lock acquisition ends it promptly (no reliance on SIGKILL).events.log.lock(own lock file), verified by test or by inspection.FeedNotice/log line (no silentErrto main on the drain path).cargo build --workspace,cargo test --workspace, andcargo clippy --workspace --all-targets -- -D warningspass.Follow-up (do NOT do in this task; file separately)
shelbi-orchestrator/src/supervision.rs), so a drain that does hit a terminal error is relaunched automatically. With the self-heal above this becomes a backstop rather than the only line of defense.Context
Reported by the user 2026-08-18 after the prbadge orchestrator reported its background drain died. Confirmed via runtime evidence (prbadge
event-cursorfrozen 4h at 13391298 while events kept appending) and a code trace on origin/main @ 7be7327. This is core hub-global event infrastructure that every orchestrator depends on for board awareness — review the diff carefully; blast radius is high.Auto-opened by Shelbi — review at: /Users/jlong/.shelbi/projects/shelbi/tasks/drain-consuming-event-drain-hangs-past-max-lifetime-and-permanently-wedges-on-a-poison-cursor-silent-death.md