Skip to content

acpmux: the open after a kill syncs nothing per replayed chat; retention never waits for a checkpoint in flight (R-DURABILITY, follow-up 4 of review 3 of #34901) - #35866

Merged
lawrencecchen merged 134 commits into
feat-cmux-nextfrom
rewrite/r-durability-recovery-syncs
Nov 5, 2026
Merged

lawrencecchen merged 134 commits into
feat-cmux-nextfrom
rewrite/r-durability-recovery-syncs

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Follow-up 4 of the third review of #34901 (S2): the open's syncs per replayed chat, measured against the daemon's 8 s start budget, then cut. Its base is now feat-cmux-next: #35483, #35562 and #35895 have landed, and the S3b head (#35565) is merged in. Behind the same switch, store.journal (default off), which stays off until this PR and the data-loss follow-ups (#35905, #35915) land.

An acpmux daemon that a kill or a crash ended replays the journal at its next start, before it binds its socket. Each replayed chat cost a few syncs, and on macOS a sync of a file is a drive cache flush (F_FULLFSYNC), so the daemon served nothing for seconds.

Open-time bench: distributions, base and head on the same Mac

open_time_after_a_kill_with_replayed_chats (scratch, never lands; branches fix/r-durability-openbench-dist2-{base,head}-nomerge): N chats each with 5 lines and a metadata image past the checkpoint, a kill, then the open. 10 runs per size, sizes interleaved, real files. Both on owned Mac aws-m4pro-8, base first, then head:

  • base: fleet 29724350 at 86441c46001 (S3b head 66e1df5ec5e + the bench);
  • head: fleet 28d76c7f at 237017ea1ae (this branch at b8159d903c8 + the bench).

Measures, in ms (median of the 10 runs / max):

  • ready: the store's open (the daemon binds its socket after it) plus the person's first change after it (a line to a replayed chat) until the S2c gate passes it (its record durable): the time the person waits at a start.
  • durable: the start until a checkpoint after that change and a new chat's ends (every replayed record durable and retired).
Chats Ready, base Ready, head Durable, base Durable, head
10 225 / 249 9 / 82 264 / 284 168 / 390
100 2,035 / 2,118 23 / 28 2,076 / 2,159 764 / 1,150
300 6,224 / 9,000 39 / 48 6,262 / 9,122 2,160 / 3,369

The parts, median / max (base, then head):

  • open alone: 10 chats 221 / 245, 7 / 76; 100 chats 2,028 / 2,112, 21 / 26; 300 chats 6,221 / 8,980, 38 / 45.
  • first acknowledged change (its record through the gate): 10 chats 3.6 / 3.9, 1.8 / 6.4; 100 chats 3.8 / 9.4, 1.8 / 2.4; 300 chats 3.8 / 20.5, 1.9 / 2.4.
  • a new chat (its record and its folder's sync through the gate): 10 chats 12.1 / 15.8, 11.7 / 32.5; 100 chats 14.6 / 29.8, 3.8 / 10.9; 300 chats 13.1 / 25.1, 4.3 / 5.8.

The head is faster in every measure. The 10-chat maxima on the head (ready 82 ms, open 76 ms, new chat 32.5 ms, durable 390 ms) all come from run=0 chats=10, the first open in the test process; every later 10-chat run opened in 5.8 to 11.3 ms, and run=0 of 100 and 300 chats (in the same process right after) is at their medians (19.2 and 36.1 ms). So it is a cost of the first open in a process (about 65 ms here), not host noise. A daemon start is a first open in its process, so the start to expect with 10 chats is that run's: 76 ms open, 82 ms ready (the base's first run: 113 ms open, 114 ms ready). Where those 65 ms go is not measured (not a split by step); follow-up R-DURABILITY.

Does a visible action wait for "durable"?

No, after the fix below. On the base, the open itself made everything durable (open = durable), and the daemon answered nothing until then. On the head, the open returns at once; the checkpoint that makes the replayed records durable runs on the checkpointer's thread:

  • Replies and events wait only for their own record (one group commit, the S2c gate): the first acknowledged change above, at most 2.4 ms with 100 or 300 chats replaying.
  • A new chat waits for its record and its folder's sync (S3a): the new chat above.
  • The durable time is the checkpoint's, after the daemon serves; the base had the same work before it served (100 chats: 2,076 ms before any answer, against the head's 23 ms to the first answer and 764 ms to durable).
  • No request path calls Store::flush (only the shutdown does, after the agents stop, within its 5 s budget).
  • One path did wait for the checkpoint, found while answering this: the retention worker's first pass runs right after a start, and commit_compaction ran a checkpoint under the chat's append lock (hub/retention.rs), so a person's first message to that chat waited for the open's checkpoint. Fix f9c6a16d1b8: Order::log_lsn keeps each chat's newest log record in this process; a chat whose records are all checkpointed (retention takes chats untouched for days) runs no checkpoint; any other still checkpoints first. Red d860bef9d32: a_retention_rewrite_of_a_checkpointed_chat_does_not_wait_for_a_checkpoint; guards a_retention_rewrite_of_a_chat_with_records_past_the_checkpoint_checkpoints_first, a_retention_rewrite_of_a_replayed_chat_checkpoints_first.

Until the checkpoint ends, the journal keeps every replayed record, so a second crash replays them again; nothing acknowledged depends on the checkpoint.

What changes

  • The checkpoint and the recovery sync in write order (log_sync::sync_segment_ordered, sync_directory_ordered: macOS F_BARRIERFSYNC, Linux fdatasync/fsync) and flush the drive once, with the checkpoint file (sync_full), before they retire any record. cmux_journal::sync_file is public for this.
  • The open syncs nothing per replayed chat:
    • A log that already holds every merged line, in order, with nothing torn, keeps its bytes. The open writes the bytes it just read and checked against the journal back over themselves (one write per segment, no sync) and lists the segment (Order::read_segments). The next checkpoint syncs each listed segment before it retires their records, through the handle the open wrote with (on Unix, up to a quarter of the open-file limit) or else by path (past that cap, and on Windows): a killed daemon's writes may be in the page cache only, and a previous run's failed sync may have dropped pages that still read back. The write marks those pages dirty again, so the sync puts them on the disk either way, with no need for a durable note of the failure (delta review 2, D2). The write happens at the open, before any other thread uses the logs, and the checkpoint writes nothing, so a file that a delete, a folder replace or a retention rewrite put at the path since is never changed (delta review 3, D3). A write error at the open takes the slow path for that chat (journal, cut, write again); a delete drops its chat's listed segments.
    • The replay puts each chat's newest metadata image in the overlay; the checkpoint writes session.json.
    • The checkpoint after the replay runs on the checkpointer's thread at once, not on the open's thread.
  • A retention rewrite of a chat whose records are all checkpointed runs no checkpoint (Order::log_lsn, a per-chat upper bound of its newest log record in this process): it no longer waits, under the chat's append lock, for a checkpoint in flight such as the open's.
  • A freeze (a failed sync) is a frozen journal record (kind 6), a control record that a full queue never refuses (Journal::append_control); the journal closes only after the shutdown's last checkpoint. The open that replays it writes every replayed chat's lines again. A record the journal still refuses (its writer stopped) leaves a durable marker, <home>/journal-frozen, which the open reads the same way and the first checkpoint that succeeds after it removes (never while the store is frozen). A chat whose lines come from a repair file (acpmux: event journal follow-ups: a failed repair keeps its records, a hole's last zero splits, an idle checkpointer sleeps #35562) is written again too.

Risk

risk:high, the label the risk classifier set at 02:35Z for the push that added Journal::append_control (it had set risk:normal before; agents do not change it). It lands on an independent SHIP at the exact head and greens on macOS and Linux at that head, both stated below. Behind the default-off switch. It changes when the store's files reach the disk after a crash: the open returns before the replayed state is synced, and the checkpoint on its own thread makes it durable before any record is retired. Every record stays in the journal until then, so a second crash replays the same records again.

Testing

acpmux store::journal_meta_tests and store::journal_store_tests:

  • an_open_that_replays_many_chats_flushes_the_drive_a_bounded_number_of_times (40 chats; at most 8 full syncs)
  • an_open_after_a_kill_syncs_nothing_per_chat_and_the_checkpoint_makes_it_durable (40 chats; at most 4 syncs on the opening thread; then an OS crash after the checkpoint keeps every line)
  • a_freeze_makes_the_next_open_write_the_lines_again_and_only_that_open (the freeze record), and the review tests listed in the review sections below
  • the S2 restart tests now flush after the reopen (the checkpoint runs on its own thread)
  • a_retention_rewrite_of_a_checkpointed_chat_does_not_wait_for_a_checkpoint; guards a_retention_rewrite_of_a_chat_with_records_past_the_checkpoint_checkpoints_first, a_retention_rewrite_of_a_replayed_chat_checkpoints_first
  • the open-time bench (scratch, never lands): open_time_after_a_kill_with_replayed_chats (10, 100 and 300 chats, 10 runs each; it prints ACPMUX-PERF openbench)

Red:

  • b2240f1b050: fleet 86535ce1 (aws-m4pro-4): "the open flushed the drive 283 times for 40 replayed chats".
  • 6def18f92c5: fleet 90d87e10 (aws-m4pro-5): "the open synced 283 times for 40 replayed chats before it returned".

Red, the freeze marker (a mutant at 07dc13192c8: this branch at 05cb007eeba with the open ignoring journal-frozen; branch fix/r-durability-rsync-nomarker-nomerge, never merged): an_os_crash_after_a_restart_from_a_failed_sync_keeps_every_acked_line fails, in the full run and alone, "the restart's checkpoint retired records whose lines a failed sync lost": macOS fleet cbd6e808, Linux fleet e4e9634c (hq run 41899881551). Nothing else fails.

Red, the retention wait, at d860bef9d32: a_retention_rewrite_of_a_checkpointed_chat_does_not_wait_for_a_checkpoint fails, in the full run and alone, "the retention rewrite of a checkpointed chat waited 1.72083425s for a checkpoint in flight" (macOS fleet 80f5ee16) and 1.70 s (Linux fleet 623bf9af, hq run 41908393200); the two guards pass.

Green at the head f9c6a16d1b8447f40274186314a64f67b80253c8:

  • macOS: fleet eb0777e8 (owned Mac aws-m4pro-3), journal_ 356 of 356; fleet a93736f5 (owned Mac aws-m4pro-6), retention 29 of 29 (the hub's retention tests and the three new ones).
  • Linux: fleet e109a17e (hq run 41908624629), all store::: fmt, clippy and 357 of 357 tests (acpmux 159, cmux-tui 41, cmux-tui-core 154, cmux-worktree-care 3).
  • At b0aee122fe2 (before the retention fix; the merge of the S3a N1 fix): macOS fleet eeaf9997, journal_ 353 of 353; Linux fleet 1e12de8d (hq run 41907479654), all store::: fmt, clippy and 354 of 354.

At 05cb007eeba (the merge of the review fixes, before the retention fix): macOS fleet ea82537d (journal_ 345 of 345), ee6bc8a4 (a_failed_log_sync_stops_the_daemon_for_a_restart 1 of 1); Linux fleet c44c6512 (hq run 41898799949, all store::: clippy and 350 of 350 tests; fmt red only on the base files #35845 fixed). fmt at b8159d903c8 after #35845: fleet e56e4c55, green.

Earlier, at b80145ec8a6: fleet bfb0a243 (macOS, journal_ 328 of 328); fleet 0577ac62 (Linux, store:: 331 of 332, with one failure from the idle checkpointer race that #35562 fixed at 5279af4bbf7; see that PR).

  • a_chat_deleted_and_made_again_never_takes_the_old_chats_verified_bytes, a_folder_replaced_outside_the_store_never_takes_the_old_chats_verified_bytes, a_failed_write_back_at_the_open_writes_the_lines_again (store::journal_write_back_tests, delta review 3)

Observed / Not run

  • Observed: the fleet steps above, each log read.
  • Not run: a dogfood daemon with the switch on and a real kill; a power-loss model of the barrier order (the argument is in the code comments and DESIGN.md 3.3e); the bench on Linux after this PR; the bench on another mini class (both distributions are from aws-m4pro-8; the M6 minis were not used).

Review of f9c6a16d1b8: what changed (P1-1, P2-1, P2-2)

Red tests first (the reviewer's) in 05493cad342. They fail at that commit, in the full run and alone, each for its reason (macOS fleet a3bd72b0, 3 of 27; Linux fleet 0f24f011):

  • a_failed_checkpoint_after_the_open_keeps_the_segments_it_read (P1-1).
  • a_freeze_during_a_checkpoint_still_makes_the_restart_write_the_lines_again (P2-1).
  • a_freeze_in_a_home_that_takes_no_new_file_still_writes_the_lines_again (P2-2).

The fixes:

  • f0ebe9e0ac4 (P1-1): checkpoint_once took Order::read_segments at its start and put the set back only when sync_read_segments itself failed. A checkpoint that failed before it (a journal sync slower than its wait, an image that could not be written) dropped the set, and the next checkpoint retired records whose log bytes no sync of this process covered. Now the checkpoint reads a copy and removes those paths only after their sync succeeds.
  • 101232eeb4d (P2-1 and P2-2): both came from one root. The restart learned of a freeze from a marker file whose write and removal the journal did not order. Now freeze appends a frozen journal record (kind 6) under the appends' lock, after every line it covers. The open that replays it writes every replayed chat's lines again. No checkpoint can take it back, because a checkpoint in flight read its LSN before the record. The record needs no write that can fail apart from the journal's own. The marker file is gone. A chat whose lines come from a repair file is written again too (Order::rewrite).
  • 24cc4ae39e4: a compile fix for 101232eeb4d (E0027: apply did not name the replay's new frozen field).
  • Green at 24cc4ae39e4: macOS journal_ (fleet 8095cfdd), Linux all store:: (fleet 65386a18), macOS restart tests (fleet ea9d4c48).

Merged down (head 60cce5e745b)

Delta review of f62feba6e3d (FIX): what changed (D1)

D1 (P2): when the journal refused the store's frozen record, the freeze only logged an error, and a restart in the same boot trusted the log pages the failed sync dropped; an OS crash then lost acknowledged lines. Three ways, one test each, red at 4a883c4f006 (and test 1 armed before the seal at 32fd1f845f4) on macOS (fleet c07e8938, ce3f21e3) and Linux (fleet 5234e655, hq run 41954644923), each in the full run and alone, each lost lines 2 to 5:

  • a_log_sync_that_fails_in_the_shutdowns_last_checkpoint_keeps_the_rewrite (the reviewer's): Hub::seal sealed the store, then ran its last checkpoint; a log sync that failed there found the journal closed.
  • a_frozen_record_while_the_journals_queue_stays_full_keeps_the_rewrite (the reviewer's, no seam in the store): a slow writer and other chats' lines kept the queue full, so the record was refused twice.
  • a_seal_while_a_freeze_is_under_way_keeps_the_rewrite: a failing flush_session held right before its freeze while another thread seals; handshakes only. (The reviewer's third test held the freeze between the two steps of f62feba6e3d, which the fix removes.)

The fix, three commits:

  1. 1617013d136: cmux-journal gets Journal::append_control, an append that a full queue never refuses (still Closed when the journal is closed, TooLarge past MAX_PAYLOAD_LEN; unit test a_control_record_is_taken_while_the_queue_is_full). The frozen record uses it (one per store, its reason cut to 4 KiB). So the freeze is one step again, inside the failed sync's ticket, with no retry and no wait: a checkpoint's wait is bounded by the syncs under way (the bound of acpmux: a checkpoint never writes an LSN past a sync that failed on another thread (R-DURABILITY; culprit 71bc09a1e63, S3a) #35895). The two-step freeze of f62feba6e3d, its seams, and the test of its retry are gone.
  2. 7b27d29b8be: JournalStore::seal seals the logs, runs the shutdown's last checkpoint while the journal is still open, then seals the journal. A failure in that checkpoint, or in a sync it waits for, journals its record before the journal closes.
  3. b3988138ef9 (red at 510cade7cf0: a_refused_frozen_record_falls_back_to_a_durable_marker, macOS fleet 8f90a826, fails, in the full run and alone: "a refused frozen record left no marker"): fail closed. A record the journal still refuses (its writer stopped), or a frozen store whose journal does not close with every record durable, leaves a durable marker <home>/journal-frozen (written whole, fully synced, renamed, the home folder synced), after the failed sync's ticket ends. The open reads it as it reads the record. The first checkpoint that succeeds after that open removes it, never while the store is frozen (so the P2-1 race cannot come back). Guard: a_checkpoint_does_not_wait_for_a_freezes_fallback_marker.

P3s: the module docs, the moved doc comment, and the two P2 review tests' text now describe the freeze as it is; the P2-1 test orders its threads by handshakes (a2475c77387). DESIGN.md 3.3c has the new bound and the rollback note: a build without this PR cannot open a journal with a frozen record (unknown kind 6); with the switch off, its catch-up fails and only logs, and the logs open alone.

Green at the head a2475c77387: macOS journal_ (fleet eca8d85e) 374 of 374; macOS cmux_journal (fleet b0970653) 42 of 42; Linux all store:: with fmt and clippy -D warnings (fleet 296eb354) 374 of 374 (Blacksmith; hq run 41957521689).

Delta review 2 of a2475c77387 (FIX): what changed (D2)

D2 (P2): after a failed sync, the restart trusted a whole log unless it found a frozen record or a marker. A kill before either was durable broke that. Two windows: the record still waited in the journal's queue (its append succeeded, so no marker), or a log's close only set its flag and no checkpoint had read it yet. The base wrote every replayed chat's lines again at each open, so it had neither window; this PR's fast path brought them.

  • Red, window 1, at 9125b99c745: a_kill_while_the_frozen_record_waits_in_the_queue_keeps_the_lines, the reviewer's repro (red on fleet e150f624 and e2ba4960), rebuilt with a hold in place of a 2 s sync and a 300 ms sleep (new SimStorage::hold_next_sync). It fails at journal_meta_tests.rs:1353, in the full run and alone, left: [1]: macOS fleet e8626223, Linux fleet a5d401ae (hq run 41965232344).
  • Red, window 2, at dcf9ed7a8aa: a_kill_after_a_failed_log_close_before_any_checkpoint_keeps_the_lines (the reviewer found it by reading): it fails at journal_meta_tests.rs:1393 after a clean compile, in the full run and alone, left: [1]: macOS fleet 11761812, Linux fleet e8e5017f (hq run 41966752715).
  • Fix at 2a065d25cd3, the reviewer's direction: the checkpoint writes back the bytes the open verified, then syncs (above, "What changes"). It needs no note, so neither window matters. A segment that is gone, replaced (another file id) or shorter is passed; a failed write or sync freezes the store. The crash model learns the write-back (sync_model::rewritten). The frozen record, the seal order and the marker stay for the chats the fast path does not take.
  • P3 4: JournalStore::seal's doc comment no longer starts with the old text. P3 3: "What changes" and "Testing" above describe the record, the write-back and the marker. P3 1 (apply_metas does not read rewrite_all, so a session.json from a failed sync is trusted) is R-DURABILITY: four older durability gaps from the reviews of #35895 and #35562 #35915 item 2. P3 2 (at shutdown only, a freeze can wait up to 5 s while the seal holds the appends' lock) is noted here; the wait is the journal's own seal wait, and nothing visible waits behind it.

Green at 6b0547afc43 (before the merge with #35963): macOS journal_ (fleet 1ef76c60) 376 of 376; macOS cmux_journal (fleet d9ac896f) 42 of 42; Linux all store:: with fmt and clippy -D warnings (fleet b33baa1b) 376 of 376 (Blacksmith; hq run 41967437029).

Merged down after #35963 landed (head 7f3e036d277)

#35963 (the #35905 fix) landed on feat and changed the same store files, so this PR conflicted with feat. 2e36ab0db02 merges feat 1b585e3ddca, with conflicts in 2 files, resolved by keeping both sides:

Checked, with no textual conflict: write_through counts the kept-aside image, which keep_aside_meta now writes through write_kept (a new file each time); the ordered folder sync honors the same test seams, so #35963's FAIL_DIR_SYNC_WHEN tests name the same syncs; and the D2 write-back passes a segment that a retention rewrite renamed over (another file id). 7f3e036d277 is a clean merge-main of feat cd9b4166785.

Green at the head 7f3e036d277: macOS journal_ (fleet c03d8a73) 380 of 380 (the 376 of 6b0547afc43 plus the 4 journal_sync_tests from #35963); macOS cmux_journal (fleet ed5a03c6) 42 of 42; Linux all store:: with fmt and clippy -D warnings (fleet 86498e8b) 380 of 380, fmt and clippy pass (Blacksmith; hq run 41969667583).

Delta review 3 of 7f3e036d277 (FIX): what changed (D3)

  • D3 (P2): the checkpoint's write-back found its segment by path, inode number and length (open_verified), and an inode number is not a file's identity. A chat deleted while its write-back was pending and made again under the same id, or a folder that code outside the store replaced (a transfer-in), could put a file with the old number at the path: ext4 hands a freed inode out again (the reviewer's Linux repro got the freed inode 13893637 back), and on Windows nothing was compared. The checkpoint then wrote the old chat's bytes over the new chat's acknowledged lines, synced them and retired their records.

  • Red b53a750bcd1: a_chat_deleted_and_made_again_never_takes_the_old_chats_verified_bytes (the reviewer's test, rsync_review5_a_deleted_chats_write_back_never_lands_in_a_new_chats_log, renamed; a second link stands in for the reused inode) and a_folder_replaced_outside_the_store_never_takes_the_old_chats_verified_bytes (the reviewer's request: no delete, the folder renamed aside and a new one put in, as a transfer-in does). Both fail at their own assertions after a clean compile, in the full run and alone: macOS fleet 6b7b8fa2, Linux fleet 55b36bf4 (hq run 41976946052), "the checkpoint wrote the old chat's verified bytes into a new chat's log" (line 179) and "... into a folder put in its place outside the store" (line 229). The reviewer's ext4 test is not taken: on a file system that does not give the inode out again it fails as "inconclusive".

  • Fix 4846ec2b09e, the reviewer's direction: the open writes the verified bytes back (Inner::write_back), and the checkpoint only syncs (sync_read_segments). A write error at the open neither fails the open nor freezes: that chat takes the slow path, whose cut also removes a range written back in part (new test a_failed_write_back_at_the_open_writes_the_lines_again). The write opens the segment as every read does, plus the lost-home check (O_NOFOLLOW, never made, owner, mode and macOS ACL checks; Windows owner_only::open_rw_existing): the reviewer's P3 2. Store::delete drops its chat's listed segments.

  • 4846ec2b09e kept the handle the open wrote through until the checkpoint synced it. Before its review, the reviewer named two costs of that, and 3452068e332 removes them: the checkpoint now syncs each listed segment by path and holds no handle.

    • Descriptors: one per replayed segment until the first checkpoint that succeeds, longer while checkpoints fail. The daemon raises its soft limit to min(hard, 65536), but when the raise fails the limit stays (256 under launchd), and 300 replayed chats would use them up during the open.
    • Windows: a folder whose file is open may refuse a rename or a removal (a transfer-in's rename aside, Store::delete).
    • What the sync by path gives back: a folder moved aside during the window between the open and its checkpoint (the reviewer's P3 1, a transfer-in that fails and renames its backup back) is passed and its records retired, as before D2. That is now R-DURABILITY: four older durability gaps from the reviews of #35895 and #35562 #35915 item 6 (issuecomment-6652305673), the class of item 4.
  • ff6922678dd: clippy -D warnings failed the Linux step at 4846ec2b09e on an unused std::io::Write import in the new tests (fleet bd0548b2, hq run 41979974134; every test passed there).

  • Open-time A/B (the reviewer asked for it), the PR's bench, 10 runs per size, all on owned Mac aws-m4pro-8, in ms, median / p90 / max:

    Measure, chats A: 7f3e036d277 (fleet e5daffbe) B: 4846ec2b09e (fleet c69599be) C: 3452068e332 (fleet 93887e54)
    open, 10 5.0 / 9.7 / 9.7 6.1 / 7.6 / 9.3 5.9 / 8.7 / 9.7
    open, 100 17.1 / 20.0 / 21.4 17.1 / 22.8 / 31.4 34.5 / 37.9 / 37.9
    open, 300 35.6 / 43.5 / 47.8 39.1 / 42.3 / 44.9 115.8 / 120.2 / 126.3
    ready, 10 9.0 / 13.5 / 14.1 8.0 / 9.6 / 12.1 8.3 / 14.9 / 16.5
    ready, 100 19.8 / 22.3 / 25.4 18.8 / 25.2 / 34.1 50.4 / 57.6 / 57.7
    ready, 300 37.7 / 45.5 / 49.6 40.9 / 44.9 / 47.1 137.2 / 148.2 / 163.9
    first acknowledged change, 100 2.4 / 4.0 / 4.5 1.8 / 2.4 / 2.6 16.0 / 19.8 / 20.1
    first acknowledged change, 300 2.1 / 2.6 / 2.8 1.9 / 2.2 / 2.6 22.6 / 28.3 / 37.6
    durable, 100 1,323 / 1,939 / 2,166 993 / 1,582 / 2,164 922 / 1,229 / 1,235
    durable, 300 3,364 / 5,407 / 13,054 2,882 / 3,654 / 4,301 2,438 / 3,376 / 3,532

    The three ran one after the other (06:36Z, 07:01Z, 07:31Z), not interleaved. B against A is inside the same-arm spread on the open. C is worse than A and B on the open, ready and the first acknowledged change at 100 and 300 chats, beyond the same-arm spread (C's fastest open with 300 chats, 113.8 ms, is slower than A's slowest, 47.8 ms); with 10 chats nothing changes. In the open, B and C differ in one thing only: B closes each written-back segment after the checkpoint's sync, C right after the write.

    The rerun confirmed it on two more Macs and in both orders: median open with 300 chats, B 52.8 against C 130.8 ms on aws-m4pro-6 (the two run at the same time, fleet dab0d89f and 15f9cd66), B 42.0 against C 121.3 ms on aws-m4pro-9 (C first, then B: fleet 69c4e412, d741646f). The first acknowledged change: 2.3 against 29.4 ms, and 2.4 against 30.4 ms. So on macOS the close right after the write costs the open about a quarter of a millisecond per replayed chat.

  • 29967f9f5f7: the open keeps the handle it wrote each segment through (repair::WrittenBack::Held), up to repair::held_cap: a quarter of the open-file limit (which the daemon raises before the store opens), at most 4096, and 64 when the limit cannot be read. The checkpoint syncs a held segment through its handle and closes it on its own thread, after the sync. A segment past the cap, and every segment on Windows, is closed at the open and synced by path, as at 3452068e332. The checkpoint still writes nothing, so D3 stays closed. Within the cap on Unix, a held handle also covers a folder moved aside and put back before the checkpoint (R-DURABILITY: four older durability gaps from the reviews of #35895 and #35562 #35915 item 6), since a rename keeps the file the handle holds. Store::delete takes its chat's entries out under the appends' lock and closes their handles after it lets the lock go. New test: the_written_back_segments_past_the_cap_are_synced_by_path (a cap of 1: one segment held, two closed, then every acknowledged line after the checkpoint and an OS crash).

  • Open-time A/B at 29967f9f5f7 (arm E), median / min to max, ms:

    Measure, chats A 7f3e036d277, aws-m4pro-6 (fleet c74909f1) E 29967f9f5f7, aws-m4pro-6 (fleet 1021ba81) E 29967f9f5f7, aws-m4pro-8 (fleet cfcb5ffd)
    open, 10 10.2 / 5.3 to 13.6 10.0 / 6.3 to 12.2 9.9 / 5.2 to 17.8
    open, 100 19.1 / 16.0 to 55.0 22.4 / 17.9 to 33.9 20.9 / 18.8 to 28.1
    open, 300 40.6 / 38.4 to 44.0 41.6 / 36.0 to 61.7 48.3 / 40.4 to 68.9
    ready, 300 43.2 / 40.3 to 46.2 44.0 / 37.5 to 63.5 50.7 / 43.0 to 73.4
    first acknowledged change, 300 2.5 / 1.6 to 2.7 2.4 / 1.6 to 3.5 2.6 / 1.8 to 5.3
    durable, 300 4,541 / 4,435 to 5,143 4,358 / 4,305 to 6,222 4,360 / 4,282 to 5,977

    A and E on aws-m4pro-6 ran at the same time, so they share the host's load: every difference is inside the same-arm spread, so the head is not worse than the code before D3 by the floor rule. E on aws-m4pro-8 ran an hour and a half after the D draft there (09:32Z against 07:59Z) (18612dc, which has the same open path: it differs only in the delete), whose open with 300 chats was 40.8 ms; that gap is the host between the two times, not the code.

Green at the head 29967f9f5f72b3fc668a1d20c6d5211edbc92f81: macOS journal_ (fleet dbb06014) 384 of 384; macOS cmux_journal (fleet 31ef1527) 42 of 42; Linux all store:: with fmt and clippy -D warnings (fleet fc960a45, Blacksmith, hq run 42003274872) 384 of 384. Each has the four journal_write_back_tests. (At 3452068e332: 383, 42 and 383 of 383, fleet 01ee21d5, 4fff42b7, e125df6c.)

Changelog

none

Proof

No capture: a daemon storage change behind a default-off setting, with no UI.

…nd ack after durable (R-DURABILITY S1)

A new crate, no consumer yet. It is stage 1 of the R-DURABILITY rewrite
(handoff/rewrites/R-DURABILITY/DESIGN.md): every acknowledged change of
the cmux-tui daemon and of acpmux goes into one journal of this format,
and a reply goes out only once its record is synced.

- Journal::append copies one record into memory (no I/O, no wait) and
  returns its LSN. One writer thread takes every pending record as one
  batch, writes it with one write, and syncs it once when it holds an
  acked record (group commit). Durable listeners then release the
  replies that wait for those LSNs. Stream records (terminal output,
  streamed tokens) are written in the next batch and synced with the
  next acked batch.
- Sync levels: F_BARRIERFSYNC per acked batch on macOS (fdatasync on
  Linux), and F_FULLFSYNC at most 1 s after the first barrier sync, so a
  power loss takes back at most 1 s of acked batches, always a suffix.
- Recovery is the one start path (clean exit, crash, kill -9, OS
  crash): it syncs the last segment, checks every record's CRC and LSN,
  and hands the records after the caller's compaction point to its
  replay. A segment is only ever appended to: a torn tail stays in its
  file and the journal goes on in a new segment whose header names the
  last good LSN. Damage that is not a torn tail moves the later segments
  to quarantine/ (never deleted), and no LSN is used twice.
- A failed write is cut back and written again in the same segment; a
  failed sync is never retried on that segment (its pages may be clean
  and lost): the records after the durable LSN go into a new segment.
- Storage is the file system seam. The `sim` feature's SimStorage keeps
  synced and unsynced bytes apart and crashes at any storage operation,
  then keeps what a process crash, an OS crash or a power loss keeps.

Tests: unit tests of the format, group commit, ack after durable,
listeners, sync levels, idle (no storage operation), backlog, seal,
lock, rotation, failed writes and syncs at every operation, torn tails,
quarantine and LSN reuse; the crash simulation (200 seeds per crash
kind by default, CMUX_JOURNAL_CRASH_SEEDS for the acceptance run); and
a round trip with a torn tail on the real file system.

OWNERS.md: a row for the crate (R-DURABILITY rewrite owner).
…clippy)

Fleet job 1a494d9372ec2bb7d71e8e1e at 26e4e71 refused two lines:
E0689 (is_multiple_of on an untyped range in a test) and dead_code
(Active::seq in the writer: the next segment's number is next_seq).
The acpmux notify::Watcher import error in the same run is a base red.
… segment while nothing is owed

- macOS sync_file fell back to fsync on ANY fcntl error. An EIO from
  F_FULLFSYNC or F_BARRIERFSYNC then became a success of a weaker sync,
  and the writer counted the records durable. Now only "not supported"
  (ENOTSUP, EOPNOTSUPP, EINVAL, ENOTTY: a network or FAT volume) falls
  back; any other error reaches the writer, which starts a new segment.
- After a rotation the writer made the next segment at its next pass
  even with nothing to write (the shutdown pass did it too). A segment
  is now made only when records or a full sync are owed.
- Test-only and lint-safety rewrites: a slice parameter in place of
  &Vec, a type alias for the crash model, a range check, one split
  match scrutinee.
Fleet job 7f6bfd5afb225cd1cee9d0f1 at 36f09c8: 24 of 25 crate tests
pass (the three 200-seed crash simulations included). The one red,
a_compaction_point_past_the_end_never_reuses_an_lsn (left [1], right
[1, 10]), was a test fault: it appended LSN 10 in the restarted storage
and then read back the first one. The clippy type_complexity hit of the
same run is the crash model type, fixed in f55f8c5.
…rate

Merge-main commit by scripts/merge-main.sh.
Merged by scripts/merge-main.sh: origin/feat-cmux-next at 3a43646.

Merge-main-previous-head: 0027391
Merge-main-base: 3a43646
fcntl(F_BARRIERFSYNC / F_FULLFSYNC) and fsync can return EINTR. The
writer counted that as a failed sync and moved to a new segment. Now
the call is made again, as std's sync_data and sync_all do (cvt_r).
…1 review regressions (red)

The S1 review (FIX at 89293fb) found that the crash simulation could not find
its F1 and F4: it never crashed inside recovery, a failed operation had no partial
effect, and a failed sync never lost a page. This commit changes only the
simulation and the tests; the next commit fixes the journal.

The simulated machine (sim.rs) now has:
- a page cache over a disk, per 4 KiB page; a sync writes the dirty pages; a sync
  that fails writes some of them and drops the rest as clean, so a later sync
  reports success without them (Linux write-back error behavior);
- a write that fails writes a random prefix first;
- one ordered log of names (journaling file system): a folder sync makes durable
  every change up to the last one in that folder; a rename between folders is one
  change; an unsynced folder can be lost;
- a process crash keeps the machine whole, so a later OS crash still loses what
  it would have lost;
- an OS crash writes each dirty page whole, not at all, or a prefix of its
  sectors; a power loss also cuts back to a common point after the last full sync;
- counters of open files and of injected failures.

The crash runs (crash_sim_tests.rs) now run several epochs per seed: appends, a
crash at a random operation (sometimes after a failed write or sync), and an open
that sometimes crashes inside recovery and is tried again. After each open the
oracle checks every record byte for byte, that every record an earlier open
replayed is still there, that the epoch's records are a prefix of its appends, and
that every acked record (fully durable after a power loss; every written one when
only processes crashed) survives. Four runs: process, OS, power loss, and mixed.

Regression tests for the review findings, red on the code before the fix:
- F1 a_crash_inside_the_quarantine_step_never_reuses_a_quarantined_lsn
- F2 a_damaged_header_in_the_last_segment_never_reuses_its_lsns
- F3 a_panic_on_the_writer_thread_closes_the_journal
- F4 a_restart_after_a_failed_sync_keeps_every_later_ack
- F6 recovery_holds_at_most_two_files_open
…eral segments (red)

The reviewer's F2 scenario has two segments: S1 holds 1..10, S2 (the last) holds
11..20, and S2's header is damaged. The test now rotates segments (300-byte
segments, 20 acked records) and damages the last one's header, so the report
must say lost_through 20 and the next append must take 21. On the code before
the fix the moved segment's LSNs read as 0, so the next append reuses one.
…S crash) (red)

On the code before the S1 fix, the crash-sim step was green (fleet 184129d8 at
263da86): 6 of 7 runs passed, and the mixed run failed only seed 140 in the
full run and passed when it ran alone. The random faults reached the order the
review found (F4) in 1 of 800 runs. So these tests were no red/green proof.

The order: the sync of a write that spans pages fails and the disk gets none of
them (Linux keeps them clean in the cache, so a later sync reports success);
the process is killed in the writer's backoff; the next open reads the failed
segment from the cache, replays its records and goes on; an OS crash or a power
loss then takes those pages, and records an open already replayed are gone.

- sim.rs: `fail_next_sync(pages, kind)` fails the next sync that has at least
  that many dirty pages, and the disk gets none of them. `crash_after_failure(n)`
  makes the n-th operation after the next injected failure the crash point.
- crash_sim_tests.rs: a new run forces the order on every seed
  (a_kill_after_a_failed_sync_then_os_crashes_keep_every_acked_record): epoch 0
  aims the fault, the kill is a process crash at the writer's retry, the open
  after it does not crash, epoch 1 ends in an OS crash, and a third open checks
  that the records the second open replayed are all there. In the random runs,
  one epoch in four aims the fault, with the crash 1 to 3 operations after the
  failed sync and a crash of any kind after it.
- A sim test checks the aimed fault itself (the_sim_fails_the_aimed_sync_and_crashes_in_the_backoff).

Expected on this commit (old journal code): the forced run fails every seed; the
mixed run fails most runs. The fix commit follows.
…y, LSN floor, writer end)

Review FIX at 89293fb (PR #33919 comment 6610468202). The regression tests
are the two commits before this one.

- Sync markers (format.rs): an entry with flags 1 and no payload, whose LSN field
  says "every record of this segment before me is synced". The writer appends one
  after each successful sync that covered records (after the durable listeners, so
  no reply waits for it), and syncs it fully at a seal and at a rotation.
- F4, recovery (step 4 and 6): the last segment is trusted only up to its last
  marker. Its records after the marker may sit in pages a failed sync dropped (a
  later sync on another descriptor reports success without them), so recovery
  writes them again into a new segment: header, records and a marker, fully
  synced under `<name>.seg.new`, renamed into place, folder synced. The journal
  always goes on in a segment this open made, never in one an earlier process
  wrote. A restart in the writer's backoff after a failed sync is then safe.
- F1, quarantine: every LSN a quarantined segment holds counts in the LSN floor
  at every open, and a quarantine that no new segment followed (a crash in the
  middle of an earlier recovery) is reported as damage again. The highest LSN of
  every moved segment is read before the first rename.
- F2, highest LSN: a segment whose header is damaged is read from its first
  record (each record's checksum covers its LSN), then the bytes after the first
  bad entry are scanned for records and markers that still check out. The same
  scan runs after a cut in the last segment: a marker there is media damage
  (reported), records there are a torn write (`torn_lsns`); their LSNs are never
  used again.
- F3, writer end: a guard closes the journal when the writer thread ends, also by
  a panic: appends fail with Closed, waits end with WriterEnded, the listeners
  are dropped (so a channel a held reply waits on closes), and stats.failing says
  why. The crate does not abort the process; the daemon owns that policy.
- F6: recovery closes each segment after its header is read and opens one again
  only to read it. (Retirement of compacted segments is an S2 API, before the
  first consumer.)
- F5: recovery syncs the parent folder after it makes the journal folder.
- F7: format.rs and lib.rs now say what the code does: a failed write is cut back
  and retried in the same segment; the cut removes only bytes never synced and
  never acknowledged; nothing shortens a segment past a synced record.

Docs: journal.rs says when open's replayed records are durable (on Ok) and what a
stopped writer does.
… (review G5)

sim.rs had 924 lines and 63 functions, over the 60-function limit for a new
Rust file (the god-file check warned "not in baseline: split it"). The
machine model (each file's page cache and disk, the ordered log of names,
and what an OS crash or a power loss leaves) moves to sim/machine.rs (297
lines, 19 functions); the process view and the Storage implementation stay
in sim.rs (639 lines, 30 functions). No behavior change: the items move as
they are, made visible to the sim module.
…ash sim (red)

Re-review FIX at 3498975 (PR #33919 comment 6620113993). Tests and sim
only; the fix follows.

- sim: the pages a failed sync dropped stay in the cache until an eviction;
  `evict_dropped_pages_after_read(true)` makes each read evict the dropped
  pages it read, so a second read of them sees the disk's bytes.
- G1 recovery_copies_the_records_it_checked_when_the_cache_drops_them: a sync
  fails and drops record 2's pages, a kill in the backoff, the next open reads
  record 2 from the cache (replayed) and the pages are evicted after that read;
  then an OS crash. Record 2 and the record 3 acked after the restart must
  survive. On this code recovery's second, unchecked read stages zeros under a
  vouching marker, and the last open keeps only record 1 and reports damage.
- G2: the F1 regression asserts that every open after the damage reports the
  loss (lost_through 40). On this code a crash at the open's last step (the
  folder sync after the new segment's rename) loses the report for good.
- G3 a_journal_in_a_symlinked_folder_opens_and_round_trips (real file system):
  a journal in a symlinked state folder, and a journal folder that is a
  symlink. On this code the parent folder's sync opens with O_NOFOLLOW (ELOOP).
- G4 an_open_after_a_finished_quarantine_reads_no_quarantined_segment: on this
  code every open reads every quarantined segment in full.
Re-review FIX at 3498975 (PR #33919 comment 6620113993); the regression
tests are the commit before this one (red: fleet de35f6d0).

- G1: recovery writes again exactly the bytes its checked read returned. The
  last segment's entries after its last marker are kept from the checked read
  (header and payload) and staged from memory; the second, unchecked read of
  the file is gone. A page that a failed sync dropped can be evicted between
  two reads, and the second read then returned the disk's zeros, which the
  copy kept under a vouching marker.
- G2: the damage an open finds is written to `DAMAGE` (whole, fully synced,
  renamed into place, folder synced) before the new segment that hides it from
  later opens. Every open reports the recorded damage first, until the daemon
  calls the new `Journal::clear_damage`, which moves the record into
  `quarantine/` (kept, never deleted).
- G3: a folder sync follows a symlink in its path (no O_NOFOLLOW): a state
  folder, or the journal folder itself, may be a symlink. The sync is for the
  folder that holds the entries.
- G4: the quarantined segments are read for their LSNs only while no live
  segment is numbered above them (an unfinished quarantine); once one is, its
  first LSN already carries their floor.
- G6: `checked_div` in the scan of the bytes after a cut (the crate's one
  tracked arithmetic hit).
- G5 (the sim split) is the commit before the regressions.
Third review FIX at cb893c6 (PR #33919 comment 6621681018). Tests only.

- G7 A a_damaged_header_after_a_quarantine_never_reuses_a_quarantined_lsn:
  after a finished quarantine, one byte of the empty new segment's header
  changes. On this code the next open skips the quarantine scan (the listing
  counts the damaged segment) and the next append reuses a quarantined LSN.
- G7 B a_damaged_newest_segment_after_a_quarantine_reports_its_records: the
  newest segment holds acked 41..43, and its header and its first record are
  damaged. On this code the moved segment is scanned with a base far below 41,
  so the loss is not reported through 43 and the next append is not 44.
- N1: the F1 regression also asserts that an open reports each loss once (no
  reported LSN range lies inside another). On this code a crash after the
  quarantine's renames makes the next open report the same loss twice.
…y.rs

Third review FIX at cb893c6 (PR #33919 comment 6621681018); the
regression tests are the commit before this one.

- G7: the quarantine scan is skipped only when a segment whose header checked
  out is numbered above every quarantined segment (the folder listing also
  counted a newest segment with a damaged header, which carries no floor). The
  scans start from the running floor: `quarantined_floor` and `quarantine`
  pass the highest LSN counted so far as the base of the next file's scan, so
  a quarantined file with no readable header and no readable first record is
  scanned for the LSNs right above what is counted.
- The "unfinished quarantine" report is made only when the quarantine holds
  LSNs nothing else counted (an old quarantine behind a segment that a crash
  tore at its creation holds none, and a crash is not damage). Its reason now
  also names a damaged newest header.
- N1: a loss whose LSN range lies inside a recorded one is the same loss found
  again after a crash: it is neither recorded nor reported twice.
- N2: recovery.rs's docs name `DAMAGE`; two doc lines over 100 columns are
  wrapped (lib.rs, sim.rs).
- recovery.rs was at 987 of its 1000 lines: the damage record moves to
  recovery/damage.rs, the entry reader and the LSN scans to recovery/scan.rs
  (moved as they are).
…e (N1)

The red run of the N1 regression (fleet 27d011ca, op 64 of the F1 test) shows
the case the previous commit did not cover: a crash after the quarantine's
renames and before DAMAGE is written; the next open finds both the damaged
synced record of the last segment (7..14) and the unfinished quarantine
(7..40), and reported both. Now every loss inside another (recorded or found
by this open) is dropped, the larger range kept; DAMAGE is rewritten only
when its set of losses changed.
Fourth review FIX at 215c8f0 (PR #33919 comment 6622740919). Tests only.

- G8 a_recovery_copy_above_a_quarantine_never_reuses_a_quarantined_lsn (the
  reviewer's three steps): records 2..5 unsynced and a kill -9; open #1 copies
  them into C1 and goes on there with 6..10; C1's header is damaged, open #2
  quarantines C1, copies 2..5 again into C2 and makes the empty E (first LSN
  11); E's header is damaged. On this code open #3 sees C2 numbered above the
  quarantine, skips its scan and gives 6, an LSN the quarantine holds.
- N3: G7 B and the F1 test assert that every file in quarantine/ is named by
  a reported loss. On this code the absorbed loss's names are dropped (G7 B).
- N4: the F1 test asserts that the loss through 40 names the segment where
  the journal ends (00000000000000000002.seg). On this code a crash after the
  moves into quarantine/ and before DAMAGE turns it into the vaguer
  "unfinished quarantine" report.
… the moves (G8, N3, N4)

Fourth review FIX at 215c8f0 (PR #33919 comment 6622740919); the
regression tests are the commit before this one.

- G8 (the G7 class): the skip of the quarantine scan trusted a live segment
  numbered above the quarantine to carry its floor, which a recovery copy
  does not (its records start at vouched + 1). Now each moved segment is named
  `<segment>.<n>.<floor>`, with the highest LSN counted when it moved (that
  file's and every one before it, scanned from the open's floor), and every
  open takes the quarantine's floor from the names alone: no quarantined file
  is read (G4 stays), and no header can hide the floor. `quarantined_floor`
  and the `checked_max_seq` skip are gone. The "unfinished quarantine" report
  is made when the names' floor is above everything else counted.
- N4: `quarantine()` is split into `plan_quarantine` (measure and name every
  file, no change on disk) and `move_quarantined`; the loss, with the planned
  names, goes to DAMAGE before the moves. A crash after the moves keeps the
  precise report (the damaged segment and reason).
- N3: when one loss absorbs another, the absorbed loss's files and reason go
  to the kept one, so DAMAGE names every quarantined file.
- Docs: recovery step 5 and lib.rs name the quarantine file format.
…essary_sort_by)

Fleet d9f22bed at b589044: the 33 journal tests pass, and clippy -D
warnings fails at recovery.rs:478 (`consider using sort_by_key`).
…S2's projector)

R-DURABILITY S2 (acpmux event logs through the journal) projects each
chat's log line into its ndjson file once the journal has WRITTEN the
batch that holds it, so readers see stream lines at once. The listener
runs on the writer thread after the write and before the sync, once per
batch that moved the written LSN: a batch written again into a new
segment after a failed sync is not heard twice. When the writer stops,
these listeners are dropped too. Test: heard while the batch's sync still
runs, once.

Port of 145faa6 (branch rewrite/r-durability-s2-acpmux-logs) onto the
S1 review fixes.
…paction point covers)

R-DURABILITY S2: a daemon's projector compacts the journal into its own files
(acpmux: the chat ndjson logs, then a synced checkpoint). Without a delete the
segment count only grows (review finding F6). `retire_through(compacted)`
deletes the segments that hold no record after the caller's compaction point:
a segment goes when the next segment's "previous last LSN" is at or before it,
so the newest segment never goes. The point is capped at the durable LSN. The
deletes and a folder sync run on the caller's thread (the projector, never a
request path). A delete that fails leaves its segment live for a later call.

Why a crash during a retirement is safe: the caller reopens from a point at or
after `compacted`, and recovery reads nothing from a segment the point covers
(it skips it on its next segment's header), so any subset of the deletes may
reach the disk. A point older than the retired segments is reported as
Damage::Missing. Quarantined segments are never deleted.

- Storage::remove_file (FsStorage: std::fs::remove_file; SimStorage: one more
  change in the ordered log of names, durable at the folder sync).
- The live segment list: recovery's, then each segment the writer makes.
- JournalStats::segments_retired.
- Tests: only covered segments go, and a second call deletes none; an OS crash
  after it keeps every record after the point and an older point is reported;
  a crash at every operation of a retirement (process, OS crash, power loss)
  keeps every record after the point and never reuses an LSN; the newest
  segment stays; a point in the middle of what this writer wrote.

This adds a delete of journal data, so its PR is risk:high.
store.rs (2139 lines, 120 fns) and config.rs (1909 lines, 97 fns) are over
the god-file limit, and the journal store (R-DURABILITY S2) must not grow
them. MemoryStore moves to store/memory.rs and StoreMode, StoreConfig and its
defaults to config/store_config.rs; both stay exported where they were
(crate::store::MemoryStore, crate::config::{StoreConfig, StoreMode}). No
behavior change: the code moves as it is.
R-DURABILITY S2 (DESIGN.md 3.3b). Today no event-log append is synced: a line
a client was told about is on the disk only after the next flush (shutdown),
so an OS crash or a power loss takes back every line since (E12). This commit
adds only the switch, a seam and the tests; the journal store follows.

- `store.journal` (StoreConfig::journal, default false).
- `Store::wait_durable(timeout)`: tests and tools wait until every appended
  record is durable. The default returns at once (the Local and Memory stores
  never sync an append), which is today's behavior.
- `store::open_with_journal` (tests): the store with the journal on a given
  `cmux_journal::Storage`. Here it ignores the journal and opens LocalStore.
- acpmux depends on cmux-journal (dev: with the `sim` feature).
- store/journal_store_tests.rs, red on this commit:
  - a_line_the_store_says_is_durable_survives_an_os_crash (lines 2..20 are
    lost: nothing synced them);
  - a_deleted_chat_stays_deleted_after_a_crash and
    a_line_the_log_refused_is_never_written_later pass here, and must keep
    passing with the journal store (no replay may bring them back);
  - a_kill_keeps_every_line_once passes here and must keep passing (a replay
    after a kill must write no line twice).
…DURABILITY S2)

DESIGN.md 3.3b. With `store.journal` on, each event-log append is one record
in `<home>/journal/` (cmux-journal: group commit, one sync per batch; a power
loss takes back at most the last second, always a suffix), then the same line
goes to the chat's ndjson as before. Readers, segments and file formats do not
change. Default off.

- store/journal_store.rs (JournalStore over LocalStore):
  - append: under one lock, the journal record (a memory copy, no I/O), then
    the log line; a line the log refuses gets a `cancel` record. Agent stream
    lines (`in` session/update, claude.*, codex.*) are Class::Stream, every
    other line Class::Ack.
  - checkpoint (`<home>/journal-projected.json`): every 30 s while the journal
    grew, past 64 MiB, at flush (shutdown) and before a retention commit. It
    syncs each log written since (LocalStore::sync_log, with no lock held
    while the disk works) and the sessions folder, writes the LSN (temp file,
    sync, rename, folder sync, every error checked), then retires the journal
    before it. A log close that failed its sync freezes the checkpoint until a
    restart (LocalStore::sync_failed).
  - recovery: the records after the checkpoint; for each chat, the log is cut
    right after its last readable line below the first journaled seq (later
    segments removed, the boundary one shortened, their kind-index copies
    dropped), and the journaled lines are written again in order. `forget`
    deletes a chat again; `cancel` keeps a refused line out.
  - delete: `forget` record, folder removal, then a bounded wait until the
    record is durable.
  - seal: the logs refuse writes and the journal syncs fully.
- LocalStore: `append` is serialize + `append_line`; `sync_log`; a failed sync
  at a log's close is noted (`sync_failed`).
- store::open opens the JournalStore when `store.journal` is on.

Not yet (S2c): replies and events do not wait for durability; the daemon does
not stop when the journal closes; the writer thread keeps the process's
Utility I/O policy.
…its delete (red)

- a_chat_made_again_after_its_delete_keeps_its_files_through_a_replay: a chat
  is deleted (its forget record), made again under the same id (a transfer
  back: session.json and new lines), then a kill -9. On this code the replay
  deletes the folder again and its session.json is gone.
- a_line_after_the_seal_is_refused_and_never_written_later: coverage of the
  seal (it passes here; the fix also closes a race the test cannot force).
…ps a chat made again

- seal takes the appends' lock: an append lands in both the journal and the
  logs, or in neither. Before, an append that raced the seal could leave a
  journal record whose line the sealed logs refused (its cancel then failed
  on the closed journal), and the next start would write that line.
- append and delete check the logs' seal first, under the same lock, and do
  not touch the journal after it (no error per append at shutdown).
- replay deletes a forgotten chat again only when no line follows its forget
  record: otherwise the chat was made again under the same id, and its folder
  holds that chat's own files.
Fifth review FIX at 9c47594 (PR #33919 comment 6623794646). Tests only.

- a_skip_hidden_behind_a_recovery_copy_is_never_given_out_again (the
  reviewer's route): record 1 synced, 2..10 Stream and 11 Ack synced as one
  run with marker 11, media damage in record 5. Open #1 reports (4, 11],
  copies 2..4 into segment 2 and makes the empty segment 3 from 12. Segment
  3's header is damaged. On this code open #2 reads segment 1 only through
  the copy's previous last LSN, never scans the damage again, and gives 5.
- torn_lsns_hidden_behind_a_recovery_copy_are_never_given_out_again: the same
  for torn records 4..5 (a tear, then a whole record that no marker vouches
  for) behind the copy of 2..3: on this code the next append gets 4.
Fifth review FIX at 9c47594 (PR #33919 comment 6623794646); the
regression tests are the commit before this one.

When an open gives out LSNs above everything its kept and new segments say
(damaged synced records or torn records in the last segment, which a copy
then hides from every later open), the only record of that floor was the
header of the empty segment it makes; a damaged header lost it, and DAMAGE
is cleared by `clear_damage` (torn records are not damage at all). Now such
an open makes the empty file `FLOOR.<lsn>` and syncs its folder before any
new segment, and every open takes the highest floor from the folder listing
(no file read), as it does for the quarantine names. The caller's compaction
point, the quarantine's names and this open's moves say their floors
already: no file is made for them.
Both sides added tests after the same test in cmux_journal_tests.rs; both
are kept (the G9 tests, then the S2 retirement section).
Review FIX at ab0e953 (PR #34901 comment 6624439824). Each test fails
on this commit and passes after the fix that follows it:

- 1a lines_written_with_the_switch_off_survive_turning_it_on_again
- 1b a_kill_while_the_journal_syncs_keeps_the_lines_the_log_got
- 1c a_line_flush_session_made_durable_survives_losing_its_record
- 2 a_folder_put_back_after_a_delete_is_not_deleted_by_the_replay
- 3 a_kill_in_a_delete_leaves_the_chat_whole_or_gone
- 4 a_delete_does_not_wait_for_the_journal_sync
- 5 a_failed_log_sync_freezes_the_checkpoint,
  a_checkpoint_that_keeps_failing_asks_the_daemon_to_stop
- 6 one_chat_that_cannot_be_repaired_does_not_stop_the_open
- 7 the_journal_storage_refuses_a_lost_home
- the repair keeps a log's lines after a hole, also through a crash in
  it: an_os_crash_hole_is_repaired_and_the_lines_after_it_kept,
  a_crash_inside_a_repair_loses_no_line
- nits: an_unreadable_checkpoint_fails_the_open,
  the_open_waits_for_the_previous_daemon_to_let_go_of_the_journal,
  a_crashed_checkpoint_temporary_is_removed_at_open,
  a_crash_right_after_a_checkpoint_reports_no_damage
- the catch-up with the switch off:
  switching_off_after_a_crash_keeps_every_acknowledged_line
- cmux-journal: check_append_says_what_append_would_do

So they compile here, this commit adds the seams they use, with no
behavior: `Store::stop_reason` (default None), `Journal::check_append`
(always Ok), `FencedStorage` (never asks its fence), the open report and a
test stop point after a repair's cut.
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Review (r-durability-rsync-reviewer): FIX at 7f3e036

Delta review 3: a2475c77387..7f3e036d277 in cmux-tui/crates/acpmux/src/store/ and cmux-tui/crates/cmux-journal/. I read the D2 reds and fix, the seal doc fix, and both merges (2e36ab0db02 with git show --remerge-diff, and the clean 7f3e036d277). Line numbers are at 7f3e036d277.

Repro tests (never merge): branch fix/r-durability-rsync-review-inode-reuse-nomerge at 19ddd526b58, which is this head plus store/journal_review_tests.rs (prefix rsync_review5_). Fleet cmux-tui-rust-check.sh test rsync_review5, each log read:

  • macOS: fleet 857c0030 (aws-m4pro-5). The deterministic test is red, in the full run and alone. The real-inode test is Linux only.
  • Linux: fleet 878ccf7a (hq run 41971869419, Blacksmith Ubuntu 24.04). Both tests are red, in the full run and alone.

D2 from my last review: fixed

  • The open keeps each verified range with its bytes (repair.rs:213-234). The checkpoint writes them back and syncs (checkpoint.rs:160-166, :210-236). This needs no note of the failure, so both windows close. The reds and the greens are as stated: e8626223 and a5d401ae for window 1, 11761812 and e8e5017f for window 2.
  • The range is exactly the bytes the open verified. In the whole case every piece is a whole line equal to a merged line, so the range runs from the repair start to the segment's end at the read. Later appends go after it (O_APPEND). Within one file, nothing in this process changes those bytes: only the open's own repair cuts, and a compaction renames a new file over the path.
  • Window 2 is also covered for a chat that this run did not replay. Its lines since the last checkpoint are journaled, so the next open replays them, verifies them and writes them back.

Finding

D3 (P2). The write-back finds its file by path, inode number and length. An inode number is not a file's identity, so the write-back can write a deleted chat's bytes into a new chat's log.

  • Where:
    • open_verified (checkpoint.rs:598-622) accepts the file at the path when its inode equals the one read at the open and the file is long enough. On Windows it checks no identity at all (same = true, :618).
    • Store::delete (journal_store.rs:787-816) does not drop the chat's entries from Order::read_segments.
  • Scenario:
    1. A restart replays chat X on the fast path, so its segment range waits for the write-back.
    2. Before the write-back, X is deleted (a purge) and made again under the same id. The trigger bridge's fixed ids allow this once the folder is gone (refuse_stored_fixed_id, hub/lifecycle.rs:1812).
    3. The new chat's first segment is a new file at the same path. ext4 and XFS give a freed inode number out again, and Windows compares nothing.
    4. When the new log is at least as long as the old range's end, the checkpoint pwrites the deleted chat's bytes over the new chat's lines and syncs. Then it retires the new chat's records.
    5. The new chat's acknowledged lines are gone, and its log shows the deleted chat's lines in their place.
  • The same happens with no Store::delete on Windows: a transfer-in that replaces the chat's folder removes the old folder itself (hub/transfer_in.rs:669), and any file at the path passes. So dropping the entries at Store::delete alone does not close D3.
  • The window is the open's first checkpoint. That is seconds with many chats (the PR's bench: 2,160 ms median to durable with 300 chats), or minutes while checkpoints fail before the write-back (the entries stay listed, by the P1-1 fix).
  • The codebase already treats an inode number alone as no identity: FolderId is the inode and the birth time (meta.rs:64-70), and "another folder made under the same path does not [keep it]".
  • Evidence (every red is at 19ddd526b58, this head plus the tests):
    • rsync_review5_a_deleted_chats_write_back_never_lands_in_a_new_chats_log is a stand-in for a reused inode, deterministic on any file system. The deleted segment is kept by a second link, then linked back emptied as the new chat's first segment, so it has the old file id and the new chat's content. Red on macOS (857c0030) and Linux (878ccf7a): "the checkpoint wrote the deleted chat's verified bytes into the new chat's log (another file with the same file id)".
    • rsync_review5_a_reused_inode_never_takes_a_deleted_chats_bytes is Linux only and uses no second link. Fillers first take every free inode below the freed one, as ext4 hands out the lowest free inode of the folder's group. On Blacksmith's ext4 the new segment got the freed inode 13893637. Red on Linux (878ccf7a): "the checkpoint wrote the deleted chat's verified bytes into the new chat's log (a reused inode 13893637, file system ext4)".
    • In both, the new chat's log after the checkpoint starts with its line 1 cut short, then holds the deleted chat's lines 2 to 5 ("queued") in place of its own, then a fragment of its own line 5, then its lines 6 to 8.
  • Fix direction:
    • Do the write at the open, not in the checkpoint. The open has just read and verified the file, it is single-threaded, and no other thread touches the folder yet. Write the verified bytes back there: one pwrite per segment, no sync. The checkpoint then only syncs the listed segments by path, as sync_read_segments did before 2a065d25cd3. Verified no longer needs its bytes or the file id.
    • A sync of a file that replaced the segment is harmless; a write is not. The pages stay dirty until a sync writes them, so the D2 argument still holds, and the open still syncs nothing per chat.
    • Also drop a chat's entries in Store::delete, under the order lock it already holds. This is hygiene, not the fix (see the transfer-in case above).
    • A failed pwrite at the open: take the existing slow path for that chat (journal the lines only the log holds, cut, write the merged lines). Its errors already go to the chat's repair file, and the open goes on. Do not fail the open, and do not freeze the store for a write error.
    • Keep refuse_lost_home and the owner and mode checks of private_state_file on that open for writing. open_verified has neither (P3 2).
    • Tests: the deterministic test above passes with the delete hygiene alone. Add one that replaces the folder outside the store (rename it aside and install a new folder, as a transfer-in does), so that only the open-time write makes it pass. The ext4 test depends on the allocator, and its "inconclusive" branch panics when the inode is not given out again. Do not land it in that form.
    • The open now writes each replayed tail once. Measure the open again (the PR's bench) rather than only argue it.

P3

  1. A folder that code outside the store moves aside during the write-back window and then puts back. An example is a transfer-in that replaces a durable chat, fails, and renames its backup back (hub/transfer_in.rs:598, :638-639). The write-back passes the segment while it is aside (NotFound), drops the entry and retires the records. The folder then comes back with pages that no sync of this process covered. sync_read_segments had the same gap. The same class is in R-DURABILITY: four older durability gaps from the reviews of #35895 and #35562 #35915 item 4. With the write at the open, the pages are at least dirty again, so the kernel writes them back.
  2. open_verified opens a segment for writing with neither refuse_lost_home nor the owner, mode and ACL checks of private_state_file, which sync_read_segments used. Every other write under the home checks them.
  3. Unchanged from my last review and accepted in the PR body: P3 1 is now R-DURABILITY: four older durability gaps from the reviews of #35895 and #35562 #35915 item 2, and P3 2 happens at shutdown only.

Checked and found correct

  • Both merges lose nothing. For each store and journal file, I compared each side's changed lines from the merge base with what the merge commit holds against the other parent. The only differences are in the two conflicted files, journal_store.rs and checkpoint.rs: comment wording and the inner binding. No code line of either side is lost. 7f3e036d277 is clean (no differences).
  • The merged commit_compaction (journal_store.rs:766-779) runs the covered check, then the checkpoint when the chat is not covered, then acpmux: a failed sync of a retention rewrite or a kept-aside image freezes the store (R-DURABILITY, #35905) #35963's counted_sync around the commit.
    • A covered chat has no write-back entries. Covered means its newest LSN is at or below checkpointed, which only a checkpoint that wrote back every entry from the open can reach.
    • So a retention rename never comes before its chat's write-back. A rename between open_verified and the write sends the same bytes to the old inode, which is harmless (also when the archive holds a second link).
    • No deadlock: the commit holds its ticket while it takes the writers' lock. A checkpoint takes the writers' lock (close_sync_failed) and lets it go before it waits for tickets, and it holds neither that lock nor order while it waits.
  • The write-back and a log close or an eviction write different ranges of the same file, so neither changes the other's bytes. A close's failed sync is read under the writers' lock after the checkpoint's own syncs (acpmux: a checkpoint never writes an LSN past a sync that failed on another thread (R-DURABILITY; culprit 71bc09a1e63, S3a) #35895).
  • The seal doc (P3 4) and the PR body's "What changes" and "Testing" (P3 3) now describe the code.
  • The head greens, each log read at 7f3e036d277: macOS journal_ 380/380 (c03d8a73), macOS cmux_journal 42/42 (ed5a03c6), Linux all store:: with fmt and clippy, 380/380 (86498e8b, hq run 41969667583).

Not run

  • No fix was written or run.
  • The deterministic repro uses a second link as a stand-in for inode reuse. The real-inode repro got a reused inode on Blacksmith's ext4 this time. On another file system or a busier inode group it can miss, and it then panics as "inconclusive".
  • Not run on Windows, where no identity is checked. No real failing disk, no real kill under load.

…en verified, tests (red; delta review 3 of #35866, D3)

D3: the checkpoint's write-back finds its segment by path, inode number and length
(open_verified). An inode number is not a file's identity. A chat deleted while its write-back is
pending and made again under the same id gets a new first segment, and a file system that hands
the freed inode out again gives it the old number (ext4 did, in the reviewer's Linux repro); on
Windows nothing is compared. The checkpoint then writes the deleted chat's bytes over the new
chat's acknowledged lines, syncs them and retires their records.

- a_chat_deleted_and_made_again_never_takes_the_old_chats_verified_bytes: the reviewer's test
  (rsync_review5_a_deleted_chats_write_back_never_lands_in_a_new_chats_log, red on macOS fleet
  857c0030 and Linux fleet 878ccf7a), renamed. A second link stands in for the reused inode.
- a_folder_replaced_outside_the_store_never_takes_the_old_chats_verified_bytes: the same with no
  delete, as a transfer-in that replaces a durable chat does: the folder is renamed aside and a
  new one is put in its place, whose first segment has the old file id. A cleanup at
  Store::delete would not see it.

The reviewer's ext4 test is not taken: where the file system does not give the inode out again,
it fails as "inconclusive". The second link gives the same file id on every file system.

Both fail at this commit; the fix follows.
…nly syncs the open's handles (delta review 3 of #35866, D3)

D3: the checkpoint's write-back found its segment by path, inode number and length, and an
inode number is not a file's identity. A chat deleted while its write-back was pending and made
again under the same id, or a folder that code outside the store replaced (a transfer-in that
replaces a durable chat), could put a file with the old number at the path; ext4 hands a freed
inode out again, and Windows compared nothing. The checkpoint then wrote the old chat's bytes
over the new chat's acknowledged lines, synced them and retired their records.

The fix takes the reviewer's direction:

- The open's repair writes each verified range back over itself right after it read and checked
  it (Inner::write_back: one write per segment, no sync). It runs before any other thread uses
  the logs, so the file it writes is the file it read.
- It keeps the handle it wrote through (Order::read_segments, now path to Arc<File>), and the
  checkpoint only syncs those handles (sync_read_segments). A sync reaches the very file the
  open wrote, wherever a delete, a folder replace or a retention rewrite moved it, and the
  checkpoint writes nothing, so a file put at the path since is never changed. A folder moved
  aside and put back (the reviewer's P3 1) is synced too.
- The open still syncs nothing per chat. The write marks the range dirty again, so the sync puts
  the verified bytes on the disk even after a failed sync that left no note (D2 holds).
- A write error at the open neither fails the open nor freezes the store: the chat takes the
  slow path (journal the lines only the log holds, cut at the repair start, write the merged
  lines), whose cut also removes a range written back in part.
- The write opens the segment as every segment read does, plus the lost-home check: never made,
  never through a final link, only an owner-only file of this user (macOS: no extended ACL;
  Windows: owner_only::open_rw_existing). (The reviewer's P3 2.)
- Store::delete drops its chat's handles: its files need no sync, and the handles would keep
  the removed files' space in use until a checkpoint.

The cost: one descriptor per replayed segment, from the open until the first checkpoint that
succeeds (the daemon raises its open-file limit before the store opens), and the open's write of
each replayed tail (to be measured with the open-time bench).

Green: a_chat_deleted_and_made_again_never_takes_the_old_chats_verified_bytes and
a_folder_replaced_outside_the_store_never_takes_the_old_chats_verified_bytes (red at
b53a750: macOS fleet 6b7b8fa2, Linux fleet 55b36bf4); new
a_failed_write_back_at_the_open_writes_the_lines_again.
…e (clippy -D warnings, Linux fleet bd0548b2)

The D3 tests imported std::io::Write, which store.rs already brings in through use super::*;
clippy's unused_imports failed the Linux all store:: step at 4846ec2 (every test passed).
… and holds no handle (delta review 3 of #35866, pre-review points 1 and 2)

4846ec2 kept the handle the open wrote each verified range through until the first
checkpoint that succeeds. The reviewer named two costs before its review:

1. Descriptors: one per replayed segment, and a failed checkpoint keeps them (the P1-1 rule).
   LocalStore caps its open logs at 64 because a daemon ran out at 256. The daemon raises its
   soft limit to min(hard, 65536) at start, but when setrlimit fails the limit stays (256 under
   launchd): 300 replayed chats then use up the descriptors during the open itself, whose
   repair file writes then fail and fail the open.
2. Windows: a folder whose files are open may refuse a rename or a removal, so a transfer-in's
   rename of the chat folder aside, or Store::delete's removal, could fail while a handle is held.

The checkpoint now opens each listed segment by path only to sync it, and writes nothing. That
is enough for D3: a file that a delete, a folder replace or a retention rewrite put at the path
since is synced, never written, and its sync is harmless. D2 holds: the open's write marked the
range dirty, and the sync of the same file puts the bytes on the disk. A retention rewrite of a
replayed chat still checkpoints first (Order::log_lsn), so the sync comes before any rename over
the segment. Order::read_segments is a set of paths again; Store::delete still drops its chat's.
The sync opens the segment as the write does (lost-home, link, owner, mode and ACL checks); on
Windows FlushFileBuffers needs a handle that may write.

What this gives back against 4846ec2:
- A folder moved aside during the window between the open and its checkpoint (the reviewer's P3
  1: a transfer-in that fails and renames its backup back) is passed when not found, as before
  D2, and its records are retired: the class of #35915 item 4.
- A sync through a new descriptor does not report a writeback error that another process's
  fsync of the same file already saw (Linux errseq). Inside this process every other sync of a
  log freezes the store when it fails, and the checkpoint reads frozen after the syncs under
  way.

The D3 tests and a_failed_write_back_at_the_open_writes_the_lines_again are unchanged.
…or the checkpoint's sync (open-time A/B of delta review 3 of #35866)

3452068 closed each segment the open wrote its verified bytes back into right after the
write, and the checkpoint synced it by path. The open-time A/B (the PR's bench, 10 runs per
size) showed that close is costly on macOS, on three Macs and in both orders. Median with 300
replayed chats, the open: 39.1 ms with the handle held (4846ec2, aws-m4pro-8), 115.8 ms with
the close (3452068, aws-m4pro-8); 42.0 against 121.3 ms on aws-m4pro-9, 52.8 against
130.8 ms on aws-m4pro-6 (run at the same time). The first acknowledged change after the open:
1.9 against 22.6 ms. With 10 chats nothing changed.

Now, on Unix, the open keeps the handle it wrote through (repair::WrittenBack::Held) for up to
repair::held_cap segments: a quarter of the open-file limit, which the daemon raised before the
store opens, at most 4096 (64 when the limit cannot be read). The checkpoint syncs a held
segment through its handle and then closes it on its own thread; a segment past the cap, and
every segment on Windows (where an open file can block its folder's rename or removal), is
closed at the open and synced by path, as at 3452068. The checkpoint still writes nothing,
so D3 stays closed. A held handle also covers a folder moved aside and put back before the
checkpoint (the reviewer's P3 1, #35915 item 6), within the cap on Unix.

Store::delete takes its chat's entries out under the appends' lock and closes the handles after
it lets the lock go.

New test: the_written_back_segments_past_the_cap_are_synced_by_path (a cap of 1: one segment
held, two closed, then every acknowledged line after the checkpoint and an OS crash).

Measured at 18612dc (this change without the delete's close order): open with 300 chats
40.8 ms median (aws-m4pro-8, fleet b26f2929); macOS journal_ 384/384 (fleet 9e5aa05f); Linux all
store:: with fmt and clippy, 186 acpmux tests pass (fleet 4cfd173c).
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Review (r-durability-rsync-reviewer): SHIP at 29967f9

Delta review 4: 7f3e036d277..29967f9f5f7 in cmux-tui/crates/acpmux/src/store/, owner_only.rs and config.rs. I read the five commits: b53a750bcd1 (reds), 4846ec2b09e (the fix), ff6922678dd (an import), 3452068e332 (sync by path, no handle) and 29967f9f5f7 (handles held up to a cap). Line numbers are at 29967f9f5f7.

D3 from my last review: fixed

  • The open writes the verified bytes back (repair.rs, Inner::write_back), so the write lands in the file the open just read and checked. The checkpoint only syncs (checkpoint.rs, sync_read_segments), never writes, so no file that a delete, a folder replace or a retention rewrite put at the path can take another chat's bytes. A sync of the wrong file is harmless.
  • Store::delete takes its chat's entries out under the appends' lock, after the folder's removal. It closes the handles after it lets the lock go. When a checkpoint in flight has cloned the list, that checkpoint syncs the unlinked inode, which is harmless, and closes it at its end.
  • A failed write at the open takes the slow path: journal the lines only the log holds, cut, write the merged lines. The cut also removes a range written back in part. The open neither fails nor freezes.
  • The write and the sync open the segment with the lost-home, link, owner, mode and ACL checks (repair::open_segment_to_write; Windows owner_only::open_rw_existing, OPEN_EXISTING). This answers my P3 2.
  • The reds are as stated: 6b7b8fa2 and 55b36bf4. I did not read those two logs again.

The held handles (29967f9f5f7), the five points you asked about

  1. A held handle and a retention rewrite. Order::log_lsn puts every replayed chat above checkpointed until a checkpoint succeeds. That checkpoint syncs every listed segment first. So a retention rewrite of a replayed chat checkpoints before its rename, and the handle syncs the inode the open wrote. After the rename, the chat has no entries left. A checkpoint that fails before sync_read_segments keeps the entries, so covered stays false and the rewrite's own checkpoint fails too. Correct.
  2. Descriptors. The cap is a quarter of the soft limit at the open, at most 4096, so held handles use at most a quarter of the descriptors. The daemon raises the limit at daemon.rs:188, before the store opens at :266. On macOS it falls back to OPEN_MAX when the first raise fails, so the limit stays 256 only when both raises fail. In that case the store can hold 64 log writers (OPEN_LOGS) plus 64 written-back segments until the first checkpoint succeeds. That is half the limit, against a quarter before this PR. The window ends with the first successful checkpoint, or with the stop after 10 failed checkpoints. I accept this, but it is the reason the cap must stay a fraction of the limit.
  3. The delete order is correct (see above). A delete whose folder removal fails keeps the entries, and the next checkpoint syncs them, which is harmless.
  4. Windows. held_cap is 0, so every segment is closed at the open and synced by path through open_rw_existing. That open asks for read and write access and shares read, write and delete. LocalStore's append handle also shares write, so the two opens do not conflict. A held handle could block a folder's rename on Windows, and there is none.
  5. R-DURABILITY: four older durability gaps from the reviews of #35895 and #35562 #35915 item 6 (a folder moved aside and put back). Within the cap on Unix it is covered: a rename keeps the inode the handle holds, and the sync writes its dirty pages. Past the cap and on Windows it stays open, tracked in R-DURABILITY: four older durability gaps from the reviews of #35895 and #35562 #35915.

Also checked:

  • The checkpoint clones the list under the order lock. The retain after a successful sync drops only the map's reference, because the clone still holds the other. So each close runs when the clone drops, on the checkpoint's thread and off the lock, and after the sync, when the pages are clean.
  • A checkpoint that fails before the sync keeps every handle in the map for the next one.
  • Rust opens files with O_CLOEXEC, so agent processes do not inherit the held handles.

Your question: a better way to avoid the close at the open, without held handles

I see none. The A/B is consistent with this cause: on macOS, the last close of a written file starts its write-back. C's durable time is shorter than B's, so C's writes start earlier. Any close before the first journal sync puts that I/O in front of the sync, and the first acknowledged change went from about 2 ms to 22-30 ms.

  • The pages must be dirty (D2), and the write must happen at the open (D3).
  • A close on another thread moves the CPU off the open, but not the I/O in front of the first sync.
  • A close after a sync is cheap, because the pages are clean then. Holding the handle until the checkpoint's sync does exactly that.
  • One optional saving: open each segment once with read and write access, for both the open's read and its write-back. That saves one open per segment; it is not a different design.

P3

  1. The PR body's "What changes" still says "The next checkpoint syncs each listed segment, by path". At this head that is true only past the cap and on Windows. Within the cap on Unix, the checkpoint syncs through the handle the open kept.
  2. The open-time numbers: I read both aws-m4pro-6 runs (A c74909f1 at 59c0c9552de, E 1021ba81 at 2382fe6b9c7; the bench diff is the same in both, and both ran at the same time). At 300 chats the medians are within the spread: open 40.6 against 41.6 ms, ready 43.2 against 44.0 ms, first acknowledged change 2.5 against 2.4 ms. E's tail is wider: open p90 57.8 and max 61.7 ms, against 43.6 and 44.0 ms. A has one spike of the same size at 100 chats (55.0 ms). With 10 runs, and two benches on one host at once, I read this as noise. An interleaved rerun would show it if it is not.

Checked and found correct

  • The head greens, each log read at 29967f9f5f72b3fc668a1d20c6d5211edbc92f81:
    • macOS journal_: 384/384 (dbb06014).
    • macOS cmux_journal: 42/42 (31ef1527).
    • Linux all store::: fmt pass, clippy -D warnings pass, 384/384 (fc960a45, hq run 42003274872).
    • Each green ran the four journal_write_back_tests.
  • Every test this PR took from my repro branches passes at the head on both systems:
    • P1-1, P2-1 and P2-2;
    • the three D1 tests and the fail-closed marker test;
    • both D2 windows;
    • both D3 tests.
  • The Windows jobs run cargo check and filtered tests without -D warnings, so the unix-only test re-export (HELD_CAP) does not break them.
  • DESIGN.md 3.3c ("The open's fast path and its write-back") describes the held handles, the cap and the Windows rule.
  • The earlier delta-review findings and P3s are fixed or tracked: R-DURABILITY: four older durability gaps from the reviews of #35895 and #35562 #35915 items 2, 4 and 6, and the shutdown-only wait.

Not run

  • I did not run my ext4 real-inode repro (rsync_review5_a_reused_inode_never_takes_a_deleted_chats_bytes) at this head. The checkpoint no longer writes, and the delete drops the chat's entries, so the write cannot reach another file. The stand-in tests pass on both systems.
  • Nothing on Windows. No real failing disk, no real kill under load, no rollback to an older build.
  • I did not run the open-time bench myself. I read the A/B/C and A/E logs named above.

@lawrencecchen lawrencecchen added the ready-to-land Reviewed at this head; next-autoland merges it into feat-cmux-next when its only reds are base reds label Nov 5, 2026
@lawrencecchen
lawrencecchen merged commit f89bc2e into feat-cmux-next Nov 5, 2026
246 of 251 checks passed
@lawrencecchen
lawrencecchen deleted the rewrite/r-durability-recovery-syncs branch November 5, 2026 10:49
teamleaderleo added a commit that referenced this pull request Nov 5, 2026
- cef-embedder.sh (#36421): a comment line named both kill(0) and the name
  lookup, which the pkill lint counts as a name-lookup kill; reword it.
- journal_store.rs (#35866): SEAL_WAIT and LOCK_WAIT get a ratchet entry
  that says what each bounds.
- nightly.yml (#36287): drop the gh-app-install and ring-token anchors no
  alias uses any more (actionlint).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-c21fa64be7cee139675a

This branch was previously deployed

3 inactive deployments
artifacts — 29967f9f Deployed Nov 5, 2026 by lawrencecchen via publish tree-addressed cmux-tui #115393
sentry-dsyms — 29967f9f Deployed Nov 5, 2026 by lawrencecchen via upload cmux-tui dSYMs to Sentry #115393
ci-routing-agent — 29967f9f Deployed Nov 5, 2026 by lawrencecchen via cmux-tui tree preflight #115393
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-to-land Reviewed at this head; next-autoland merges it into feat-cmux-next when its only reds are base reds risk:high Risk tier (docs/ci/risk-tiers.md)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant