Skip to content

feat: defined shutdown/flush contract and opt-in SIGTERM/SIGINT flush (#8) - #31

Merged
krassx merged 78 commits into
mainfrom
fix/issue-8
Oct 14, 2026
Merged

krassx merged 78 commits into
mainfrom
fix/issue-8

Conversation

@krassx

@krassx krassx commented Oct 14, 2026

Copy link
Copy Markdown
Contributor

Closes #8. Stacked on #24 (fix/issue-9) — base is fix/issue-9; retarget to main after #24 merges.

Summary

  • Contract: flush(timeout) persists everything captured and tries to drain the queue (true only if empty; idempotent; not signal-safe). stop()/guard Drop persists and removes the liveness marker but never uploads — undelivered reports stay in the durable queue (disk mode). New Bugsee::shutdown(timeout) = flush then stop. Exit-path table in DESIGN.md §8 (at-least-once delivery).
  • Opt-in termination hook: Bugsee::install_termination_flush(timeout) (unix): async-signal-safe handler that only writes to a self-pipe and chains to the host's previous handler (SA_SIGINFO/plain, mask/ONSTACK/NODEFER carried, SA_RESETHAND emulated); a watcher thread flushes, then restores SIG_DFL and re-raises only if the disposition really is still ours and was default. SIG_IGN is left alone; handlers the host installs after ours are detected (flush only, never kill the host). Second signal kills at once. Windows: SetConsoleCtrlHandler returning FALSE (chain on).
  • LaunchOptions::flush_at_exit(timeout) via atexit (unix; no-op on Windows; skipped in fork children).
  • Stop no longer depends on the uploader (a hung upload used to leave the marker → false abnormal-exit next launch); stopping aborts in-flight flush/drain.

Review

Opus review (13 findings incl. late-installed host handlers, in-flight upload at stop, fork-child pipe race) addressed; 14 subprocess tests in crates/bugsee/tests/shutdown.rs (unix-only), ~27 s.

Not verified

Windows handler and Linux-specific paths (pipe2) were never run; SA_RESETHAND emulation has no test.

Test plan

  • fmt, clippy -D warnings, workspace tests on macOS (skipping the pre-existing hanging fork_safety test)
  • CI matrix

🤖 Generated with Claude Code

krassx added 16 commits October 14, 2026 14:10
…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)
…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)
- Document per exit path what is persisted, delivered and recovered
  (Bugsee::stop docs, DESIGN.md section 8).
- Bugsee::shutdown(timeout) = flush then stop. A stop during a flush now
  aborts the flush so the clean-stop acknowledgement bound holds.
- Bugsee::install_termination_flush: opt-in, chaining SIGTERM/SIGINT hook
  (self-pipe; flush on a normal thread; re-raises with the default
  disposition so the exit status is unchanged) and a Windows console
  control handler.
- LaunchOptions::flush_at_exit: atexit-based clean stop for deliberate
  process::exit.
- Subprocess tests for every exit path.

Closes #8

🤖 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)
- Detect a handler installed after ours (tokio, signal-hook) by asking the
  kernel what is in force; flush only, never terminate the host.
- A stop no longer waits on the uploader to end the session cleanly (an HTTP
  call cannot be interrupted); delivery is documented at-least-once.
- Fork-child re-install ordering, SIG_IGN left alone, watcher-alive and
  pending-signal tracking, host sa_mask/SA_ONSTACK/SA_NODEFER/SA_RESETHAND
  carried over, fork-aware install lock, pipe2, clean rollback on failure.
- flush_at_exit is unix only and skipped in fork children.
- Flush aborts between deliveries on stop (memory and every queue dir).
- Tests: late/SA_SIGINFO/ignored handlers, SIGINT, double signal, fork child,
  hanging transport; no fixed sleeps; temp dirs cleaned.

🤖 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)
# Conflicts:
#	crates/bugsee-core/src/runtime.rs
- 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)
The stop-time memory drain relies on the scheduled pass having had its one
attempt; only an explicit flush aborts on stop.

🤖 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)

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale comment

Deep review: shutdown/flush contract and opt-in termination hook

Reviewed the stacked diff (fix/issue-9…c462eef) against issue #8, Recorder::drop / drain_until_empty, the fork protocol, and the subprocess matrix in crates/bugsee/tests/shutdown.rs. This is not a style pass.

The contract itself is sound: flush drains, stop/Drop persist and drop the marker without uploading, shutdown is flush-then-stop, and a hung HTTP call no longer turns a deliberate stop into a false abnormal-exit. The unix self-pipe + watcher + late-handler sigaction probe is the right shape, and the 14 child-process tests cover the paths they claim.

Three issues should be fixed before merge. I did not run the test suite here (toolchain in this environment is 1.83; MSRV is 1.88).

Findings

  1. P1 — Windows Clippy is red because flush_at_exit state is not cfg(unix) (crates/bugsee/src/api.rs)
  2. P2 — SA_RESETHAND emulation kills the process on the first signal (crates/bugsee/src/termination.rs)
  3. P2 — Windows Ctrl+C/Break with the hook still files an abnormal-exit (crates/bugsee/src/termination.rs)

CI (at review time)

  • windows-latest: build OK, tests OK, Clippy failed (-D warnings)
  • android aarch64: Clippy failed on bugsee-core / bugsee-native / bugsee-ffi (job logs were not available; this crate’s new Windows/atexit code is not in that job)
  • ubuntu / macos were still running

Overall risk: Medium

Merge recommendation: Request changes

Most important to fix

  1. cfg(unix) the EXIT_FLUSH_* statics and the AtomicU32 import so Windows Clippy is green.
  2. Snapshot “should this wakeup terminate?” at handler time (pipe byte), instead of re-reading a slot that SA_RESETHAND has already rewritten. Add a Linux SA_RESETHAND subprocess test.
  3. On Windows Ctrl+C/Break, shutdown when this handler is the one that will let the process die (or document that hosts must still stop themselves, and drop the implication that the hook makes Ctrl+C a clean exit).

Residual (not blocking)

  • C ABI still has bugsee_flush / bugsee_stop only — no bugsee_shutdown / termination hook. C hosts can compose the two calls; they cannot get the signal helper.
  • DESIGN.md §6 still says the launch guard’s Drop flushes; §8 now says it never uploads. Pre-existing inaccuracy, now contradictory.
  • SA_RESETHAND / pipe2 / the Windows handler were not exercised (noted in the PR). The first of those is a real bug, not just a coverage gap.

What looks good

Stop no longer gates the marker on the uploader, so a slow PUT cannot invent a crash. stopping aborts drain_until_empty between deliveries. Late host handlers (tokio / signal-hook) are detected by asking the kernel, not by trusting the slot. Fork children without a watcher die() immediately instead of hanging on a dead pipe. Host SA_SIGINFO / sa_mask / SA_ONSTACK / SA_NODEFER are carried through. The subprocess tests actually kill children and relaunch over the same data dir.

Open in Web View Automation 

Sent by Cursor Automation: Bugsee code review

Comment thread crates/bugsee/src/api.rs
Comment thread crates/bugsee/src/termination.rs
Comment thread crates/bugsee/src/termination.rs Outdated
…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)
- Android: errno via libc::__errno (and the BSD variants), not __errno_location.
- Gate the flush_at_exit statics and import on unix (Windows clippy).
- SA_RESETHAND: the handler decides "terminate" before emulating the reset
  and ships it in the pipe byte; the watcher no longer re-reads the slot.
  New test with a one-shot host handler.
- Windows Ctrl+C/Break now run shutdown like close/logoff/shutdown; document
  that a host which swallows the event registers its handler after ours.
- DESIGN section 6 no longer says guard Drop flushes.

🤖 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)

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale comment

Deep review: shutdown/flush contract (re-review at e739839)

Re-reviewed the stacked diff (fix/issue-9…e739839) against issue #8, Recorder::drop / drain_until_empty, the fork protocol, and crates/bugsee/tests/shutdown.rs. This is not a style pass.

Prior findings

All three from the last review are fully addressed:

  1. P1 EXIT_FLUSH_* / AtomicU32 are #[cfg(unix)]. Windows Clippy is green.
  2. P2 SA_RESETHAND: the handler snapshots terminate before rewriting the slot and ships it in the pipe byte (TERMINATE = 0x80). The watcher no longer re-reads the slot. sigterm_oneshot_handler covers the first-signal-must-not-kill case.
  3. P2 Windows Ctrl+C/Break now call Bugsee::shutdown; the docs state that a swallowing host must register after the SDK.

CI at e739839 (38554525334) is SUCCESS on ubuntu, macos, windows (including Clippy -D warnings), Android/iOS lint, and MSRV.

Findings

  1. P2 — drain_until_empty starts a blocking disk upload after stopping already aborted the memory drain (crates/bugsee-core/src/runtime.rs)

No P0/P1. I did not run the suite here; the judgement is from the current code plus the green CI run above.

Overall risk: Medium

Merge recommendation: Request changes

Most important to fix

Re-check stopping after drain_memory returns, and again before each deliver_queued. When it is set, return immediately so the uploader can take UploadMsg::Stop and run the 1.5 s memory drain that the contract depends on.

Residual (not blocking)

  • C ABI still exposes bugsee_flush / bugsee_stop only. C hosts can compose the two calls; they cannot get install_termination_flush.
  • Windows console handler and Linux pipe2 are still not exercised by tests (shutdown.rs is #![cfg(unix)]). The Windows path is now the right shape on paper.
  • macOS still does not report SA_RESETHAND (already documented on the native crash path). One-shot / BSD signal() hosts stay on the flush-only side; safer than the old first-signal kill, but a second SIGTERM will not become default-terminate there.
  • Service-control stop is not a console event; documented, host must call shutdown.

What looks good

The contract is still sound: flush drains, stop/Drop persist and drop the marker without uploading, shutdown is flush-then-stop, and a hung HTTP call no longer invents a crash. The unix self-pipe + watcher + late-handler sigaction probe is the right shape. Fork children without a watcher die() immediately. flush_at_exit is unix-only and skipped in fork children. The 15 subprocess cases actually kill children and relaunch over the same data dir, including the new oneshot test.

Open in Web View Automation 

Sent by Cursor Automation: Bugsee code review

Comment thread crates/bugsee-core/src/runtime.rs
…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)
- 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)
Records how each session ends (ok/errored/crashed/abnormal) behind an
off-by-default option. Recovered crashes settle their session exactly once.
Nothing is sent: the backend has no session-health ingest endpoint.

Refs #12

🤖 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)

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale comment

Deep review: shutdown/flush contract (re-review at 50da593)

Re-reviewed the unique diff versus the current base (fix/issue-12 @ 5a844b8 … 50da593) and the merge of a newer origin/fix/issue-12 (native CrashBackend, origin walks, FFI audit, lock_backtrace). Unique shutdown/flush code is unchanged since 3703010. This is not a style pass.

What changed this round

50da593 remarges origin/fix/issue-12 @ 5a844b8. Unique delta vs that base is still the eight #8 files (termination.rs, shutdown.rs, api.rs lifecycle, options.rs flush_at_exit, drain_until_empty / stopping, DESIGN §8, the storage-failures stop-during-flush test). termination.rs and shutdown.rs have an empty diff against 3703010. The only unique-file edit in the merge itself is Bugsee::upload() taking the origin walk off the global lock — that is #1/#12 work, not #8.

Prior findings

All four from earlier rounds stay fully addressed:

  1. P1 EXIT_FLUSH_* / AtomicU32 are #[cfg(unix)].
  2. P2 SA_RESETHAND: terminate is decided in the handler and shipped in the pipe byte (TERMINATE = 0x80). sigterm_oneshot_handler covers the first-signal-must-not-kill case.
  3. P2 Windows Ctrl+C/Break call Bugsee::shutdown; docs say a swallowing host registers after the SDK.
  4. P2 drain_until_empty (546024b): re-checks stopping after drain_memory and after each claim, so a stop that landed in the memory pass cannot start a blocking disk PUT.

Merge intersection (re-checked against the new base)

Still coherent with #12/#13/#10/#14/#1/#23:

  • Marker and session lock still require the capture worker’s ack; health finish_clean still runs on any non-panicking stop. Uploader detach no longer invents a crash.
  • drain_memory honors both stopping (flush abort) and is_dying() (crash-time flush). New disk claims are refused while dying (try_claim_lenient).
  • revive_after_fork still forgets the parent session and crash-flush helper, so a child shutdown cannot clear the parent’s marker. A fork child that has not re-armed still die()s immediately (OWNER_PID / WATCHER_ALIVE).
  • register_exit_flush still runs before recover_on_launch_path; atexit is a no-op until there is something to flush, and a child whose pid does not match EXIT_FLUSH_PID does not run it.
  • Native POSIX capture still owns SIGABRT/SIGSEGV/friends, not SIGTERM/SIGINT. The termination hook and the crash handler do not share a slot.
  • lock_backtrace (origin walks) is not taken on the flush/stop path; a concurrent fork waits the walk out instead of handing the child a stuck backtrace mutex.

Tried to break: second signal vs SA_RESETHAND, late host handler, ignored SIGTERM, fork child without watcher, atexit in a child, stop during an in-flight memory pass then disk PUT, Drop vs health vs marker. Those paths still match the contract and the tests that exist for them.

Findings

No remaining unique P0–P3.

CI (run 38577916981 at 50da593)

All 8 checks SUCCESS: ubuntu (includes the unix shutdown.rs subprocess matrix), windows Build/Test/Clippy, macos, MSRV 1.88, Android aarch64, iOS device and sim, macOS private-API job.

I did not run the suite locally (this environment is rustc 1.83; MSRV is 1.88). The judgement is from the current code plus the green CI jobs above.

Overall risk: Low

Merge recommendation: Approve (after/with the stacked #12 base as before).

Residual (not blocking)

  • C ABI still exposes bugsee_flush / bugsee_stop only. C hosts can compose the two calls; they cannot get install_termination_flush or flush_at_exit. bugsee_stop’s rustdoc still says “flushing pending work” — persist + the 1.5 s memory drain, not a disk-queue upload.
  • shutdown.rs is #![cfg(unix)]. The Windows console handler is the right shape and now compiles and Clippy-cleans on windows-latest; it is still not exercised as a subprocess.
  • macOS still does not report SA_RESETHAND. One-shot / BSD signal() hosts stay flush-only after the first signal.
  • Service-control stop is not a console event; documented, host must call shutdown.
  • An HTTP call already in flight cannot be interrupted. That is the documented at-least-once / 2 s detach bound. Scheduled drain_all still does not honor stopping (intentional; only an explicit flush aborts).
  • DESIGN §12 still says shutdown / install_termination_flush “neither exists on this branch yet”. They do, in §8. Stale #12 wording; not a behavioral bug.

What looks good

The contract still matches the code after the stack merge: flush drains, stop/Drop persist and drop the marker without uploading, shutdown is flush-then-stop, and a hung HTTP call no longer invents a crash. The unix self-pipe + watcher + late-handler probe is unchanged and still the right shape. Fork children without a watcher die() immediately. The subprocess matrix still kills children and relaunches over the same data dir. Windows Clippy is green on this tree.

Open in Web View Automation 

Sent by Cursor Automation: Bugsee code review

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale comment

Deep review: shutdown/flush contract (re-review at f5dcac4)

Re-reviewed the unique diff versus the current base (fix/issue-12 @ fe63783 … f5dcac4) and the surrounding Drop / drain / crash-flush / fork / native paths. Unique shutdown/flush code is unchanged since 3703010. This is not a style pass.

What changed this round

f5dcac4 remarges origin/fix/issue-12 @ fe63783 (libc de-dupe 1ecfca8 already in that stack). The trees of f5dcac4 and the last-reviewed 50da593 are identical (9444ea77); the bases fe63783 and 5a844b8 are likewise identical (5722f5e9). Unique delta vs base is still the eight #8 files (termination.rs, shutdown.rs, api.rs lifecycle, options.rs flush_at_exit, drain_until_empty / stopping, DESIGN §8, the storage-failures stop-during-flush test). crates/bugsee/Cargo.toml has a single [target.'cfg(unix)'.dependencies] libc table; cargo metadata loads (the duplicate-table P0 that hit sibling stack PRs is not on this tree).

Prior findings

All four from earlier rounds stay fully addressed:

  1. P1 EXIT_FLUSH_* / AtomicU32 are #[cfg(unix)].
  2. P2 SA_RESETHAND: terminate is decided in the handler and shipped in the pipe byte (TERMINATE = 0x80). sigterm_oneshot_handler covers the first-signal-must-not-kill case.
  3. P2 Windows Ctrl+C/Break call Bugsee::shutdown; docs say a swallowing host registers after the SDK.
  4. P2 drain_until_empty (546024b): re-checks stopping after drain_memory and after each claim, so a stop that landed in the memory pass cannot start a blocking disk PUT.

Merge intersection (re-checked)

Still coherent with #12/#13/#10/#14/#1/#23/#9:

  • Marker and session lock still require the capture worker’s ack; health finish_clean still runs on any non-panicking stop. Uploader detach no longer invents a crash. Drop still waits 2 s for the uploader ack; it just does not gate the marker on the result.
  • drain_memory honors both stopping (flush abort) and is_dying() (crash-time flush). New disk claims are refused while dying (try_claim_lenient).
  • revive_after_fork still forgets the parent session and crash-flush helper, so a child shutdown cannot clear the parent’s marker. A fork child that has not re-armed still die()s immediately (OWNER_PID / WATCHER_ALIVE).
  • register_exit_flush still runs before recover_on_launch_path; atexit is a no-op until there is something to flush, and a child whose pid does not match EXIT_FLUSH_PID does not run it.
  • Native POSIX capture still owns SIGABRT/SIGSEGV/friends, not SIGTERM/SIGINT. The termination hook and the crash handler do not share a slot.
  • lock_backtrace (origin walks) is not taken on the flush/stop path.

Tried to break, and did not file:

  • Ignored write return in the unix handler: on this O_NONBLOCK 1-byte pipe, EINTR is from pipe_wait (full + blocking); a full pipe is EAGAIN; the default-disposition path cannot fill PIPE_BUF. OWNER_PID is stored after PIPE_W with release/acquire, so a live owner always sees a valid fd. Watcher read already retries EINTR.
  • Drop vs is_dying() during a hung upload: Drop still sits in recv_timeout(2s) for the uploader; a concurrent native crash aborts at the 50 ms budget first, so session.end() does not run and the marker stays. The unique change (not gating the marker on uploader_acked) only applies after that wait returns.

Findings

No remaining unique P0–P3.

CI (run 38579287636 at f5dcac4)

All 8 checks SUCCESS: ubuntu (includes the unix shutdown.rs subprocess matrix), windows Build/Test/Clippy, macos, MSRV 1.88, Android aarch64, iOS device and sim, macOS private-API job.

I did not run the suite locally (this environment is rustc 1.83; MSRV is 1.88). The judgement is from the current code plus the green CI jobs above. cargo metadata --offline succeeds on this tree.

Overall risk: Low

Merge recommendation: Approve (after/with the stacked #12 base as before).

Residual (not blocking)

  • C ABI still exposes bugsee_flush / bugsee_stop only. C hosts can compose the two calls; they cannot get install_termination_flush or flush_at_exit. bugsee_stop’s rustdoc still says “flushing pending work” — persist + the 1.5 s memory drain, not a disk-queue upload.
  • shutdown.rs is #![cfg(unix)]. The Windows console handler is the right shape and now compiles and Clippy-cleans on windows-latest; it is still not exercised as a subprocess.
  • macOS still does not report SA_RESETHAND. One-shot / BSD signal() hosts stay flush-only after the first signal.
  • Service-control stop is not a console event; documented, host must call shutdown.
  • An HTTP call already in flight cannot be interrupted. That is the documented at-least-once / 2 s detach bound. Scheduled drain_all still does not honor stopping (intentional; only an explicit flush aborts).
  • DESIGN §12 still says shutdown / install_termination_flush “neither exists on this branch yet”. They do, in §8. Stale #12 wording; not a behavioral bug.

What looks good

The contract still matches the code after the stack merge: flush drains, stop/Drop persist and drop the marker without uploading, shutdown is flush-then-stop, and a hung HTTP call no longer invents a crash. The unix self-pipe + watcher + late-handler probe is unchanged and still the right shape. Fork children without a watcher die() immediately. The subprocess matrix still kills children and relaunches over the same data dir. Windows Clippy is green on this tree.

Open in Web View Automation 

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)
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)
Tree unchanged: this branch already contains #13's content.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Tree unchanged: this branch already contains #12's content.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
@krassx
krassx changed the base branch from fix/issue-12 to main October 14, 2026 16:28

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deep review: shutdown/flush contract (re-review at fa0a34e)

Re-reviewed the unique diff versus the current GitHub base (main @ 78424d4 … fa0a34e) and the surrounding Drop / drain / crash-flush / fork / health / native paths. Unique shutdown/flush code is unchanged in substance since 3703010. This is not a style pass.

What changed this round

fa0a34e remarges origin/main (squash of #12) after #12 landed; GitHub retargeted this PR to main. Unique delta vs main is still the eight #8 files (termination.rs, shutdown.rs, api.rs lifecycle, options.rs flush_at_exit, drain_until_empty / stopping, DESIGN §8, the storage-failures stop-during-flush test). Against last-reviewed f5dcac4, those files differ only by inherited #36 wording in DESIGN §10 and the already-on-main CRASH_FLUSH_ACK_TIMEOUT 10 s comment in runtime.rs. termination.rs / shutdown.rs / the flush/stop/shutdown API are an empty diff versus f5dcac4.

crates/bugsee/Cargo.toml still has a single [target.'cfg(unix)'.dependencies] libc table; cargo metadata --offline --no-deps loads.

Prior findings

All four from earlier rounds stay fully addressed:

  1. P1 EXIT_FLUSH_* / AtomicU32 are #[cfg(unix)].
  2. P2 SA_RESETHAND: terminate is decided in the handler and shipped in the pipe byte (TERMINATE = 0x80). sigterm_oneshot_handler covers the first-signal-must-not-kill case.
  3. P2 Windows Ctrl+C/Break call Bugsee::shutdown; docs say a swallowing host registers after the SDK.
  4. P2 drain_until_empty (546024b): re-checks stopping after drain_memory and after each claim, so a stop that landed in the memory pass cannot start a blocking disk PUT.

Merge intersection (re-checked against landed #12 / #13 on main)

Still coherent:

  • Marker and session lock still require the capture worker’s ack; health finish_clean still runs on any non-panicking stop. Uploader detach no longer invents a crash. Drop still waits 2 s for the uploader ack; it just does not gate the marker on the result.
  • drain_memory honors both stopping (flush abort) and is_dying() (crash-time flush). New disk claims are refused while dying (try_claim_lenient). Stop-time memory drain still passes honor_stop = false so the 1.5 s last attempt is not aborted by the flag it itself depends on.
  • revive_after_fork still forgets the parent session and crash-flush helper, so a child shutdown cannot clear the parent’s marker. A fork child that has not re-armed still die()s immediately (OWNER_PID / WATCHER_ALIVE). Re-arm starts a new watcher and returns because *installed survives the fork (ForkMutex does not reset the bool), so it does not re-sigaction over itself.
  • register_exit_flush still runs before recover_on_launch_path; atexit is a no-op until there is something to flush, and a child whose pid does not match EXIT_FLUSH_PID does not run it.
  • Native POSIX capture still owns SIGABRT/SIGSEGV/friends, not SIGTERM/SIGINT. The termination hook and the crash handler do not share a slot.

Tried to break, and did not file:

  • Ignored write return in the unix handler: on this O_NONBLOCK 1-byte pipe, a full pipe is EAGAIN and the default-disposition path cannot fill PIPE_BUF. OWNER_PID is stored after PIPE_W with release/acquire.
  • Drop vs is_dying() during a hung upload: Drop still sits in recv_timeout(2s) for the uploader; a concurrent native crash aborts at the 50 ms budget first (or immediately once crash_flush is dropped), so session.end() does not run and the marker stays.
  • break 'dirs after a just-finished disk delivery when stopping is set: the next loop iteration returns false at the top; it does not start another PUT.

Findings

No remaining unique P0–P3.

CI (run 38598330393 at fa0a34e)

  • ubuntu: SUCCESS (includes the unix shutdown.rs subprocess matrix)
  • windows: SUCCESS — Build, Test, and Clippy (-D warnings)
  • MSRV 1.88, Android aarch64, iOS sim, macOS private-API: SUCCESS
  • macos-latest tests: still in progress at review time. Unique unix tests already passed on ubuntu.
  • Prior SHA f5dcac4 run 38579287636 was all 8 SUCCESS.

I did not run the suite locally (this environment is rustc 1.83; MSRV is 1.88). The judgement is from the current code plus the green CI jobs above. cargo metadata --offline succeeds on this tree.

Overall risk: Low

Merge recommendation: Approve

The stacked #12 base is now on main, so this can land on its own.

Residual (not blocking)

  • C ABI still exposes bugsee_flush / bugsee_stop only. C hosts can compose the two calls; they cannot get install_termination_flush or flush_at_exit. bugsee_stop’s rustdoc still says “flushing pending work” — persist + the 1.5 s memory drain, not a disk-queue upload.
  • shutdown.rs is #![cfg(unix)]. The Windows console handler is the right shape and compiles and Clippy-cleans on windows-latest; it is still not exercised as a subprocess.
  • macOS still does not report SA_RESETHAND. One-shot / BSD signal() hosts stay flush-only after the first signal.
  • Service-control stop is not a console event; documented, host must call shutdown.
  • An HTTP call already in flight cannot be interrupted. That is the documented at-least-once / 2 s detach bound. Scheduled drain_all still does not honor stopping (intentional; only an explicit flush aborts).
  • DESIGN §10 still says shutdown / install_termination_flush “neither exists on this branch yet”. They do, in §8. Stale #12 wording; not a behavioral bug.

What looks good

The contract still matches the code after the remarge onto main: flush drains, stop/Drop persist and drop the marker without uploading, shutdown is flush-then-stop, and a hung HTTP call no longer invents a crash. The unix self-pipe + watcher + late-handler probe is unchanged and still the right shape. Fork children without a watcher die() immediately. The subprocess matrix still kills children and relaunches over the same data dir. Windows Clippy is green on this tree.

Open in Web View Automation 

Sent by Cursor Automation: Bugsee code review

@krassx
krassx merged commit 4b0b3d1 into main Oct 14, 2026
9 checks passed
krassx added a commit that referenced this pull request Oct 14, 2026
* feat: degrade to in-memory reporting on read-only/ephemeral file systems (#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)

* fix: address review of data-directory fallback (#9)

- 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)

* fix: second review round for data-directory fallback (#9)

- 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)

* feat(bugsee): populate utc_offset, hardware model and Windows locale

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)

* fix(bugsee): address review of hostinfo (iOS model, tm_gmtoff gating, locale)

Closes #16

🤖 Generated with [Claude Code](https://claude.com/claude-code)

* fix(core): exactly-once recovery and queue draining across processes 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)

* feat: define the shutdown/flush contract; opt-in SIGTERM/SIGINT flush

- Document per exit path what is persisted, delivered and recovered
  (Bugsee::stop docs, DESIGN.md section 8).
- Bugsee::shutdown(timeout) = flush then stop. A stop during a flush now
  aborts the flush so the clean-stop acknowledgement bound holds.
- Bugsee::install_termination_flush: opt-in, chaining SIGTERM/SIGINT hook
  (self-pipe; flush on a normal thread; re-raises with the default
  disposition so the exit status is unchanged) and a Windows console
  control handler.
- LaunchOptions::flush_at_exit: atexit-based clean stop for deliberate
  process::exit.
- Subprocess tests for every exit path.

Closes #8

🤖 Generated with [Claude Code](https://claude.com/claude-code)

* feat: general PII scrubber and report rate limiting

Add a regex-free scrubber (emails, token shapes, keyed secrets, card-like
numbers, home-dir usernames; allow/deny/sensitive_keys; off switch) applied
at report assembly over every JSON section after before_send, and token
bucket rate limiting (per-signature + global, crash floor, bounded memory)
applied before the durable queue, surfaced via DropReason::RateLimited and
a bugsee.reports_suppressed attribute. Both on by default, opt-out via
LaunchOptions::disable_scrubbing / disable_rate_limiting.

Closes #11

🤖 Generated with [Claude Code](https://claude.com/claude-code)

* fix(core): arbitrate cross-process claims and session liveness with OS 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)

* fix: harden the termination hook and shutdown after review

- Detect a handler installed after ours (tokio, signal-hook) by asking the
  kernel what is in force; flush only, never terminate the host.
- A stop no longer waits on the uploader to end the session cleanly (an HTTP
  call cannot be interrupted); delivery is documented at-least-once.
- Fork-child re-install ordering, SIG_IGN left alone, watcher-alive and
  pending-signal tracking, host sa_mask/SA_ONSTACK/SA_NODEFER/SA_RESETHAND
  carried over, fork-aware install lock, pipe2, clean rollback on failure.
- flush_at_exit is unix only and skipped in fork children.
- Flush aborts between deliveries on stop (memory and every queue dir).
- Tests: late/SA_SIGINFO/ignored handlers, SIGINT, double signal, fork child,
  hanging transport; no fixed sleeps; temp dirs cleaned.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

* fix: keep draining existing data dirs that cannot take new files (#9)

- 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)

* fix(scrub): second-review hardening of the PII scrubber

Numeric secrets under secret keys and integer card numbers are redacted;
colon-form prose ("invalid token: expected ident") and "my_app::token:"
are left alone; Cookie headers take the whole line; escaped quotes inside
quoted values are honoured; the "signatures" exemption moves out of the
shared key matcher into crash.json handling; tighter token shapes (AWS,
hf_, sk-, prose after Bearer); contextual stem exclusions (author,
tokenizer, session_state, method_signature); exact key/sig only redact
credential-looking strings; request.json no longer double-scrubs
metadata; file names (icon@2x.png) and /Users/Shared are not PII; the
pre-check handles \u escapes and separated key stems. Test secrets are
assembled from halves for push protection.

Refs #11

🤖 Generated with [Claude Code](https://claude.com/claude-code)

* fix(core): harden the file-lock claims (second review)

- 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)

* fix: keep the scheduled memory drain independent of the stop flag

The stop-time memory drain relies on the scheduled pass having had its one
attempt; only an explicit flush aborts on stop.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

* test: gate unix-only helpers so Windows clippy stays clean (#9)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

* feat(native): best-effort flush of in-flight events at crash time

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)

* fix(core): queue the previous crash on the launch path before launch 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)

* fix: drain every trusted unwritable default tree, not just one; stale 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)

* fix(scrub,runtime): second re-review fixes

Stem exclusions for cancellation_token, www-authenticate/authentication,
token_endpoint/url/uri/expiry/ttl; the colon-prose leniency applies only to
bare 'token', so password/passwd/secret/api_key values are always redacted;
the rate limiter now runs before the on-disk blacklist read so a denied
storm is lock + hash only (test counts disk reads).

Refs #11

🤖 Generated with [Claude Code](https://claude.com/claude-code)

* fix: PR #31 review and CI fixes

- Android: errno via libc::__errno (and the BSD variants), not __errno_location.
- Gate the flush_at_exit statics and import on unix (Windows clippy).
- SA_RESETHAND: the handler decides "terminate" before emulating the reset
  and ships it in the pipe byte; the watcher no longer re-reads the slot.
  New test with a one-shot host handler.
- Windows Ctrl+C/Break now run shutdown like close/logoff/shutdown; document
  that a host which swallows the event registers its handler after ours.
- DESIGN section 6 no longer says guard Drop flushes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

* fix(core): run launch-path recovery on a bounded helper, after the crash 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)

* fix(native): harden in-flight crash flush (review feedback, #13)

- 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)

* fix(core): review round 3 on lock claims

- 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)

* feat(health): local session health records for release health (#12)

Records how each session ends (ok/errored/crashed/abnormal) behind an
off-by-default option. Recovered crashes settle their session exactly once.
Nothing is sent: the backend has no session-health ingest endpoint.

Refs #12

🤖 Generated with [Claude Code](https://claude.com/claude-code)

* fix(core): make the lock-less heartbeat robust to clock skew and slow 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)

* fix(core): keep unique crashes the launch pass cannot queue; scan newest-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)

* fix(health): address review of release health (#12)

Register the backend session eagerly at launch when release_health is on, finish
records on any non-panicking stop, ignore stale panic snapshots, memory-mode and
empty-dir guards, ack tombstones, generation floor, no file I/O under the facade
lock, gc, monotonic durations, unstable/hidden API, docs moved to section 10.

Refs #12

🤖 Generated with [Claude Code](https://claude.com/claude-code)

* fix(core): tried sessions first, and a queue-cap refusal is not an attempt

- 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)

* fix(native): address review of the in-flight flush (#13)

- 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)

* fix: never recover the same directory twice in one launch (#9)

- 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)

* fix(core): a lock file that vanishes under a scan is not evidence of 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)

* fix: a stop during a flush's memory pass must not start a disk upload

Re-check `stopping` after the memory drain and after each claim, so the
uploader reaches its stop arm and the stop-time memory drain runs.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

* fix(health): keep panic.info for abort correlation, no eager registration in memory mode

Address review on #35: the worker-thread snapshot is expired by a detached
thread after the process survived 1 s instead of deleted in the hook, and the
eager session registration requires a disk session.

Refs #12

🤖 Generated with [Claude Code](https://claude.com/claude-code)

* fix(core): a failed enqueue write is not a recovery attempt

🤖 Generated with [Claude Code](https://claude.com/claude-code)

* fix(health): do not expire panic.info from a hook timer

Address re-review on #35: leave the worker-thread snapshot alone (abort
correlation) and rule out a surviving process's leftover at recovery with a
5 s tail. Adds a worker-thread aborting-panic case to the crash matrix.

Refs #12

🤖 Generated with [Claude Code](https://claude.com/claude-code)

* fix(core): a session the launch pass defers is picked up even if the pass outlived the worker's wait

🤖 Generated with [Claude Code](https://claude.com/claude-code)

* fix(health): mark a panicking drop so a slow unwind is still a crash

Address re-review on #35: panic_is_recent no longer guesses from the clock when
the recorder was dropped while unwinding; empty rotation directories no longer
count as capture activity.

Refs #12

🤖 Generated with [Claude Code](https://claude.com/claude-code)

* fix(core): deferred launch-pass recovery uses the recovery directory, not only the capture tree

🤖 Generated with [Claude Code](https://claude.com/claude-code)

* fix(scrub): do not redact public OIDC/OAuth metadata keys

authorization_endpoint/url/uri, oauth* names (oauth_token and
oauth_client_secret still hit the token/secret stems), auth_method and
token_endpoint_auth_* are not secrets. Tests in bugsee-core and
bugsee-reqwest (query params and headers).

Refs #11

🤖 Generated with [Claude Code](https://claude.com/claude-code)

* fix: remember every undeletable retired bundle, not just delivered ones (#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)

* test(core): wait on an observable condition in the full-queue test

🤖 Generated with [Claude Code](https://claude.com/claude-code)

* fix(scrub): narrow the oauth exclusion to public metadata

oauth_verifier, oauth_pin and a bare oauth key stay redacted again;
oauth_client_id/redirect/scope/issuer/endpoint/... and auth_url/uri/endpoint
are public. Tests in bugsee-core and bugsee-reqwest.

Refs #11

🤖 Generated with [Claude Code](https://claude.com/claude-code)

* Merge origin/fix/issue-9 into fix/issue-11

Adapt the new storage_failures test to assemble_docs' scrubber argument.

Refs #11

🤖 Generated with [Claude Code](https://claude.com/claude-code)

* fix(core): declare kernel32 calls once (clashing_extern_declarations 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)

* fix(scrub): free-text oauth_verifier/oauth_pin and short pin/otp codes

Free-text keyed matching now follows the structured matcher for the OAuth
1.0a verifier/PIN and redacts 4-10 digit pin=/otp= values.

Refs #11

🤖 Generated with [Claude Code](https://claude.com/claude-code)

* fix(scrub): structured matching redacts code_verifier (PKCE)

Refs #11

🤖 Generated with [Claude Code](https://claude.com/claude-code)

* docs: keep the corrected native feature comment from main

* fix: de-duplicate the unix libc dependency after merging main (#27's optional form vs the unconditional one)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

* fix(core): make the crash-flush handshake robust on slow runners

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)

* docs: describe backend behaviour generically in release-health notes

Refs #12

🤖 Generated with [Claude Code](https://claude.com/claude-code)

* docs: keep design notes generic (no private-component references) (#36)

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)

* docs: drop numeric server error codes from notes and comments (#37)

Comments and notes only; no behaviour change.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
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.

Graceful shutdown and flush contract (SIGTERM, containers, host signal handling)

1 participant