Skip to content

perf(daemon): async snapshot worker — debounced off-main writes#49

Merged
subinium merged 1 commit into
mainfrom
feat/04-snapshot-pipeline-async
Apr 26, 2026
Merged

perf(daemon): async snapshot worker — debounced off-main writes#49
subinium merged 1 commit into
mainfrom
feat/04-snapshot-pipeline-async

Conversation

@subinium

Copy link
Copy Markdown
Owner

Summary

Closes #36. Fourth v0.10.0 stability fix.

Snapshot writes (and gzip+bincode for persist_scrollback = true) move off the daemon main loop into a dedicated ezpn-snapshot worker thread. Per SPEC 04.

Changes

A. New worker module — src/daemon/snapshot_worker.rs (~290 LoC)

  • SnapshotWorker thread named ezpn-snapshot.
  • Bounded mpsc::sync_channel(4); try_send returning Full is a deliberate drop (debounce coalesces on the next idle).
  • 150 ms debounce window: a freshly arrived Auto job replaces any pending one; the worker only writes when the window elapses.
  • User-initiated Save jobs bypass the debounce and signal completion via an ack channel (30 s timeout).
  • Atomic write: path.with_file_name("{name}.tmp.{pid}") + rename.
  • Drop impl drains any pending Auto and joins the thread within a 5 s deadline; on timeout the handle is mem::forgeted and a single warn line is emitted.

B. Wiring — src/daemon/event_loop.rs

  • SnapshotWorker::spawn() near other init.
  • Three workspace::auto_save(...) sites → submit(SnapshotJob::Auto).
  • IPC Save site → submit(SnapshotJob::UserSave) + bounded ack recv_timeout; saturation/timeout return structured errors via IpcResponse::error(...).

Acceptance criteria (from #36)

  • SnapshotWorker spawned at daemon bring-up; one named thread ezpn-snapshot.
  • Auto-save path uses bounded mpsc::sync_channel(4) and try_send.
  • Debounce window of 150 ms coalesces rapid Auto jobs.
  • User-initiated Save jobs bypass debounce and surface result via an ack channel.
  • Atomic write (tmp file + rename) used for both auto-save and user save.
  • On run() return the worker drains pending captures within a 5 s deadline (via Drop).
  • Snapshot v3 on-disk schema unchanged.
  • No new clippy warnings; cargo test green.
  • PROTOCOL_VERSION unchanged.

Test plan

  • cargo build clean
  • cargo clippy --all-targets -- -D warnings clean
  • cargo test180 / 180 passing (3 new unit tests):
    • worker_writes_user_save_atomically (no .tmp.* leftovers)
    • worker_shutdown_drains_pending_auto
    • worker_debounces_rapid_auto_jobs

Wire protocol & schema

Wire protocol unchanged. On-disk snapshot schema unchanged (v3). No new external deps.

Files

+349 / −5 across 4 files. New: src/daemon/snapshot_worker.rs (~290 LoC).

🤖 Generated with Claude Code

Closes #36.

Snapshot encoding (gzip+bincode for `persist_scrollback = true`),
serde_json serialization, and disk I/O previously all ran synchronously
on the daemon main loop. A 4-pane workspace at the per-pane cap could
stall the loop for 1+ seconds per detach. Rapid attach/detach (10 cycles
in 1 s) hammered disk every cycle.

* New `src/daemon/snapshot_worker.rs` (~290 LoC):
  - `SnapshotWorker` thread named `ezpn-snapshot`.
  - Bounded `mpsc::sync_channel(4)`; `try_send` returning `Full` is a
    deliberate drop (debounce coalesces on the next idle).
  - 150 ms debounce window: a freshly arrived `Auto` job replaces any
    pending one; the worker only writes when the window elapses.
  - User-initiated `Save` jobs bypass the debounce and signal
    completion via an ack channel (30 s timeout).
  - Atomic write: `path.with_file_name("{name}.tmp.{pid}")` + `rename`.
  - `Drop` impl drains any pending `Auto` and joins the thread within
    a 5 s deadline; on timeout the handle is `mem::forget`ed and a
    single warn line is emitted.
* `src/daemon/event_loop.rs`:
  - `SnapshotWorker::spawn()` near other init.
  - Three `workspace::auto_save(...)` sites → `submit(SnapshotJob::Auto)`.
  - IPC `Save` site → `submit(SnapshotJob::UserSave)` + bounded ack
    `recv_timeout`; saturation / timeout return structured errors via
    the existing `IpcResponse::error(...)` path.

3 new unit tests (atomic user save with no `.tmp.*` leftovers, shutdown
drains pending Auto, debounce coalesces 5 rapid Auto jobs into one
final file). All 180 tests pass; clippy clean.

Wire protocol unchanged (`PROTOCOL_VERSION = 1`). On-disk snapshot
schema unchanged (`v3`). No new external deps.

Per SPEC docs/spec/v0.10.0/04-snapshot-pipeline-async.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@subinium subinium added this to the v0.10.0 milestone Apr 26, 2026
@subinium subinium added severity:warning Should fix, can defer type:perf Performance improvement area:daemon Server/IPC/lifecycle labels Apr 26, 2026
@github-actions github-actions Bot added the area:repo Repo hygiene, .github, docs metadata label Apr 26, 2026
@subinium subinium merged commit c8c62b0 into main Apr 26, 2026
21 checks passed
@subinium subinium deleted the feat/04-snapshot-pipeline-async branch April 26, 2026 04:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:daemon Server/IPC/lifecycle area:repo Repo hygiene, .github, docs metadata severity:warning Should fix, can defer type:perf Performance improvement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[v0.10.0 / 04] Snapshot pipeline async — off-main-thread encode + detach debounce

1 participant