Repository navigation
feat: best-effort flush of in-flight events at crash time (#13) - #34
Conversation
…ems (#9) Resolve the data directory as explicit option, BUGSEE_DATA_DIR, platform default, then temp dir, taking the first that is actually writable. If none is, or the disk fails mid-run (full, read-only, removed), launch still succeeds and handled errors / manual reports are assembled without capture context and delivered from a bounded in-memory backlog. Native crash handler and panic snapshots are skipped in memory mode. Adds Bugsee::storage_status() so a host can check crash recovery is available and whether the dir may be ephemeral. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
- memory backlog: entries stay queued while in flight so the cap is real; cache the presigned endpoint (no duplicate issue on a retried PUT); honour the flush deadline; clear the backlog in fork children - a requested data_dir / BUGSEE_DATA_DIR is final: unusable means memory mode (explicit_rejected), never another disk location - temp fallback is bugsee-<uid>/<token>, 0700, symlink/owner checked; probe uses create_new with a random name - recover and deliver leftovers from the previous default location once - mid-run degradation is visible (StorageStatus::degraded); fork child falls back to memory instead of failing; warn_once per message, never panics - #[non_exhaustive] on the new public types; ffi test no longer writes to the real data directory 🤖 Generated with [Claude Code](https://claude.com/claude-code)
- fork child that falls back to memory clears the panic snapshot path and updates the published storage resolution (dir None, source None) - legacy location is adopted only if <temp>/bugsee and the token dir are real directories owned by us and not our own directory (canonicalised compare) - legacy queue is included in flush, gc'd for orphans, and retired from polling once recovery has run and its queue is empty - do not resolve temp_dir() unless a default location is actually needed - tests: fork memory-fallback, symlinked/aliased legacy dir, end-to-end legacy crashed session + queued report delivered once and cleaned up 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Adds crates/bugsee/src/hostinfo.rs with per-platform lookups (libc on unix, hand-declared Win32 calls on Windows; no new dependencies) and threads the results through HostFacts into platform.utc_offset and hardware.model. Closes #16 🤖 Generated with [Claude Code](https://claude.com/claude-code)
… locale) Closes #16 🤖 Generated with [Claude Code](https://claude.com/claude-code)
…sharing a data dir Several processes on one data directory could double-deliver (or lose) reports: - A session was recovered twice: a peer that listed it before another finished claimed it after the winner had discarded it, producing a phantom report. The marker is now re-checked after the claim is won. - Stale-claim takeover was remove-then-create, so two takers could each remove the other's fresh claim and both hold it. Takeover is now serialised by a guard file and re-validated under it; release only removes the claim's own content. - Claims and session markers identified owners by bare pid: a recycled pid (or a restarted container's pid 1) kept a dead owner's work blocked forever. Owners now carry a process start time (Linux /proc, macOS proc_pidinfo, Windows GetProcessTimes), with a 30 minute lease as a backstop. - On Windows process liveness always answered "dead", so live peers' claims and markers were taken over. Implemented via OpenProcess/GetExitCodeProcess. - queue::gc_orphans at a peer's launch reaped another process's in-flight enqueue (sidecars before the bundle rename), stranding the bundle and losing the report. Only files untouched for 60 s are reaped now. - Leftover claims of discarded sessions are swept. Adds tests/multiprocess.rs (real child processes racing; SIGKILLed holder) plus unit tests for claims, process identity and queue gc. Closes #14 🤖 Generated with [Claude Code](https://claude.com/claude-code)
…S file locks Review of the pid/start-time claim scheme found it unsound where #14 matters most: containers with separate pid namespaces sharing a volume (both apps pid 1) cannot compare pids or start times, so a peer's live claim was judged stale and its live session reported as a crash. Pid-in-file schemes also needed a lease, a takeover guard with a timer, and identity checks, each with its own race. Replace them with kernel locks: fcntl(F_SETLK) on unix (per process, released on death, not inherited by fork) and LockFileEx on Windows, on dedicated *.lock files (queue/<bundle>.lock, sessions/<gen>.recover.lock, and sessions/<gen>.lock held for a session's life). Nothing can go stale, so there is no lease, takeover or start-time logic. An in-process registry (pid-keyed, spin-locked with the holder's pid so a fork child cannot inherit a held lock) excludes threads and keeps a process from ever opening one lock file twice, which POSIX would punish by dropping the lock. - .alive stays a bare pid and lock files are new names, so older builds sharing a directory keep working (no exactly-once between old and new; documented). Markers with no .lock beside them still use the pid probe. - Liveness probe: zombies count as dead (Linux /proc state, macOS proc_pidinfo); Windows uses WaitForSingleObject. - Leftover lock files of finished items and dead sessions are swept when free. - Tests: a live peer whose marker names a colliding pid (parent's, init's) is not recovered until it really dies, then exactly once; concurrent claimers; probing never drops a held lock; zombie probe. Closes #14 🤖 Generated with [Claude Code](https://claude.com/claude-code)
- an existing directory that fails the write probe / session open is still recovered from and drained (read + delete only); claims are lenient there and a crash that cannot be re-queued on disk is delivered from memory; delivered but undeletable bundles are remembered so they are not resent every drain - in-memory TooManySimilar blacklist (cleared in fork children) - a clean stop gives the in-memory backlog a last bounded delivery attempt - legacy temp location adopted whenever it exists and is trusted, even if no new default directory was usable - rotation tick rewrites a missing liveness marker after a wipe and marks the recorder degraded - DESIGN.md: failure table, legacy/flush wording 🤖 Generated with [Claude Code](https://claude.com/claude-code)
- A launch that cannot hold <gen>.lock removes the file instead of leaving an unheld one, which every peer read as "that session died" and recovered (and deleted) the live session. Contended claims are retried briefly; an empty, fresh marker with a free lock is skipped (the pid is written after locking); on unix a probe uses F_GETLK and never takes the lock. - Filesystems that reject locks (ENOLCK, EOPNOTSUPP, ENOSYS, EINVAL) degrade to thread-only claims with a one-time message, and probes report unknown so recovery falls back to the marker pid, instead of blocking the queue forever. - An unclean shutdown (detached worker/uploader) keeps the session lock until the process exits so peers cannot recover a session still being written. - Registry keys are canonicalised; a fork child resets the registry from the fork hook; old-scheme .claim/.claim.takeover files are swept. - Tests: the real pid-namespace hazard (marker pid that does not exist here, reaped pid), crashed sessions with free lock files, the lock-race launch, unsupported-lock injection, and the probe/launch ordering cases. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Resolves conflicts in claim.rs (keep the file-lock claims, add the lenient variant #9 introduced: a directory that cannot take a lock file proceeds unclaimed) and recovery.rs (claim_pending is lenient and still re-checks the marker). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
A producer only queues an entry; the capture worker appends it later, so the last events before a fault (still in the channel) were lost. Measured: a 1500-event burst followed by SIGSEGV recovered ~50-60 events. After the marker is complete, the fatal-crash handler now does one atomic ticket, one non-blocking write(2) (Windows: SetEvent) to a pre-created wake handle and polls an atomic for at most 50 ms. A helper thread made at launch posts a Flush through the normal FIFO channel and publishes the worker's ack. No allocation or lock on the crashing thread; a stuck worker costs only the budget. A main-thread panic nothing will catch runs the same bounded flush. Tests: core handshake/stuck-worker/deregistration, torn part-tail recovery, and subprocess crash tests (burst then SIGSEGV recovers every event; wedged worker still dies promptly and reports). fork_safety's FileTransport now writes atomically since a dying worker can linger mid-upload. Closes #13 🤖 Generated with [Claude Code](https://claude.com/claude-code)
…returns A host that crashes again right after launch (crash loop) could die before the worker thread recovered the previous crash. launch now runs a bounded, newest-first recovery pass (8 sessions, 500 ms budget via LaunchOptions::launch_recovery_budget, 4096-entry scan cap) that enqueues the reports durably before returning; upload stays async and the worker's pass picks up the rest, skipping what the launch pass attempted. Closes #10 🤖 Generated with [Claude Code](https://claude.com/claude-code)
… unlinkable claims proceed (#9) - recovery_dirs: all rejected existing candidates (incl. the private temp tree) are drained/recovered even when a later default wins; core takes extra_data_dirs - a stale claim that cannot be unlinked is Unavailable, not Taken - seed the in-memory blacklist from the on-disk one 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Keeps the file-lock claim.rs (the pid-claim changes from #9 no longer apply; its lenient variant is retained). Writes the F_UNLCK comparison portably and gates the unix-only Lock::Unsupported variant so non-unix clippy is clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
…ash handlers Address review of the launch-path recovery pass: - the pass runs on a helper thread and launch waits at most the budget, so a blocked before_send cannot stall or deadlock launch; the worker holds its own recovery until the pass is done (bounded) and skips what the pass claimed - Recorder::recover_on_launch_path is called by launch_with after the native handler and panic hook are installed, outside all locks - dedup repeated identical recovered crashes against the queue and cap it - recovered crashes carry no scope on either path; claimed-only attempted set; scan cap counts session markers only; docs, latency numbers, deterministic tests, facade test 🤖 Generated with [Claude Code](https://claude.com/claude-code)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
- One process-wide wake pipe/event and ONE helper thread, created once and re-created in a fork child; the flush target is swapped under a fork-aware lock (last registered wins). Handles are never closed, so no descriptor-reuse race and no SIGPIPE for C hosts; shared ticket counters are now coherent. - Dying flag raised by the crash handler before waking the helper: claims and recoveries start nothing in the window, so a report is never half-delivered (removes the 5 s UNOWNED_GRACE stall in fork_safety; test-helper workaround reverted). - Skip the wait when the crashing thread is the worker or helper. - Handler waits with poll (Unix) / ack event + QPC (Windows) instead of nanosleep / Sleep(1); pipe2(O_CLOEXEC) on Linux/Android; parent's fds closed in a fork child; unpublish-then-republish ordering. - Panic hook shares DEFAULT_BUDGET; docs cover host-caught panics, ordering, Windows filter order and last-registered-wins. - crash_inflight runs on Windows too (cfg any(unix, windows)); a hidden budget setter lets the burst test use a long budget deterministically, a small burst covers the real 50 ms budget, and the wedged test now requires the handler to wait the budget and no longer (all three fail without the feature). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
- A leftover <gen>.lock that cannot be opened (0444, read-only volume) or whose lock call errors probes as Unknown, not Held, and the probe opens read-only, so it can no longer hide a crash forever. - Lock-less sessions (filesystem rejects locks) keep a <gen>.beat heartbeat file, touched every 2 s by the worker; a beat younger than 60 s means alive whatever pid the marker names, so a peer in another pid namespace is never mistaken for a crash. Without a beat the pid is probed as before. - claim_pending re-runs the liveness decision under the recovery claim, so a session that finished locking since the scan is not torn down. - Windows maps ERROR_INVALID_FUNCTION/NOT_SUPPORTED/CALL_NOT_IMPLEMENTED to Unsupported, so the documented degrade exists there; the dead-code allow is gone. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
… launches - A heartbeat whose mtime is in the future (file server clock ahead) counts as fresh, like marker_is_fresh; only a demonstrably quiet beat condemns a session. The staleness window is 3 minutes, above NFS attribute-cache lifetimes. - Launch-time recovery (before the worker loop starts) now keeps the beat going. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
…est-first without a cap; keep attempts progressing - Admission::Full no longer discards the session; it is left to the worker - find_pending_bounded reads all marker names, sorts, then probes (no capped readdir), and includes sessions with prior attempts so a fault during recovery still reaches MAX_RECOVERY_ATTEMPTS 🤖 Generated with [Claude Code](https://claude.com/claude-code)
… lock-based claims The gate sits in claim::try_claim/try_claim_lenient (which queue and recovery claims go through), and also stops the launch-path recovery pass and the worker's wait for it, so a crash inside the pass leaves the worker free to answer the in-flight flush. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
There was a problem hiding this comment.
Stale comment
Deep review: crash-time in-flight flush (#13)
The design is the right shape for a signal-safe best-effort drain: marker first, then one atomic ticket, one non-blocking wake, a pre-created helper that posts the existing FIFO
Flush, and a 50 ms cap with no allocation on the faulting thread. The Unix handler path, forkOWNER_PIDgate, pipeline-thread skip, and wedged-worker subprocess test all match that contract.The hole is that
Msg::Flushis only consumed inworker_loop. The dying flag was added so a crash inside launch-path recovery would free the worker to answer that message. It only breaks out of the wait sleep; the worker then still walks recovery before it everrecvs.Overall risk: Medium
The crash marker and frames are still written. What fails is the feature this PR exists for: last breadcrumbs/logs/network events still in the capture channel.
Merge recommendation: request changes
Fix the P1 (skip leftover recovery once
is_dying()and enterworker_loop) before merging. The two P2s are small, local, and worth taking in the same pass.Most important to fix
- P1 — After the dying wait returns, skip
run_recovery/gc_orphans/ legacy drains and go straight toworker_loopso the queuedFlushcan run inside the 50 ms budget.- P2 — Do not deregister
CrashFlushuntil the worker has ackedStop(or treat a missing target as “no ack” rather than success).- P2 — Windows
request_and_wait: integer QPC math, nof64in the exception filter.Positive
- Marker-then-flush ordering on every fatal Unix exit and the Windows filter.
- Claims/lenient claims/in-memory drain all refuse new work while dying — that is the right fix for the 5 s
UNOWNED_GRACEstall.- Handshake tests (stuck/panicking helper, last-registered-wins, fork child, torn part tails) plus subprocess burst/wedge cases are the right e2e shape.
- Author already called out that Windows/Linux were not run; the Windows wait path in particular still needs a real exception-filter run.
Sent by Cursor Automation: Bugsee code review
…tempt - find_pending_bounded returns sessions with prior attempts before newer ones, so MAX_RECOVERY_ATTEMPTS progresses even if recovering the newest keeps faulting - Admission::Full gives the attempt back (undo_attempt) - make the full-queue test race-free 🤖 Generated with [Claude Code](https://claude.com/claude-code)
- P1: a dying process skips the worker's recovery scan, orphan GC and legacy drains and reaches worker_loop at once, so the queued Flush is answered. - P2: the flush target stays registered until the worker has acked Stop, so a crash during shutdown still waits for queued entries. - P2: Windows wait uses integer QPC math (no floating point in the filter). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
Addressed in the latest push: (P1) when dying, the worker skips run_recovery/gc_orphans/legacy drains and enters worker_loop immediately (test: events queued during a dying launch are flushed promptly); (P2) the flush target is dropped only after the worker acks Stop; (P2) Windows wait uses integer QPC math. Windows path still only type-checked. 🤖 Generated with Claude Code |
- legacy_is_adoptable compares against the chosen dir AND recovery_dirs by canonical path (Windows: temp fallback is the legacy location) - spawn path dedups legacy/extra dirs against the data dir and each other - seed the in-memory blacklist from legacy/extra dirs too - test: an existing temp tree is offered in exactly one role 🤖 Generated with [Claude Code](https://claude.com/claude-code)
…a crash probe returned Free both for an unheld lock and for a file that disappeared between a caller's exists() and the open. A session without kernel locks unlinks its <gen>.lock before writing its heartbeat, so a peer that saw the file could classify that live session as crashed. probe now reports Probe::Vanished for a missing file (no pre-check), and session_is_crashed treats it like Unknown and falls through to the heartbeat/pid decision. Test injects the vanish between the existence check and the probe's open. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
… not only the capture tree 🤖 Generated with [Claude Code](https://claude.com/claude-code)
…es (#9) One retire_queued helper for all arms (delivered, TooManySimilar, duplicate, permanent, retries exhausted); recovered crashes consult the in-memory blacklist. Test: terminal verdicts on a read-only queue are sent once. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
… dep; telemetry maps to sysinfo only) 🤖 Generated with [Claude Code](https://claude.com/claude-code)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
There was a problem hiding this comment.
Stale comment
Deep review: crash-time in-flight flush (#13) @ 4893817
Re-review after
4893817(prior pass was979c6e8). This HEAD only mergesfix/issue-10(launch-path recovery) andfix/issue-16(hostinfo) onto the already-reviewed flush;crash_flush.rs, the native/panic call sites, and the e2e tests are unchanged.Prior findings
- P1 (dying wait still ran
run_recovery/gc_orphans) — fully addressed.run_recoverystill returns immediately onis_dying()after the merge (heartbeat /skip_launch_passsit after that gate). The per-session loop, legacy drain, and launch-path pass check the same flag. The newworker_looplaunch_deferredrecovery goes through the samerun_recoverygate, so it cannot start a scan once dying.Msg::Flushis still only consumed inworker_loop.- P2 (unregister
CrashFlushbeforeStop) — fully addressed.Recorder::dropstill sendsStop, waits for the ack (or the 2 s give-up), and only then drops the target.- P2 (Windows
f64in the exception filter) — fully addressed.request_and_waitstill uses integer QPC ticks (elapsed_ms = delta * 1000 / freq).No new P0–P3 with a distinct failure scenario. In-progress
find_pending/recover_one/before_sendstill occupy the worker the same way a wedged sampler does; that remains the documented stuck-worker tradeoff (50 ms cap, subprocess test), not a leftover of the old “start new recovery after dying” hole. Claims, lenient claims, anddrain_memorystill refuse new work while dying.CI (
38566841097at review time): ubuntu-latest build/test/clippy SUCCESS. windows-latest tests succeeded, clippy failed (job log not yet fetchable while macos/ios are still running). I did not run the suite locally.Overall risk: Low
The crash marker and frames are still written first. The 50 ms drain still reaches
worker_loopwhen a crash hits during the launch pass, including after the issue-10 recovery changes.Merge recommendation: approve
Land once Windows clippy is green (tests there already passed). Hostinfo (
#16) is in this diff only because it was merged into the stack; it is not part of the#13flush work.Most important to fix
None remaining from this pass. Residual (not blocking the flush contract): Windows clippy on this HEAD; Windows and Linux still have not been run under a real exception filter / SIGSEGV outside CI (author already noted).
Positive
- Marker-then-flush on every fatal Unix exit and the Windows filter; pipeline-thread skip; fork
OWNER_PIDgate; dying still stops claims and new recovery.- The issue-10 merge kept the dying short-circuit on the new heartbeat / bounded-scan / deferred-recovery paths instead of reopening the P1 hole.
- Handshake tests plus subprocess burst/wedge cases still match the best-effort signal-safe contract.
Sent by Cursor Automation: Bugsee code review
…on Windows) process.rs and crash_flush.rs declared WaitForSingleObject with different handle types. Both now use one win32 module; no other duplicate extern declarations exist in bugsee-core, bugsee-native or bugsee. Verified with a standalone windows-msvc clippy of the affected modules. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
There was a problem hiding this comment.
Stale comment
Deep review: crash-time in-flight flush (#13) @ 7ab8a90
Re-review after
7ab8a90(prior pass was4893817). The unique delta is the Windows clippy fix:WaitForSingleObjectis no longer declared twice with different handle types. The flush contract, dying gates, Drop order, and QPC wait are unchanged from the last pass.Prior findings
- P1 (dying wait still ran
run_recovery/gc_orphans) — fully addressed.run_recoverystill returns immediately onis_dying(). The per-session loop, legacy drain, launch-path pass, andworker_looplaunch_deferredrecovery all hit that gate.Msg::Flushis still only consumed inworker_loop, which the worker now reaches without starting a new scan.- P2 (unregister
CrashFlushbeforeStop) — fully addressed.Recorder::dropstill sendsStop, waits for the ack (or the 2 s give-up), and only then drops the target.- P2 (Windows
f64in the exception filter) — fully addressed.request_and_waitstill uses integer QPC ticks (elapsed_ms = delta * 1000 / freq).This HEAD (
7ab8a90)The previous Windows clippy failure (
38566841097) wasclashing_extern_declarations:crash_flushdeclaredWaitForSingleObject(usize, …)andprocess.rsdeclaredWaitForSingleObject(*mut c_void, …). Both now go through onewin32module (Handle = *mut c_void). The helper thread still carries the handles asusizebecause raw pointers are notSend; it casts back only at the FFI call.WAIT_OBJECT_0is0, so the helper’s exit condition is the same as the old!= 0. No other duplicate kernel32 declarations remain inbugsee-core(LockFileExinclaim.rsis unique).No new P0–P3 with a distinct failure scenario. In-progress
find_pending/recover_one/before_send/ startupqueue::gc_orphanscan still occupy the worker past the 50 ms budget; that remains the documented stuck-worker tradeoff (subprocess wedge test), not a leftover of the old “start new recovery after dying” hole.CI (
38569249960): ubuntu-latest, windows-latest, MSRV, android, and ios-sim SUCCESS. macos-latest and ios-device still pending at review. I did not run the suite locally.Overall risk: Low
The crash marker and frames are still written first. The 50 ms drain still reaches
worker_loopwhen a crash hits during the launch pass. Windows now compiles under-D warnings.Merge recommendation: approve
Most important to fix
None remaining from this pass.
Positive
- Marker-then-flush on every fatal Unix exit and the Windows filter; pipeline-thread skip; fork
OWNER_PIDgate; dying still stops claims and new recovery.- One kernel32 surface is the right way to keep
clashing_extern_declarationsfrom coming back the next time a wait or event is added.- Handshake tests plus subprocess burst/wedge cases still match the best-effort signal-safe contract. Windows CI on this HEAD includes those tests.
Sent by Cursor Automation: Bugsee code review
🤖 Generated with [Claude Code](https://claude.com/claude-code)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
# Conflicts: # crates/bugsee/Cargo.toml
…optional form vs the unconditional one) 🤖 Generated with [Claude Code](https://claude.com/claude-code)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
# Conflicts: # crates/bugsee/Cargo.toml
There was a problem hiding this comment.
Stale comment
Deep review: crash-time in-flight flush (#13) @ 4e42eda
Re-review after
4e42eda(prior pass was7ab8a90). This HEAD remargesfix/issue-10@66e4f18(now carrying #14 / #9 / main: CrashBackend, origin, C API, Windows termination, hostinfo). Unique #13 files vs that base are still the same 14;crash_flush.rs, the dying gates, Drop order, QPC wait, panic hook, and e2e tests are unchanged from7ab8a90. Nativelib.rsgrew with CrashBackend; the flush calls were ported onto the same fatal exits.Prior findings
- P1 (dying wait still ran
run_recovery/gc_orphans) — fully addressed.run_recoverystill returns immediately onis_dying(). The per-session loop, legacy drain, launch-path pass, andworker_looplaunch_deferredrecovery all hit that gate.Msg::Flushis still only consumed inworker_loop, which the worker reaches without starting a new scan.- P2 (unregister
CrashFlushbeforeStop) — fully addressed.Recorder::dropstill sendsStop, waits for the ack (or the 2 s give-up), and only then drops the target.- P2 (Windows
f64in the exception filter) — fully addressed.request_and_waitstill uses integer QPC ticks (elapsed_ms = delta * 1000 / freq).This HEAD (
4e42eda)CrashBackend intersection is clean:
flush_inflight_eventsstill runs after the marker is complete on Windowson_crash, Unixfinish_fatal, and both arms offinish_original_report. The sibling P0 (duplicate[target.'cfg(unix)'.dependencies]/libc) did not land here —1ecfca8is in the base;cargo metadata --offlineloads. Onewin32module still ownsWaitForSingleObject.No new P0–P3 with a distinct failure scenario. In-progress
find_pending/recover_one/before_send/ startupqueue::gc_orphanscan still occupy the worker past the 50 ms budget; that remains the documented stuck-worker tradeoff (subprocess wedge test), not a leftover of the old “start new recovery after dying” hole.CI (
38579272645): ubuntu-latest, windows-latest, MSRV, android, and ios-sim SUCCESS. macos-latest, ios-device, and the private-API job still pending at review. I rancargo metadata --offline(workspace loads); I did not run the test suite locally.Overall risk: Low
The crash marker and frames are still written first. The 50 ms drain still reaches
worker_loopwhen a crash hits during the launch pass, including after the issue-10 / CrashBackend remarge.Merge recommendation: approve
Most important to fix
None remaining from this pass.
Positive
- Marker-then-flush on every fatal Unix exit and the Windows filter; pipeline-thread skip; fork
OWNER_PIDgate; dying still stops claims and new recovery.- The CrashBackend remarge kept the flush after
WRITINGis released, so the helper/worker are not blocked on the marker latch.- Handshake tests plus subprocess burst/wedge cases still match the best-effort signal-safe contract. Ubuntu and Windows CI on this HEAD include those tests.
Sent by Cursor Automation: Bugsee code review
Root cause of the windows flake in a_stuck_worker_costs_only_the_budget: the helper's ack wait (250 ms) was shorter than the 5 s budget the test asked for. After un-wedging, the worker had to drain a backlog (file creation on Windows is slow) and the helper gave up before the ack, never retrying, so the handler waited out its whole budget. The helper now waits 10 s (it only bounds a permanently wedged worker). The test also stopped assuming a 150 ms sleep wedges the worker (it waits for the sampler to report entry), uses generous budgets and bounds, and no longer relies on sleeps. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
There was a problem hiding this comment.
Stale comment
Deep review: crash-time in-flight flush (#13) @ 0f94b90
Re-review after
0f94b90(prior pass was4e42eda). Unique #13 vsfix/issue-10@66e4f18is still the same 14 files. The new commit is the Windows handshake flake: the helper’s ack wait was 250 ms, shorter than a legitimate drain of a capped queue, so after un-wedge it dropped the ack receiver and never retried.Prior findings
- P1 (dying wait still ran
run_recovery/gc_orphans) — fully addressed.run_recoverystill returns immediately onis_dying(). The per-session loop, legacy drain, launch-path pass, andworker_looplaunch_deferredrecovery all hit that gate.Msg::Flushis still only consumed inworker_loop.- P2 (unregister
CrashFlushbeforeStop) — fully addressed.Recorder::dropstill sendsStop, waits for the ack (or the 2 s give-up), and only then drops the target.- P2 (Windows
f64in the exception filter) — fully addressed.request_and_waitstill uses integer QPC ticks (elapsed_ms = delta * 1000 / freq).This HEAD (
0f94b90)
CRASH_FLUSH_ACK_TIMEOUTis now 10 s. That value only bounds a permanently wedged worker occupying the helper; the crashing thread still polls its own budget (DEFAULT_BUDGET= 50 ms in production,set_inflight_flush_budget_for_testin the subprocess cases). I walked the follow-up-crash case: the firstFlushis already in the FIFO, so a longer helper wait cannot make a later fault overtake work the worker has not finished. After a timeout the helper still does not retry that ticket — which is exactly why 250 ms was wrong for any request whose budget (or remaining drain) exceeded it. Production 50 ms crashes were already inside 250 ms; the failure was the unwedge-then-long-budget handshake the e2e actually exercises, and any slow-but-healthy drain of the capped queue.The stuck-worker test now waits until the sampler reports entry instead of sleeping 150 ms, which is the right pin. Marker-then-flush is unchanged: Windows
on_crash, Unixfinish_fatal, and both arms offinish_original_reportstill callflush_inflight_eventsonly afterWRITINGis released.No new P0–P3 with a distinct failure scenario. Residual only: e2e upper bounds are now very loose (
took < 5sfor a 50 ms wait; dying-promptnesselapsed < 30s), so they would not catch a 5 sUNOWNED_GRACEstall on the launch-wait path. In-progressfind_pending/recover_one/before_send/ startupqueue::gc_orphanscan still occupy the worker past 50 ms — documented stuck-worker tradeoff, not a leftover of the old “start new recovery after dying” hole.CI (
38581617934): all 8 jobs SUCCESS (ubuntu, windows, macos, MSRV, android, ios device + sim, private-API). I rancargo metadata --offline --no-deps(workspace loads); I did not run the test suite locally.Overall risk: Low
The crash marker and frames are still written first. The 50 ms drain still reaches
worker_loopwhen a crash hits during the launch pass. The helper now outlasts a slow-but-healthy drain instead of reporting failure and going idle.Merge recommendation: approve
Most important to fix
None remaining from this pass.
Positive
- Waiting on an observable
enteredflag, not a 150 ms sleep, is what actually wedges the worker on a slow runner.- 10 s as a helper cap (not a crash-handler cap) matches the contract: the handler is self-bounding; the helper only has to stay alive long enough to publish the ticket.
- Handshake tests plus subprocess burst/wedge cases still match the best-effort signal-safe contract. Windows CI on this HEAD includes those tests and is green.
Sent by Cursor Automation: Bugsee code review
Tree unchanged here; the doc clean-up from #36 is applied next. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Generalise the mobile native capture and out-of-process monitor design notes, the FFI audit and a few code comments so they describe behaviour in generic terms (the backend, the ingestion pipeline, existing report variants) instead of naming components of other projects. No behaviour change. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
There was a problem hiding this comment.
Deep review: crash-time in-flight flush (#13) @ 4858ce7
Re-review after 4858ce7 (prior pass was 0f94b90). This HEAD remarges origin/main (the #14 / #10 squashes plus #36 docs). Unique #13 vs main is still the same 14 files; vs 0f94b90 the only unique edits are #36 wording (DESIGN.md 12004, two comments). The flush contract, Drop order, QPC wait, and dying gates are unchanged — but the dying flag itself is a sticky latch, and one Unix fatal path can return to a live process after raising it. That was always in this unique code; previous passes treated finish_fatal as process-ending.
Prior findings
- P1 (dying wait still ran
run_recovery/gc_orphans) — fully addressed.run_recoverystill returns immediately onis_dying(). The per-session loop, legacy drain, launch-path pass, andworker_looplaunch_deferredrecovery all hit that gate.Msg::Flushis still only consumed inworker_loop. - P2 (unregister
CrashFlushbeforeStop) — fully addressed.Recorder::dropstill sendsStop, waits for the ack (or the 2 s give-up), and only then drops the target. - P2 (Windows
f64in the exception filter) — fully addressed.request_and_waitstill uses integer QPC ticks (elapsed_ms = delta * 1000 / freq).
Findings
P2 Medium — DYING is latched for the process lifetime, but finish_fatal is not always followed by death.
- Location:
crates/bugsee-core/src/crash_flush.rs:210(flush_for_crash); callerscrates/bugsee-native/src/lib.rs:2450(finish_fatal) and2702/2724(finish_original_report). Unix handler at2861–2868can return after that withoutdie_of. - Problem:
flush_for_crashstoresDYING = trueand never clears it exceptreset_dying_for_test. The module docs describe “the (up to budget) window”; the implementation is a sticky process-wide refuse oftry_claim/try_claim_lenient,run_recovery, the launch pass, anddrain_memory. - Impact: A process that survives the handler after this flush stops delivering queued reports and recovering other sessions until it exits. The crash marker for this fault is already on disk; what fails is everything after.
- Scenario: Linux/Android host SIGSEGV/SIGBUS handler with
SA_RESETHANDthat mprotects a mapped page and does not reinstall.call_hostitself installsSIG_DFLforSA_RESETHAND, sohost_declinedis true. Outcome isRetryOneShot.fatalis true on the first fault (did_nothingis still false untilRETRY_LIMIT).finish_fatal→flush_for_crash→ return, nodie_of. The retry succeeds. Claims then fail for the rest of the run. (macOS does not surfaceSA_RESETHAND, so that host staysRetryand takes the recovereddrop_markerpath. Windowson_crashhas the same latch if a later filter continues execution.) - Fix: Treat dying as the wait window, not a process lifetime. Either (a) raise it for the flush and clear it when the Unix handler returns without
die_of, or (b) callflush_in_flightfromfinish_fatal/on_crashand setDYINGonly on the paths that actually kill (die_of, theSIG_DFL+die_ofarm, Windows afterHandled(false)if you still want it). Pin with a test that runsflush_for_crash(or theRetryOneShotshape) and then successfully claims / launches recovery withoutreset_dying_for_test.
No other new P0–P3. Residual (not blocking): in-progress find_pending / recover_one / before_send / startup queue::gc_orphans can still occupy the worker past 50 ms — documented stuck-worker tradeoff, not a leftover of the old “start new recovery after dying” hole. Stack-overflow early complete_report still relies on nested SIGABRT reaching finish_original_report for the flush; the marker itself is already on disk.
CI (38595222834): all 8 jobs SUCCESS (ubuntu, windows, macos, MSRV, android, ios device + sim, private-API). I ran cargo metadata --offline --no-deps (workspace loads); I did not run the test suite locally.
Overall risk: Medium
The crash marker and frames are still written first, and the 50 ms drain still reaches worker_loop when a crash hits during the launch pass. The new issue is a process that does not die after that drain: report delivery then stays off.
Merge recommendation: request changes
Most important to fix
- P2 — Do not leave
DYINGset when the Unix handler returns to a live process (finish_fatalwithoutdie_of). Clear it on that return, or raise it only on paths that actually terminate.
Positive
- Marker-then-flush on every fatal Unix exit and the Windows filter; pipeline-thread skip; fork
OWNER_PIDgate; Drop order still matches “registered until the worker has stopped.” - The main remarge kept the dying short-circuit on heartbeat / bounded-scan / deferred-recovery instead of reopening the original P1 hole.
- Handshake tests plus subprocess burst/wedge cases still match the best-effort signal-safe contract. Desktop CI on this HEAD includes those tests and is green.
- #36 docs pass did not touch flush behaviour.
Sent by Cursor Automation: Bugsee code review
| /// **Async-signal-safe**: callable from a signal handler or exception filter. | ||
| /// No allocation, no locks, bounded time. | ||
| pub fn flush_for_crash(budget: Duration) -> bool { | ||
| DYING.store(true, Ordering::Release); |
There was a problem hiding this comment.
P2 Medium — DYING is latched for the process lifetime, but finish_fatal is not always followed by death.
flush_for_crash stores true here and never clears it except reset_dying_for_test. The docs above call this “the (up to budget) window”; try_claim / try_claim_lenient, run_recovery, the launch pass, and drain_memory all refuse work for as long as it stays set.
Unix finish_fatal (lib.rs:2450) always calls this, then the handler at 2861–2868 only die_ofs when did_nothing. The other fatal arm returns. On Linux/Android that includes first-fault RetryOneShot: call_host itself installs SIG_DFL for SA_RESETHAND, so host_declined is true. A host that mprotects a mapped page and does not reinstall then retries successfully. This process keeps running, and queued reports / recovery stay refused until it exits. (macOS does not surface SA_RESETHAND, so that host stays Retry and takes drop_marker. finish_original_report has the same latch before aborting_inside_host returns.)
Fix: raise dying for the wait, then clear it when the handler returns without die_of — or call flush_in_flight from finish_fatal and set DYING only on paths that actually kill. Pin with a claim/recovery after flush_for_crash that does not go through reset_dying_for_test.


Problem
A producer only queues an entry into the capture worker's channel; the worker appends it to the part file later. Events still queued (or mid-append) when the process dies are lost, and those are the last breadcrumbs, logs and network events before the fault. Measured with a subprocess crash test (1000 events, then SIGSEGV): without this change only 16-44 of 1000 events were recovered (15/250 for a smaller burst).
Design
bugsee_core::crash_flush:fork()child. The wake handles are never closed, so there is no descriptor-reuse race and noSIGPIPEfor C hosts.dyingflag, takes an atomic ticket, does one non-blockingwrite(2)(SetEvent) and polls an atomic ack for at most 50 ms (poll/ ack event + QPC).Flushmessage through the normal FIFO channel to the capture worker; when the worker acks, every entry queued before the crash has been appended, and the helper publishes the ticket.getpid,pthread_self, one non-blockingwrite,clock_gettimeandpoll. No allocation, no locks, no queue access. A stuck worker, poisoned allocator or dead helper costs exactly the budget; the 5 s watchdog is not involved. A fault on the worker or helper thread skips the wait.dyingflag: claims (claim::try_claim*, which queue and recovery go through), the in-memory drain, the launch-path recovery pass and the worker's wait for it all stop while it is set, so a report is never half-delivered in the death window and a crash inside the launch pass leaves the worker free to answer the flush. Fixes a 5 sUNOWNED_GRACEstall seen infork_safety.panic = "abort"without the native handler).Tradeoffs
Tests
bugsee-core/tests/crash_flush_e2e.rs: handshake, stuck/panicking flusher, last-registered-wins, pipeline-thread skip, dying flag and claims, a dying process recovers nothing and leaves the session pending, fork child gets its own helper, real pipeline drains everything.bugsee-core/tests/recovery_e2e.rs: torn/garbage part tails are ignored.bugsee/tests/crash_inflight.rs(subprocess,harness = false, unix + windows): burst with a long budget recovers all 1000; small burst under the real 50 ms budget recovers all 250; wedged worker makes the process wait the (shortened) budget and no more, and still reports. All three fail with the flush disabled. Stable 7/7 runs including under 16 CPU burners.Not verified
crash_flush.rswas only type-checked standalone, andbugsee-native's Windows path was not compiled locally.Stack
Stacked on #33 (fix/issue-10) -> #32 (fix/issue-14) -> #24 (fix/issue-9).
Closes #13
🤖 Generated with Claude Code