Repository navigation
Verify on resume instead of flushing every piece - #13
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical durability-contract findings and moderate resume, accounting, benchmark, test, and validation findings block approval.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
This PR defers .dhaar durability to resume-time verification and adds finalization tracking, geometry consolidation, instrumentation, repair tests, and tooling updates.
Changes:
- Replaces per-piece flushes with a clean trailer flag and resume repair.
- Adds
Finalizingstatus, telemetry, and consolidated store layout. - Updates tests, benchmarks, documentation, dependencies, and developer tooling.
File summaries
| File | Summary |
|---|---|
src/status.rs |
Adds extraction progress and Finalizing state. |
src/piece_manager/piece_writer.rs |
Implements the new store layout and repair logic. Critical (3 votes): stale durability-barrier documentation at lines 329/373. Critical (2 votes): clean-flag writes require synchronization at lines 270/365. Moderate (1 vote): validate deserialized zero piece_length. |
src/piece_manager/mod.rs |
Integrates resume verification and finalization. Moderate (3 votes): recovered pieces do not restore counters or complete finalization. |
src/peer_explorer/tracker/tracker_client_messages.rs |
Updates compact-peer parsing. |
src/peer_connection/request_manager.rs |
Adds request timing and wasted-byte accounting. Three moderate findings (1 vote each): wasted blocks are not counted as downloaded at lines 619, 630, and 650. |
src/peer_connection/mod.rs |
Simplifies address initialization. |
src/main.rs |
Adds optional console tracing. |
src/lib.rs |
Propagates geometry and finalizing status. Moderate (1 vote): resumed complete stores can skip Finalizing and Seeding. |
scripts/test-repair.sh |
Adds crash-repair integration coverage. |
scripts/test-inbound.sh |
Updates asynchronous completion checks. Moderate (2 votes): the flaky status-log assertion remains. |
scripts/console.sh |
Adds a tokio-console runner. |
scripts/bench-seeders.sh |
Adds multi-seeder benchmarking. Moderate (3 votes): timing excludes extraction completion. |
README.md |
Updates performance and request-window documentation. |
crates/dhaar-gui/src/main.rs |
Displays finalization state. |
Cargo.toml |
Adds console and profiling configuration. |
Cargo.lock |
Locks new dependencies. |
benches/piece_writer.rs |
Updates writer benchmarks. Nit (1 vote): benchmark comments still describe removed durability barriers. |
assets/benchmarks.svg |
Updates benchmark visualization. |
.husky/pre-commit |
Checks the complete workspace. |
Review details
Suppressed comments (9)
benches/piece_writer.rs:84
- These updated benchmarks no longer execute a durability barrier in
set_bitfield, but the surrounding benchmark documentation still calls it a durability barrier and describeswrite_whole_pieceas including one. The reported microsecond timings can therefore be interpreted as flush costs even though they measure unsynced writes; update the comments to match the new protocol.
runtime().block_on(w.write(next_piece(), 0, data)).unwrap()
src/lib.rs:304
- This new state check uses
DownloadStats, but initialization restores completed pieces only intoPieceProgress;DownloadStats::completed_piecesis incremented only bypiece_verifiedand remains zero on resume. A complete resumed store therefore skipsFinalizingand is reported as Downloading until a piece is verified again. Base this branch on the restored progress counts (or seed the stats from the bitfield).
} else if stats.is_complete() {
DownloadState::Finalizing
src/peer_connection/request_manager.rs:619
- This received block is counted as wasted but not as downloaded.
DownloadStats::add_downloadedis documented as counting every payload byte received, including worthless copies, so this early return underreports trackerdownloadedand measured network traffic. Incrementdownloadedbeforewastedhere.
self.stats.add_wasted(block.len() as u64);
src/peer_connection/request_manager.rs:630
- This received block is counted as wasted but not as downloaded.
DownloadStats::add_downloadedis documented as counting every payload byte received, including worthless copies, so this early return underreports trackerdownloadedand measured network traffic. Incrementdownloadedbeforewastedhere.
self.stats.add_wasted(block.len() as u64);
src/peer_connection/request_manager.rs:650
- This late endgame copy crossed the network and is counted as wasted, but not as downloaded. That violates
DownloadStats's documented byte accounting and makes trackerdownloadedand measured traffic too low whenever a cancelled copy arrives. Incrementdownloadedbeforewastedhere.
self.stats.add_wasted(block.len() as u64);
src/piece_manager/mod.rs:145
- When an existing store returns
Some(bitfield), this marks pieces complete but leavesDownloadStats.completed_pieces/verified_bytesat zero and never callsfinalizefor an already-complete store. As a resultsample_statusnever reachesFinalizing/Seeding, the tracker continues to announce the fullleftvalue, and a missing output file is not recreated after restart. Initialize the counters from recovered pieces and trigger (or otherwise complete) finalization before publishing startup progress.
if let Some(bitfield) = bitfield {
for (index, piece) in self.pieces.iter_mut().enumerate() {
piece.complete = bitfield.has_piece(index as u32);
}
src/piece_manager/piece_writer.rs:373
- The clean bit is written before the
matchbelow creates and copies the extracted files. A crash or copy error after this write leaves the store marked tidy even though the output is partial or missing; on the next start the bitfield is trusted and the current startup path never retries extraction. Set the clean bit only after the extraction succeeds, and handle a complete resumed store on startup.
file.write_all_at(&[flags.to_byte()], flags_at)
src/piece_manager/piece_writer.rs:368
- Because payload and bitfield writes are intentionally unsynced, this unsynced clean-flag write does not establish the ordering promised by the format. A power loss can persist the flag before dirty payload/bitfield pages, after which
initializetrusts a claim whose data was not durable. Flush the store once before publishing the clean bit; this preserves the per-piece performance win while making the flag a valid durability marker.
// Marking the store clean. Deliberately unsynced: if this byte is lost
// to a crash the store reads as still open, which costs one needless
// re-verification on the way back in and nothing else. The polarity is
// what makes that safe — a lost write cannot fake a tidy exit.
src/piece_manager/piece_writer.rs:172
piece_lengthis deserialized as a rawu64with no validation, while the public torrent-construction path returns aResult. A torrent advertising zero now triggers this assertion during construction and panics instead of returning an input error. Reject or validate the geometry before constructing the writer, or make this constructor fallible.
assert!(piece_length > 0, "piece length must be non-zero");
- Files reviewed: 17/19 changed files
- Comments generated: 4
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| // The last exit was tidy, so the bitfield is believable as it | ||
| // stands. Claim the store by clearing the clean bit; any other | ||
| // bit in there came from elsewhere and is left alone. | ||
| file.write_all_at(&[flags.with_clean(false).to_byte()], layout.flags_at())?; |
| // does, and `set_bitfield` is where that claim is made durable — one | ||
| // barrier per completed piece instead of one per block. A block lost |
| for _ in $(seq $((TIMEOUT * 20))); do | ||
| [ -e "$WORK/leecher/payload.bin" ] && break | ||
| sleep 0.05 | ||
| done | ||
| t1=$(date +%s.%N) | ||
| [ -e "$WORK/leecher/payload.bin" ] \ | ||
| || fail "$n seeders, run $run: no download after ${TIMEOUT}s" | ||
|
|
||
| # finalize streams the whole payload out of the store after the last piece | ||
| # verifies. Let that copy land before killing anything, or the hash below | ||
| # reads a file the leecher was still writing. | ||
| for _ in $(seq 400); do | ||
| [ "$(stat -c %s "$WORK/leecher/payload.bin")" = "$PAYLOAD_SIZE" ] && break | ||
| sleep 0.05 | ||
| done |
| [ -f "$WORK/leecher/payload.bin" ] || fail "leecher wrote no output file" | ||
|
|
||
| # Checked before the hash so a partial file says so. Comparing digests first | ||
| # would report "payload mismatch" for a file that is simply short, which sends | ||
| # you looking for corruption that is not there. |
What this changes
The store used to pay for durability on every completed piece: sync the payload,
write the bitfield claim, sync the claim. Two device flushes per piece, which
measured 2.82 ms on ext4 and capped the whole disk path at 25 MB/s.
That cost is now gone. Durability moved to a trailing flag byte whose clean bit
is set only by
finalizeon a tidy exit. A store found without it was left by acrash, so every piece its bitfield claims is re-hashed on the way back in and
any claim that outlived its data is dropped. A claim is corrected on resume
rather than prevented on write.
The sense of the bit is deliberate: a byte that was zeroed, truncated or never
written reads as not clean, so damage costs a re-verification rather than a
bitfield taken on trust.
Measured effect
Component costs, release build, store on ext4, medians over 100 samples:
set_bitfieldwrite_whole_pieceEnd to end,
scripts/bench-seeders.shon ext4, 128 MiB, median of three:The old table flattened at 20.5 MB/s from eight peers on, and the README
described that as the write rate of the disk. It was the flush rate. The ceiling
was three cells wide and read as hardware.
tmpfs and ext4 now agree within a few percent, so the two benchmark tables in
the README collapse into one. The store no longer has to live in a RAM disk for
the client's own shape to be visible.
Breaking: existing
.dhaarstores resetThe trailer grew by one byte, so a store written by an earlier build is now the
wrong size.
initializereads wrong-size as "not resumable" and lays it outfresh, zeroing the bitfield. The payload survives —
set_lenextends, itnever truncates — but the download restarts from zero.
This was hit for real on a 6.5 GB store mid-session. Recovery is to set every
bit in the bitfield and leave the flag byte clear: the repair pass then re-hashes
and prunes whatever does not verify. There is no automatic migration, and adding
one needs a format version the trailer does not carry yet.
Also in here
piece_lengthwas a per-call parameter oninitialize,readandwritethat every caller passed the same value to,which let the benchmark open a store at one stride and write it at another. It
moves to the constructor;
bitfield_lengthbecomes derivable rather thansupplied.
StoreLayoutowns the region offsets, which were open-coded ninetimes across four functions.
DownloadState::Finalizing— every piece verified but the payload stillbeing written out. Milliseconds on a small torrent, minutes on a large one,
and previously invisible.
request_windowandserve_latencytrace targets, offunless asked for, emitting samples rather than pre-aggregated averages so the
analysis can change without another run.
scripts/test-repair.sh— two arms over the same deliberately damagedstore, differing only in the flag byte, asserting the bitfield on disk rather
than the reported piece count.
scripts/console.shplus aprofilingprofile, for tokio-console.test-inbound.shwaits for the extracted payload rather than a sampled logline; the pre-commit hook checks the whole workspace, which is why the GUI
compiled against a stale enum for two commits.
Known gaps
test-inbound.shis intermittently flaky (~1 in 10). Not a download bug —the payload verifies every time. Completion lands between 1 Hz status samples
and the process exits before
Seeding 100.0%is ever published, so the testwaits for a line that never comes. Needs an edge, not a poll.
bitfield without rewriting the identity on disk, so that store rediscards its
progress on every start, silently and permanently.
at sixteen blocks in flight, and measured mean depth is ~2.6 of that. This is
now the binding constraint on per-peer throughput, and the trade it protects —
a dead connection strands at most one partial piece — is unchanged here.
🤖 Generated with Claude Code