Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
78 commits
Select commit Hold shift + click to select a range
08a31bd
feat: degrade to in-memory reporting on read-only/ephemeral file syst…
krassx Oct 14, 2026
184d102
fix: address review of data-directory fallback (#9)
krassx Oct 14, 2026
01e337c
fix: second review round for data-directory fallback (#9)
krassx Oct 14, 2026
cc96a41
feat(bugsee): populate utc_offset, hardware model and Windows locale
krassx Oct 14, 2026
a7410a4
fix(bugsee): address review of hostinfo (iOS model, tm_gmtoff gating,…
krassx Oct 14, 2026
11226fb
fix(core): exactly-once recovery and queue draining across processes …
krassx Oct 14, 2026
faf4344
feat: define the shutdown/flush contract; opt-in SIGTERM/SIGINT flush
krassx Oct 14, 2026
16937c8
fix(core): arbitrate cross-process claims and session liveness with O…
krassx Oct 14, 2026
c462eef
fix: harden the termination hook and shutdown after review
krassx Oct 14, 2026
31a293c
fix: keep draining existing data dirs that cannot take new files (#9)
krassx Oct 14, 2026
229ec84
Merge remote-tracking branch 'origin/fix/issue-9' into fix/issue-8
krassx Oct 14, 2026
10271f3
fix(core): harden the file-lock claims (second review)
krassx Oct 14, 2026
fdbb296
fix: keep the scheduled memory drain independent of the stop flag
krassx Oct 14, 2026
716c363
test: gate unix-only helpers so Windows clippy stays clean (#9)
krassx Oct 14, 2026
69a6bc8
Merge origin/fix/issue-9 into fix/issue-14
krassx Oct 14, 2026
6e71a45
feat(native): best-effort flush of in-flight events at crash time
krassx Oct 14, 2026
9ae8a05
fix(core): queue the previous crash on the launch path before launch …
krassx Oct 14, 2026
0b78a10
fix: drain every trusted unwritable default tree, not just one; stale…
krassx Oct 14, 2026
9c384ad
Merge remote-tracking branch 'origin/fix/issue-9' into fix/issue-8
krassx Oct 14, 2026
e739839
fix: PR #31 review and CI fixes
krassx Oct 14, 2026
6267bd5
Merge origin/fix/issue-9; portable clippy fixes for lock claims
krassx Oct 14, 2026
412cff1
Merge remote-tracking branch 'origin/fix/issue-9' into fix/issue-13
krassx Oct 14, 2026
0cb980b
fix(core): run launch-path recovery on a bounded helper, after the cr…
krassx Oct 14, 2026
a0a779b
Merge origin/fix/issue-14 into fix/issue-10
krassx Oct 14, 2026
1760cf2
fix(native): harden in-flight crash flush (review feedback, #13)
krassx Oct 14, 2026
ee3f808
fix(core): review round 3 on lock claims
krassx Oct 14, 2026
38f73e6
feat(health): local session health records for release health (#12)
krassx Oct 14, 2026
c4f81d6
fix(core): make the lock-less heartbeat robust to clock skew and slow…
krassx Oct 14, 2026
6dfc9c8
fix(core): keep unique crashes the launch pass cannot queue; scan new…
krassx Oct 14, 2026
ee725bc
Merge origin/fix/issue-10 into fix/issue-13: port the DYING gate onto…
krassx Oct 14, 2026
6d6faa2
Merge origin/fix/issue-14 into fix/issue-10
krassx Oct 14, 2026
460e093
fix(health): address review of release health (#12)
krassx Oct 14, 2026
74605d1
Merge origin/fix/issue-10 into fix/issue-12
krassx Oct 14, 2026
4530024
fix(core): tried sessions first, and a queue-cap refusal is not an at…
krassx Oct 14, 2026
979c6e8
fix(native): address review of the in-flight flush (#13)
krassx Oct 14, 2026
1d84feb
fix: never recover the same directory twice in one launch (#9)
krassx Oct 14, 2026
3b90a28
fix(core): a lock file that vanishes under a scan is not evidence of …
krassx Oct 14, 2026
546024b
fix: a stop during a flush's memory pass must not start a disk upload
krassx Oct 14, 2026
3703010
Merge remote-tracking branch 'origin/fix/issue-9' into fix/issue-8
krassx Oct 14, 2026
69378f1
fix(health): keep panic.info for abort correlation, no eager registra…
krassx Oct 14, 2026
4002558
fix(core): a failed enqueue write is not a recovery attempt
krassx Oct 14, 2026
7aad047
Merge remote-tracking branch 'origin/fix/issue-14' into fix/issue-10
krassx Oct 14, 2026
46872ff
fix(health): do not expire panic.info from a hook timer
krassx Oct 14, 2026
b606938
fix(core): a session the launch pass defers is picked up even if the …
krassx Oct 14, 2026
168b672
fix(health): mark a panicking drop so a slow unwind is still a crash
krassx Oct 14, 2026
12d4672
fix(core): deferred launch-pass recovery uses the recovery directory,…
krassx Oct 14, 2026
fbd6644
fix: remember every undeletable retired bundle, not just delivered on…
krassx Oct 14, 2026
5496529
Merge remote-tracking branch 'origin/fix/issue-9' into fix/issue-10
krassx Oct 14, 2026
1b91328
Merge origin/fix/issue-10 into fix/issue-13
krassx Oct 14, 2026
04a927a
Merge origin/fix/issue-16 into fix/issue-13 (libc stays a normal unix…
krassx Oct 14, 2026
bb281f1
test(core): wait on an observable condition in the full-queue test
krassx Oct 14, 2026
4893817
Merge origin/fix/issue-10 into fix/issue-13
krassx Oct 14, 2026
862072a
Merge origin/fix/issue-13 into fix/issue-12
krassx Oct 14, 2026
d0608a2
Merge remote-tracking branch 'origin/fix/issue-12' into fix/issue-8
krassx Oct 14, 2026
7ab8a90
fix(core): declare kernel32 calls once (clashing_extern_declarations …
krassx Oct 14, 2026
aa238d8
Merge remote-tracking branch 'origin/fix/issue-13' into fix/issue-12
krassx Oct 14, 2026
20cf3a7
Merge remote-tracking branch 'origin/fix/issue-12' into fix/issue-8
krassx Oct 14, 2026
b6ce5e0
Merge origin/main into fix/issue-9
krassx Oct 14, 2026
e01a20a
Merge origin/fix/issue-9 into fix/issue-14
krassx Oct 14, 2026
e00b09d
Merge origin/fix/issue-14 into fix/issue-10
krassx Oct 14, 2026
3e18e85
Merge remote-tracking branch 'origin/fix/issue-10' into HEAD
krassx Oct 14, 2026
acee6db
docs: keep the corrected native feature comment from main
krassx Oct 14, 2026
5a844b8
Merge origin/fix/issue-13 into fix/issue-12
krassx Oct 14, 2026
50da593
Merge origin/fix/issue-12 into fix/issue-8
krassx Oct 14, 2026
1ecfca8
fix: de-duplicate the unix libc dependency after merging main (#27's …
krassx Oct 14, 2026
fbbabb2
Merge origin/fix/issue-9 into fix/issue-14
krassx Oct 14, 2026
66e4f18
Merge origin/fix/issue-14 into fix/issue-10
krassx Oct 14, 2026
4e42eda
Merge remote-tracking branch 'origin/fix/issue-10' into HEAD
krassx Oct 14, 2026
fe63783
Merge origin/fix/issue-13 into fix/issue-12
krassx Oct 14, 2026
f5dcac4
Merge origin/fix/issue-12 into fix/issue-8
krassx Oct 14, 2026
0f94b90
fix(core): make the crash-flush handshake robust on slow runners
krassx Oct 14, 2026
54c5414
docs: describe backend behaviour generically in release-health notes
krassx Oct 14, 2026
a89ce8a
Merge origin/main (squashes of #14, #10) into fix/issue-13
krassx Oct 14, 2026
4858ce7
docs: keep design notes generic (no private-component references) (#36)
krassx Oct 14, 2026
4d891b1
Merge origin/fix/issue-13 into fix/issue-12
krassx Oct 14, 2026
e738c93
Merge origin/main (squash of #13) into fix/issue-12
krassx Oct 14, 2026
b7c5507
Merge origin/fix/issue-12 into fix/issue-8
krassx Oct 14, 2026
fa0a34e
Merge origin/main (squash of #12) into fix/issue-8
krassx Oct 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ A Cargo workspace. Integrations are **separate crates** re-exported by an umbrel

## 6. Public API surface

Global singleton (mobile parity); `launch` returns a guard whose `Drop` flushes.
Global singleton (mobile parity); `launch` returns a guard whose `Drop` persists and stops cleanly (see §8; it does not upload).

```rust
// Lifecycle
Expand Down Expand Up @@ -218,6 +218,21 @@ Tests: `bugsee-core/tests/multiprocess.rs` (real child processes: racing launche

Tests: `bugsee-core/tests/storage_failures.rs`, `bugsee/tests/storage_fallback.rs`, a memory-mode case in `bugsee/tests/fork_safety.rs`, `options.rs` unit tests. A real ENOSPC (size-capped tmpfs) is not exercised, since mounting one needs root; failures are injected by blocking path components and removing the directory mid-run.

**Shutdown and flush contract (issue #8).** `flush(timeout)` persists everything captured before the call and tries to upload every queued report; it returns `true` only if the outbound queue is empty, leaves the SDK running, and is idempotent. `stop()` (and the launch guard's `Drop`) persists, then ends the session by removing the liveness marker; it never uploads from the durable queue, so an undelivered report stays there and goes out next launch (disk mode). `flush` drains every queue it can see: the data directory's `queue/`, the legacy directory adopted by #9, and the in-memory backlog. In memory mode `flush` is the only guarantee; `stop` then gives the backlog one last best-effort attempt (the uploader's stop arm, bounded to 1.5 s, which deliberately ignores the `stopping` flag that aborts a flush), and a report still unsent at exit is lost. `shutdown(timeout)` is `flush` then `stop`. A `stop` arriving during a flush aborts the flush between deliveries instead of waiting out its deadline. The uploader's acknowledgement does not gate ending the session (it writes no session parts and may sit in an HTTP call for tens of seconds), so a stop during a slow upload still ends cleanly and the abandoned report is re-sent next launch: delivery is **at-least-once**, and a graceful stop plus restart reports nothing twice only in the absence of a kill mid-upload. Worst case a `shutdown(t)` takes `t` plus about 4 s. The release-health record (#12) is finished on any non-panicking stop, whatever the threads acknowledged; only the capture worker's acknowledgement gates removing the marker (and releasing the session lock early, #14). The marker is intentionally left when the capture worker does not acknowledge in time, so a wedged shutdown is recovered, not lost. None of it is async-signal-safe; in a `fork` child it acts on the child's own revived recorder.

| Exit path | Marker | Reports | Next launch |
|---|---|---|---|
| return from `main` / `stop` / `shutdown` | removed | persisted, queue intact | no abnormal-exit; queue delivered once |
| `process::exit` / `exit()` | left (default) | as already written | one abnormal-exit report; queue delivered once |
| same, with `LaunchOptions::flush_at_exit` (unix only; ignored on Windows, call `shutdown`) | removed by an `atexit` handler (not run in `fork` children) | persisted | as the first row |
| `SIGTERM`/`SIGINT` + `Bugsee::install_termination_flush` | removed if the signal would have terminated the process | flushed, bounded | as the first row; host handler still called |
| Windows Ctrl+C/Break/close/logoff/shutdown + hook | removed (`shutdown`) | flushed, bounded | as the first row |
| `SIGTERM`/`SIGINT` without the hook, `SIGKILL`, OOM, power loss | left | records appended as drained survive | one abnormal-exit report |

*Decision: `exit` without `stop` stays an abnormal exit.* From inside the process an `exit` is indistinguishable from a runtime tearing down after a fault the host swallowed, so the default errs towards reporting; hosts that exit on purpose opt in with `flush_at_exit` (or call `shutdown`). *Decision: the signal hook is opt-in and chaining.* The handler only writes the signal number to a non-blocking self-pipe and then calls the previously installed handler; a watcher thread does the flush. With a default previous disposition it runs `shutdown`, restores `SIG_DFL` and re-raises, so the exit status is unchanged; a second signal during the shutdown terminates at once. With a host handler it flushes only and the host decides; a host handler installed AFTER ours (tokio, `signal-hook`) is detected by asking the kernel what is in force, and is treated the same, so we never kill a host mid-graceful-shutdown. A signal ignored at install is left alone. A second signal while the first is served, or a dead watcher, terminates at once. The chained call keeps the host's `sa_mask`, `SA_ONSTACK`, `SA_NODEFER`, `SA_SIGINFO`, and `SA_RESETHAND` (emulated). Do not call `exit` from a host handler with `flush_at_exit` on: the exit-time `shutdown` would run in signal context. A `fork` child inherits the handler but not the watcher: it chains/terminates as if the hook were absent until `install_termination_flush` is called again there. Windows: a console control handler that runs `shutdown` for every event (a host that swallows Ctrl+C and keeps running must register its handler AFTER the SDK's, so it runs first) that returns `FALSE` to pass the event on; service-control stop is not a console event and needs a `shutdown` call from the host's stop handler. **Not compiled or run on Windows** (a macOS cross-check stops at the C dependencies of `zstd-sys`/`ring`); the Windows half was reviewed by reading only.

Tests: `bugsee/tests/shutdown.rs` (a real child process per exit path, then a relaunch asserting exactly-once delivery and the abnormal-exit count).

**Early-crash recovery on the launch path (#10).** A host that dies again within milliseconds of launch (a crash loop under systemd/Kubernetes) must not outrun the report of the crash before it. `Bugsee::launch_with` therefore ends with `Recorder::recover_on_launch_path()`: a bounded pass that turns the newest crashed sessions into queue entries (build, `before_send`/blacklist, assemble, enqueue, then discard the evidence) before launch returns. Upload stays asynchronous and resumes from the queue. A bundle is queued with plain file writes and a rename, no `fsync`, so it survives process death (the case here) but not power loss, as everywhere in §8.

- **Threading contract.** The pass runs on a helper thread (`bugsee-launch-recovery`) and `launch` waits at most `launch_recovery_budget` (500 ms default, `LaunchOptions::launch_recovery_budget`; zero disables the pass) by polling a flag (never parking: a parker used before a `fork` is inherited by the child, and on macOS the child then traps in libdispatch when it parks). That is a hard upper bound whatever a `before_send` callback does: on timeout `launch` returns and the pass finishes in the background, so a host that holds its own lock while calling `launch` cannot deadlock against a callback that takes it. The callback can run on an SDK thread before `launch` returns; this is documented on `LaunchOptions::before_send`.
Expand Down Expand Up @@ -320,7 +335,7 @@ Documented limits. Records of legacy and extra recovery directories are not surf
| Report/upload worker | 1 | `reports/`, `queue/` | export → bundle (zstd-93) → 3-step upload → retry |
| Crash-time context | any thread | — | panic observer / native handler: touch only preallocated `PanicSnapshot` + minidump |

**Crash-time isolation rule:** the fault path never enqueues/allocates/locks — it writes only to fixed preallocated storage + a file-based crash marker (worker may be dead/frozen). **Back-pressure:** the depth-capped channel drops the newest entry when full (never blocks a producer); **reports** are bounded by a separate in-flight counter (heavier per message, so a report storm drops-newest **non-crash** reports over the cap while never dropping a crash); the worker holds no large in-RAM buffer (it frames one record at a time and `write_all`s it), so on-disk growth is bounded by `max_bytes`/`max_window` eviction rather than RSS. **Async-optional:** capture worker is always a `std` thread; only the uploader can become a task on the host runtime (`async` feature). **Shutdown:** guard `Drop` → `flush(timeout)`.
**Crash-time isolation rule:** the fault path never enqueues/allocates/locks — it writes only to fixed preallocated storage + a file-based crash marker (worker may be dead/frozen). **Back-pressure:** the depth-capped channel drops the newest entry when full (never blocks a producer); **reports** are bounded by a separate in-flight counter (heavier per message, so a report storm drops-newest **non-crash** reports over the cap while never dropping a crash); the worker holds no large in-RAM buffer (it frames one record at a time and `write_all`s it), so on-disk growth is bounded by `max_bytes`/`max_window` eviction rather than RSS. **Async-optional:** capture worker is always a `std` thread; only the uploader can become a task on the host runtime (`async` feature). **Shutdown:** guard `Drop` → persist + clean stop (see §8, "Shutdown and flush contract"); `Bugsee::shutdown(timeout)` = flush then stop.

## 12. Crash-time safety, best-effort flush & edge cases

Expand Down
62 changes: 51 additions & 11 deletions crates/bugsee-core/src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,10 @@ struct Shared {
max_report_queued: usize,
/// Whether telemetry sampling is paused (mirrors the facade's pause state).
paused: AtomicBool,
/// Set when the recorder is being stopped: a flush in progress gives up at
/// once, so the stop is acknowledged inside its bound (and the session can
/// end cleanly) instead of waiting out the flush's whole deadline.
stopping: AtomicBool,
/// Reports that could not be queued on disk, delivered from memory. An entry
/// stays here until its delivery is settled, so the length is the real backlog.
mem_queue: ForkLock<Vec<MemReport>>,
Expand Down Expand Up @@ -438,6 +442,7 @@ impl Recorder {
report_queued: AtomicUsize::new(0),
max_report_queued: DEFAULT_MAX_REPORT_QUEUED,
paused: AtomicBool::new(false),
stopping: AtomicBool::new(false),
mem_queue: ForkLock::new(Vec::new()),
next_mem_id: AtomicU64::new(0),
mem_blacklist: ForkLock::new(BTreeSet::new()),
Expand Down Expand Up @@ -830,6 +835,7 @@ impl Drop for Recorder {
// A thread wedged in a host callback with no timeout is DETACHED (its
// JoinHandle dropped un-joined) rather than hanging the stopping thread
// forever. (Called only after the facade released its global lock.)
self.shared.stopping.store(true, Ordering::SeqCst);
let (tx, rx) = channel();
let worker_acked =
self.tx.send(Msg::Stop(tx)).is_ok() && rx.recv_timeout(Duration::from_secs(2)).is_ok();
Expand All @@ -849,7 +855,7 @@ impl Drop for Recorder {
let _ = handle.join();
}
}
// Only mark a genuinely clean shutdown. If either thread merely DETACHED
// Only mark a genuinely clean shutdown. If the capture worker merely DETACHED
// (didn't ack in time — e.g. a slow flush), it may still be writing this
// session's data, so removing the marker now would make next-launch
// recovery miss the generation entirely if the process then dies mid-tail.
Expand All @@ -867,6 +873,12 @@ impl Drop for Recorder {
// A panic that was merely *contained* never reaches here while
// unwinding, and its snapshot is deleted by `report_caught`, so this
// cannot turn a handled panic into a phantom crash.
// The uploader is deliberately NOT part of this condition: it never
// writes session parts, and it can be inside an HTTP call that no signal
// can interrupt (connect/read timeouts are tens of seconds). Waiting on
// it would turn every stop during a slow upload into a false
// abnormal-exit report. The cost is at-least-once delivery: a report
// whose upload was cut short is re-sent next launch.
//
// The health record is the exception: it is finished whenever the stop
// is not a death by panic, whatever the threads acked. A detached
Expand All @@ -887,14 +899,15 @@ impl Drop for Recorder {
health.finish_clean(self.shared.health_errors.load(Ordering::SeqCst));
}
}
if worker_acked && uploader_acked && !std::thread::panicking() {
if worker_acked && !std::thread::panicking() {
if let Some(session) = &self.session {
// The finished health record first, then the marker: a death in
// between leaves a marker that recovery settles idempotently.
session.end();
}
} else if !(worker_acked && uploader_acked) {
// A detached thread may still be writing this session's data: keep
} else if !worker_acked {
// A detached capture worker may still be writing this session's data
// (the uploader writes none, so its detaching does not matter): keep
// the liveness lock until the process exits so no peer recovers (and
// deletes) the session underneath it.
if let Some(session) = &mut self.session {
Expand Down Expand Up @@ -2042,6 +2055,8 @@ fn uploader_loop(
true,
false,
Some(Instant::now() + Duration::from_millis(1500)),
// `stopping` is set by now; this IS the stop-time drain.
false,
)
}));
let _ = ack.send(());
Expand Down Expand Up @@ -2112,7 +2127,9 @@ fn drain_all(
/// is set so backing-off reports are retried immediately. Returns whether any
/// report was attempted (used to pace `drain_until_empty`).
fn drain(shared: &Shared, data_dir: Option<&Path>, backoff_base: Duration, force: bool) -> bool {
let attempted = drain_memory(shared, backoff_base, force, true, None);
// Not `honor_stop`: the scheduled pass behaves as before #8, and the
// stop-time drain relies on it having had its one attempt.
let attempted = drain_memory(shared, backoff_base, force, true, None, false);
match data_dir {
Some(dir) => drain_disk(shared, dir, backoff_base, force) | attempted,
None => attempted,
Expand Down Expand Up @@ -2261,19 +2278,23 @@ fn notify_dropped(shared: &Shared, reason: DropReason) {
/// An entry STAYS in the queue while it is being delivered (the lock is held only
/// to pick it and to settle it, never across the network), so the queue length is
/// the true backlog and [`MAX_MEMORY_REPORTS`] holds. Stops at `deadline`, like
/// the disk loop in [`drain_until_empty`].
/// the disk loop in [`drain_until_empty`]. With `honor_stop`, also stops as soon
/// as the recorder is being stopped (only an explicit flush passes `true`).
fn drain_memory(
shared: &Shared,
backoff_base: Duration,
force: bool,
count_failure: bool,
deadline: Option<Instant>,
honor_stop: bool,
) -> bool {
let mut attempted = false;
// Each entry is tried once per call, whatever its outcome.
let mut tried: Vec<u64> = Vec::new();
loop {
if deadline.is_some_and(|d| Instant::now() >= d) {
if deadline.is_some_and(|d| Instant::now() >= d)
|| (honor_stop && shared.stopping.load(Ordering::SeqCst))
{
break;
}
// A dying process starts no delivery it cannot finish (crash_flush).
Expand Down Expand Up @@ -2376,13 +2397,32 @@ fn drain_until_empty(
) -> bool {
let start = Instant::now();
loop {
if shared.stopping.load(Ordering::SeqCst) {
return false; // stop wins; what is queued stays queued
}
let now = epoch_ms();
drain_memory(shared, backoff_base, true, false, Some(start + deadline));
for &data_dir in dirs {
drain_memory(
shared,
backoff_base,
true,
false,
Some(start + deadline),
true,
);
// A stop that arrived during the memory pass must not be followed by a
// blocking disk upload: the uploader has to reach `UploadMsg::Stop` and
// run the stop-time memory drain, which a hung HTTP call would starve.
if shared.stopping.load(Ordering::SeqCst) {
return false;
}
'dirs: for &data_dir in dirs {
for report in pending_reports(shared, data_dir) {
let Some(_claim) = queue::try_claim_lenient(&report) else {
continue; // a peer is delivering it; the loop below waits it out
Comment thread
cursor[bot] marked this conversation as resolved.
};
if shared.stopping.load(Ordering::SeqCst) {
return false; // checked after the claim so no upload starts
}
let Ok((zip, request_json, retry, _next)) = queue::load(&report) else {
continue;
};
Expand All @@ -2397,8 +2437,8 @@ fn drain_until_empty(
now,
false,
);
if start.elapsed() >= deadline {
break;
if start.elapsed() >= deadline || shared.stopping.load(Ordering::SeqCst) {
break 'dirs;
}
}
}
Expand Down
Loading
Loading