Skip to content

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

Merged
krassx merged 1 commit into
mainfrom
docs/sanitize-private-references
Oct 14, 2026
Merged

krassx merged 1 commit into
mainfrom
docs/sanitize-private-references

Conversation

@krassx

@krassx krassx commented Oct 14, 2026

Copy link
Copy Markdown
Contributor

Generalises recent documentation and comments so they describe behaviour in generic terms (the backend, the ingestion pipeline, the existing report variants) rather than naming components of other projects.

Changed:

  • docs/design/mobile-native-capture.md and docs/design/out-of-process-monitor.md: reworded provenance notes, symbolication and routing descriptions, risks and decisions; claims that could only be backed by non-public sources are now marked as unverified.
  • docs/ffi-audit.md, DESIGN.md: server verdicts described by meaning instead of numeric codes.
  • Two code comments (runtime.rs, hostinfo.rs).

Docs and comments only; no behaviour change. fmt and clippy pass.

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

@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.

Docs/comments only at df4d500. Intent is right: drop sibling-SDK and worker-path names, and mark claims that can no longer be cited as [U]. The two .rs comment edits are accurate (12004 → too-many-similar matches transport.rs; hw.machine is what apps read via utsname). No behaviour change.

The rewrite does more than rename. In three places it turns a Bugsee-specific, previously verified fact into a generic industry/planning claim that the rest of this tree contradicts. That is what will actually mislead Phase 3/4 and the OOP engine choice.

Findings

  1. P2 docs/design/mobile-native-capture.md:143 (also 73, 78, 268) — BSD-default / Mach-opt-in / simulator-off is generalised to “common iOS crash-SDK defaults”. That was a Bugsee iOS configuration; PLCrashReporter, KSCrash/Sentry and Crashlytics default to Mach and do run on the simulator.
  2. P2 docs/design/mobile-native-capture.md:9 vs leftover [V] at 97, 101, 176 — the new legend and §9 say Crashpad/PLCrashReporter/task_set_exception_ports are [U], but Crashpad process-model/packaging and “tvOS has no Mach exception ports” are still [V].
  3. P2 docs/design/out-of-process-monitor.md:73 — “Crashpad is the planned Android engine” replaces “sibling Android SDK already vendors it”. This doc’s own Phase B and D1 still treat Crashpad as conditional on a measured gap and on scenario C.

Residual (not blocking)

DESIGN.md §15–16, PROGRESS.md, and facade_e2e.rs still name worker/crash/rust.py, worker/jobs/bundle.py, bugsee/report-bundle-structure, and Android/iOS SDK class paths. Out of the files this PR listed, but they are the same class of private-component reference.

Verdict

  1. Overall risk: Medium
  2. Merge recommendation: Request changes
  3. Fix before merge: stop calling BSD-default “common”; relabel leftover Crashpad/tvOS [V] to [U] (or drop them); replace “planned Android engine” with the Phase B/D1 hedge
  4. What is in good shape: sibling-SDK [V]→[U] in §2.3/§3.1/§3.2 is the right move; 12003/12004 wording matches map_app_error; no code behaviour change

CI on this SHA: MSRV, Ubuntu, Windows, macOS tests, Android lint, iOS-sim lint SUCCESS; iOS-device lint and the macOS private-API job were still pending at review. I did not run cargo test.

Open in Web View Automation 

Sent by Cursor Automation: Bugsee code review

### 5.1 Apple mobile

**End state:** PLCrashReporter behind `CrashBackend` (`name() == "plcrashreporter"`), BSD signal handler, Mach exceptions off by default, tvOS BSD-only, `NSException` and C++ hooks as a later increment. This follows the agreed direction in #4 and matches the shipping iOS SDK's defaults **[V]**, which is also the evidence that it works on real devices.
**End state:** PLCrashReporter behind `CrashBackend` (`name() == "plcrashreporter"`), BSD signal handler, Mach exceptions off by default, tvOS BSD-only, `NSException` and C++ hooks as a later increment. This follows the agreed direction in #4 and matches common iOS crash-SDK defaults **[U]**.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Medium — this is not a common iOS crash-SDK default.

Problem: The end-state (“BSD signal handler, Mach exceptions off by default, tvOS BSD-only”) used to be justified as matching the shipping Bugsee iOS SDK [V]. That justification was product-specific and true. Replacing it with “matches common iOS crash-SDK defaults [U]” changes the proposition. PLCrashReporter’s own default is Mach; KSCrash/Sentry and Crashlytics also use Mach and keep capture enabled on the simulator.

Same generalisation, same file:

  • §3.1: “A common configuration is the BSD signal handler by default…”
  • §3.1 Simulator: “Existing iOS crash SDKs commonly compile their crash layer out on the Simulator… PLCrashReporter is unavailable”
  • D6 option: “follow common practice (disabled)”

Impact: Phase 3 is specified as BSD-default because of Bugsee coexistence/parity (scenario C), not because that is what PLCrashReporter or the industry does. A later reader who “corrects” toward PLCrashReporter’s Mach default diverges from the other Apple SDKs; a reader who keeps BSD thinks they have industry backing they do not.

Scenario: Implementer of name() == "plcrashreporter" opens PLCrashReporter’s PLCrashReporterConfig, sees Mach as the default, and treats this paragraph as stale. Simulator CI (Phase 2 / D6) is also easier to skip if “common practice” is “disabled”.

Fix: Keep the end-state, drop the industry claim. e.g. “BSD default, Mach opt-in, tvOS BSD-only — a coexistence choice, not PLCrashReporter’s default [U]”. D6 should stay “test it in Phase 2”, not “follow common practice (disabled)”.

- **[V]** verified in this repo, or against the behaviour of the existing wire format and the backend (described generically here, per #4's rule that private repositories are not named, linked or quoted).
- **[U]** from memory, from the issue text, or from general platform knowledge; not verified. Confirm before relying on it.
- No third-party source (PLCrashReporter, Crashpad, ART, bionic, Apple headers) was available locally. Everything about them is **[U]** unless it was observed through Bugsee's own SDK code.
- No third-party source (PLCrashReporter, Crashpad, ART, bionic, Apple headers) was available locally. Everything about them is **[U]**.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Medium — the new legend forbids [V] on Crashpad/PLCR, but leftover [V] markers still assert it.

Problem: This PR deletes the old exception (“unless it was observed through Bugsee's own SDK code”) and §9 now lists “PLCrashReporter, Crashpad and Breakpad behaviour” and task_set_exception_ports under Not verified. These cells were not relabelled:

  • §3.3 Process model: “Out-of-process helper on Android [V]” (line 97)
  • §3.3 Packaging: “C++ tree plus a handler and a launch path [V]” (line 101)
  • Deferred table: “tvOS has no Mach exception ports [V]” (line 176)

§3.2’s Crashpad launch constraints were correctly moved to [U] in this PR; the table was not. Line 98 is mixed: backend stack-walking of a harvested minidump is in-repo (DESIGN.md §15 / recovery.rs); Crashpad’s “all threads, registers, modules, stacks” is not.

Impact: [V] vs [U] is this doc’s rule for what Phase 1/4 may skip re-checking. A leftover [V] on Crashpad packaging is exactly the cost Phase B is supposed to re-measure (handler extract, API 21–28 app_process, API 29+ linker).

Scenario: Phase 4 go/no-go treats “C++ tree plus a handler and a launch path” as already verified and only spikes dump quality. tvOS work treats Mach ports as a verified prohibition while §3.1 and §9 now call the header fact [U].

Fix: Relabel those Crashpad/tvOS cells [U]. If line 98 stays [V], attach it only to “the backend already stackwalks a harvested minidump”.


### B. Crashpad as the out-of-process engine
Apache-2.0 **[U]**; handler is a separate executable (`crashpad_handler`) **[U]**; supports Windows, macOS, Linux, Android **[U]**; Windows has a `crashpad_wer.dll` WER module for fast-fail/unusual crashes **[U]**. Per #4, Bugsee's Android SDK already vendors Crashpad, so there is in-house experience and a possible single engine across Android + desktop.
Apache-2.0 **[U]**; handler is a separate executable (`crashpad_handler`) **[U]**; supports Windows, macOS, Linux, Android **[U]**; Windows has a `crashpad_wer.dll` WER module for fast-fail/unusual crashes **[U]**. Per #4, Crashpad is the planned Android engine, so there is a possible single engine across Android + desktop.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Medium — “planned Android engine” is a decision this tree has not made.

Problem: The old sentence was an empirical note about a sibling Android SDK already vendoring Crashpad (in-house experience). Sanitising that is right. Substituting “Crashpad is the planned Android engine” is a different claim, and it contradicts the mobile design this PR also edits:

  • Summary still calls Crashpad the long-term direction, but Phase B is conditional on a measured in-process gap and scenario C (mobile-native-capture.md §5.2).
  • D1: “Without C there is little case for Crashpad”.
  • D7: decide after Phase 1; prefer sharing; do not vendor a second copy without an owner.

Option B’s own hedge two lines later (“right choice if maintainers decide one engine”) now fights “planned”.

Impact: The desktop OOP choice (minidumper vs Crashpad) uses “same engine as Android” as the only real pro for B. If Android Crashpad is written as already planned, that pro looks settled; it is not.

Scenario: A desktop spike ticket copies this paragraph, vendors Crashpad “to stay aligned with Android”, and pays the C++/handler-binary cost this doc recommends against for v1, while mobile Phase B later chooses not to adopt it.

Fix: e.g. “Per #4, Crashpad is a candidate Android engine (mobile design Phase B / D1), so a single engine across Android + desktop is possible if both sides opt in [U].”

@krassx
krassx merged commit 9fb6d6d into main Oct 14, 2026
9 checks passed
krassx added a commit that referenced this pull request Oct 14, 2026
Tree unchanged here; the doc clean-up from #36 is applied next.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
krassx added a commit that referenced this pull request Oct 14, 2026
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)
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)

* 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: 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(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)

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

* 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(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(core): a failed enqueue write is not a recovery attempt

🤖 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(core): deferred launch-pass recovery uses the recovery directory, not only the capture tree

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

* 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: 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)
krassx added a commit that referenced this pull request Oct 14, 2026
…sion registration (#12) (#35)

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

* 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: 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(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)

* 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(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(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: 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(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)

* 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)
krassx added a commit that referenced this pull request Oct 14, 2026
…#8) (#31)

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

* 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(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: 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: 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(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)

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

1 participant