Files
libfreemkv/docs/writeback-linux.md
T
Matthew Jackson 6e14fc0c0c libfreemkv: 1.7.0 groundwork — sector-recycle soundness + CI hardening
Security
- Remove the unsafe Vec::set_len over recycled sector buffers — UB reachable
  from safe code (GHSA-j8ww-f5fg-9pmh, low). Uses Vec::resize; pooling unchanged.

CI / tooling
- clippy on stable with the MSRV single-sourced from Cargo.toml; cargo-deny
  dependency-audit gate with first-party git sources allowed (#15); audience-based
  comment-guard (#17).

Docs
- README (keydb ownership, BD-J parser count), drive-access transport/reset
  notes, mux timeline module-doc links.
2026-09-02 12:40:55 -07:00

71 lines
3.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# `io::writeback::linux` — rationale
## Pathology this fixes
The kernel's default `vm.dirty_ratio` (~20 % of RAM) lets dirty pages
accumulate to hundreds of MB during a big sequential write, then bursts a
flush at 99 % disk utilisation. While the burst runs, app writes block on
the writeback queue — observed empirically as instantaneous speed dropping
from ~15 MB/s to ~1 MB/s every ~30 s during a Pass 1 sweep.
## Strategy
Every `chunk_bytes` of new sequential output, kick async writeback
(`SYNC_FILE_RANGE_WRITE`) on the just-completed chunk and finalise the
*previous* chunk via `WAIT_AFTER` + `posix_fadvise(DONTNEED)`. By the time
we finalise, that previous chunk has had a full chunk's worth of work to
flush — the wait is near-instant. Dirty cache stays bounded at ~2 ×
`chunk_bytes` and writes drain continuously instead of in bursts.
The chunk size is adaptive: we measure the elapsed time of the
`WAIT_AFTER` call over a rolling window of the last 16 chunks and resize
the chunk based on the p95. Slow storage (NFS, network shares, HDD) sees
larger chunks to amortise per-chunk overhead; fast storage (NVMe) sees
smaller chunks to keep cache pressure tight. Bounds: [4 MiB, 256 MiB].
## NFS escape hatch
`sync_file_range(WAIT_AFTER)` on an NFS-mounted file can block
indefinitely waiting for the server's commit ack. If the server never
acks (network partition, server-side hang, slow commit), the syscall
never returns and the consumer thread is stuck inside the kernel —
`/api/stop` can't reach it because halt is cooperative.
When `fstatfs` reports the file lives on an NFS mount
(`f_type == NFS_SUPER_MAGIC`), the pipeline skips the WAIT_AFTER +
`posix_fadvise(DONTNEED)` dance entirely. NFS clients have their own
buffering and commit semantics that handle dirty-page bounds without us
forcing the issue. The async `SYNC_FILE_RANGE_WRITE` kickoff still runs
(non-blocking by spec) so writeback still gets a nudge.
## Defence in depth: WAIT_AFTER timeout
Even on local storage, a degraded disk or odd filesystem driver could in
principle wedge inside WAIT_AFTER. Each WAIT_AFTER call runs on a worker
thread with a 30s recv_timeout on its result channel. On timeout we log a
loud error, set a `degraded` flag, and from then on skip WAIT_AFTER +
DONTNEED for the rest of the pipeline's life (same shape as the NFS
path). The worker thread is intentionally leaked — it unwinds whenever
the syscall eventually returns or the process exits. The mux continues;
the original dirty-burst pathology re-emerges but the rip can still
finish instead of freezing.
## `wait_after_with_timeout` fd lifetime / fd-reuse safety
`worker_file` is an *owned* `File` (produced by `File::try_clone` at
pipeline construction). It is moved into the worker closure so the file
description stays alive for exactly as long as the worker thread lives —
even if the original `WritebackFile` is closed and the OS reuses its fd
number before the worker's syscall returns.
`fallback_fd` is used only when `worker_file` is `None` (i.e. the
`try_clone` at construction failed). In that case the worker captures the
raw fd integer, which carries the original fd-reuse risk but is no worse
than the pre-fix behaviour.
This delegates to `crate::io::bounded::bounded_syscall`, the generic
worker-thread + `recv_timeout` primitive, and just adapts it to the
WAIT_AFTER call shape: it returns `elapsed_ms` instead of the syscall's
`()`, and treats `WorkerLost` as a benign no-op to match the original
semantics.