Hwaro::Utils::FileSafe
Class methods
Copy src onto dest atomically: copy into a same-directory temp
file, then rename it into place. Same invariant as atomic_write, for
the paths that publish bytes from a source file instead of a rendered
string.
FileUtils.cp is File.copy, which opens the destination with
O_TRUNC and then streams — so while a copy runs the destination is
observably 0 bytes and then every intermediate size. hwaro serve
answers HTTP requests for those very paths from fibers of the same
process, so a request landing in that window gets a truncated body
whose header agrees with the short length, which means nothing
retries. rename is atomic within a filesystem, so a reader sees
either the old bytes or the new ones.
Permissions carry over from the source exactly as with FileUtils.cp
(File.copy opens the destination with the source's mode); callers
that also want the source mtime keep stamping it after the copy.
The temp name is unique per process AND fiber so parallel copies of sibling files can't collide on it.
Write content to path atomically: write to a same-directory
temp file, then rename over the target. hwaro serve rewrites
output files while HTTP fibers stream them to the browser — a plain
File.write truncates first, so a request landing mid-rebuild could
read an empty or half-written page. Rename is atomic on the same
filesystem, so readers see either the old bytes or the new bytes.
The temp name is unique per process AND fiber so parallel render workers writing sibling outputs can't collide on it.
Equivalent to FileUtils.mkdir_p but tolerates concurrent creation
of any path component. Safe to call from MT workers without an
external mutex.
We walk parents ourselves so EEXIST is absorbed per component.
Crystal's Dir.mkdir_p is exists? → mkdir for each parent and the
leaf, so two workers calling mkdir_p("/out/a/b/x") and
mkdir_p("/out/a/b/y") can race on every shared parent
(/out, /out/a, /out/a/b). A single retry of the whole call
isn't enough: the retry's parent walk can re-race on a different
shared parent, raise again, and a post-hoc Dir.exists?(leaf) check
is false because we never reached the leaf — so the EEXIST bubbles
out and a render fails ("Unable to create directory: '…': File
exists"). Tolerating EEXIST per component avoids the cascade.
The parent walk runs deepest-first and stops at the first component
that already exists, then creates downward from there. Walking from
the root instead (what each_parent yields, and what this used to do)
cost one Dir.exists? stat per ancestor on every call — and every
page of a large site creates a fresh leaf directory under an
already-existing tree, so a six-deep output path paid six stats to
create one directory. Only the components actually missing are
touched now, which for that shape is a single stat plus one mkdir.
EEXIST is still absorbed per component, so the concurrency argument above is unchanged: another fiber may materialize any of these directories between our stat and our mkdir.
Same-directory temp path for an atomic write/copy of target. The
name is fixed-length and unrelated to the target's own: suffixing the
target basename pushed any legal name within ~22 bytes of NAME_MAX
over it (ENAMETOOLONG aborted the build). Unique per process AND
fiber, so parallel workers writing siblings can't collide; one fiber
writes one file at a time, so reusing the name sequentially is safe.