Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 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
11226fb
fix(core): exactly-once recovery and queue draining across processes …
krassx Oct 14, 2026
16937c8
fix(core): arbitrate cross-process claims and session liveness with O…
krassx Oct 14, 2026
31a293c
fix: keep draining existing data dirs that cannot take new files (#9)
krassx Oct 14, 2026
10271f3
fix(core): harden the file-lock claims (second review)
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
0b78a10
fix: drain every trusted unwritable default tree, not just one; stale…
krassx Oct 14, 2026
6267bd5
Merge origin/fix/issue-9; portable clippy fixes for lock claims
krassx Oct 14, 2026
ee3f808
fix(core): review round 3 on lock claims
krassx Oct 14, 2026
c4f81d6
fix(core): make the lock-less heartbeat robust to clock skew and slow…
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
fbd6644
fix: remember every undeletable retired bundle, not just delivered on…
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
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
deead02
Merge origin/main (squash of #24) into fix/issue-14
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
17 changes: 16 additions & 1 deletion DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,22 @@ Crash recovery needs the data directory to **outlive the process**: on a contain

**Release note, default location moved.** Earlier builds defaulted to `<temp>/bugsee/<token>`; the default is now the platform location (3) or the private temp fallback (4). When no directory was requested and the old location exists, is a real directory and is owned by the current user, the launch recovers its crashed sessions and delivers its queue once, best effort, including when no new directory could be created. Recovery runs on the worker before a `flush` is acknowledged and `flush` drains the legacy queue with the data directory's, so a flush covers it; the uploader stops polling it once its queue is empty. It is not claimed to be lossless: a legacy session whose process is still alive is skipped, and a legacy directory that fails the ownership check is left alone. Hosts that set `data_dir` were never using it.

**Unsafe.** The facade makes two `geteuid()` calls (unix) to name and vet the private temp directory, alongside core's single `kill(pid, 0)`.
**Unsafe.** The facade makes two `geteuid()` calls (unix) to name and vet the private temp directory. Core adds `fcntl(F_SETLK)` (unix) / `LockFileEx` (Windows) for claims, a `kill(pid, 0)` probe (plus `proc_pidinfo` on macOS, `OpenProcess`/`WaitForSingleObject` on Windows) for legacy markers only, and one `UnsafeCell` registry guarded by a pid-stamped spin lock (below).

**Several processes on one directory (#14).** Every pending item is owned by exactly one process at a time, arbitrated by an **OS byte-range lock** on a `*.lock` file: a queued report by `queue/<bundle>.lock`, a session being recovered by `sessions/<gen>.recover.lock`, and a *live session* by `sessions/<gen>.lock`, held for the session's whole life. The kernel releases the lock when the process dies however it dies, so nothing can go stale: there is no owner pid, start time, lease or takeover protocol to get wrong. Choices that follow from this:

- **Pid namespaces.** Containers sharing a volume each see their app as pid 1; a pid (or pid plus start time) written in a file means nothing to the other. A lock does not depend on it, so a peer's live session is never mistaken for a crash and a dead one is never kept alive by pid collision. The `.alive` marker's pid is consulted only for a marker with no `.lock` beside it, i.e. one written by an older SDK build.
- **Why `fcntl`, not `flock`/OFD locks.** `fcntl` locks are per process and are not inherited across `fork`, so a prefork child never holds its parent's claim; `flock`/OFD locks follow the open file description and would. (MSRV 1.88 predates `File::lock` anyway, hence libc/FFI.)
- **Costs of per-process locks, handled in `claim.rs`.** They do not exclude threads, so a registry of held paths does (keyed by pid and spin-locked with the holder's pid stamped in, so a `fork` child that inherits it mid-update breaks the lock instead of deadlocking). POSIX drops a process's locks on a file when *any* descriptor for it closes, so a lock file is never opened twice by one process: the registry is checked before each open, and a claim closes its descriptor before leaving the registry.
- **Ordering at launch.** `Session::begin` creates the marker (`O_EXCL`), takes `<gen>.lock`, then writes the pid. Until the pid is written the marker is empty and fresh, which peers skip even if the lock reads free. A lock that cannot be held with kernel enforcement (contended past a short retry, or the filesystem rejects locks) is *removed*: an existing but unheld lock file reads as "that session died", so peers fall back to the marker's pid instead. On unix a probe asks `F_GETLK` and never takes the lock, so it cannot steal one from a launching peer.
- **Unclean shutdown.** If the worker or uploader thread is detached at shutdown it may still be writing the session, so the session's lock is kept until the process exits (the kernel then drops it) rather than released on drop.
- **Filesystems without locks.** If the filesystem rejects byte-range locks (`ENOLCK` on NFS without lockd; `EOPNOTSUPP`/`ENOSYS`/`EINVAL` on FUSE, 9p) a claim degrades to thread-only exclusion with a one-time message on stderr, and a probe reports unknown, and a lock file that vanishes under a peer's scan is likewise not taken as evidence of death (as a probe does for a leftover lock file that cannot be opened, e.g. on a read-only volume, so a crash is never hidden by it). A session that could not take a lock keeps a `<gen>.beat` heartbeat file, touched every 2 s by the worker (and during launch-time recovery): a beat younger than 3 minutes, or dated in the future (generous: NFS caches attributes for up to a minute and its mtime is the server's clock) means alive whatever pid the marker names, which is the only signal that works across pid namespaces without locks; with no beat the marker's pid is probed as before. The price is that a crash on such a filesystem is recovered only after its heartbeat has been quiet for three minutes, so a faster restart defers it to a later launch. Under the recovery claim the liveness decision is re-run, so a session that finished launching since the scan is never torn down. The queue keeps draining and sessions keep recovering, but processes sharing that directory are no longer kept from delivering a report twice. Locks that *succeed but do not exclude* (Docker Desktop bind mounts, WSL2 drvfs/9p, NFS mounted `nolock`) cannot be detected and behave the same way: run one SDK host per directory there.
- **Paths.** The in-process registry canonicalises the lock file's directory, so `/tmp` vs `/private/tmp` or relative vs absolute paths are one claim. A `fork` child clears the registry (`claim::reset_after_fork`, from the fork hook) before claiming anything.
- **Stale scans.** After winning a session claim the marker is re-checked, since a peer may have recovered and discarded it since the scan (this caused phantom duplicate reports). `queue::gc_orphans` only reaps files untouched for 60 s: a peer's in-flight `enqueue` looks like an orphan, and reaping it lost the report. Lock files of finished items and dead sessions are swept when free.
- **Compatibility.** The `.alive` marker is still a bare pid and the lock files are new names, so older builds sharing the directory keep working. They do not take part in the locking, so a mixed-version directory (a rolling upgrade) gives no exactly-once guarantee between old and new processes, and a new process treats an old build's session by its pid as before. Run one SDK version per directory for the guarantee.
- **Limits.** Advisory locks on network filesystems are best effort (see above). The only timestamps relied on are the 30 s grace for an empty marker and the 60 s orphan grace, which assume clocks within seconds. Delivery is at-least-once: a holder that dies after uploading but before removing the item (or after enqueueing a recovered report but before discarding the session) makes its successor do it again. Platforms with neither unix nor Windows locks exclude threads only.

Tests: `bugsee-core/tests/multiprocess.rs` (real child processes: racing launchers, a SIGKILLed holder, a live peer whose marker names a colliding pid) and unit tests in `claim.rs`, `process.rs`, `queue.rs`.

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.

Expand Down
Loading
Loading