module

Hwaro::Utils::FileSafe

Class methods

atomic_copy(src : String | Path, dest : String | Path) : Nil

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.

Source
atomic_write(path : String | Path, content : String) : Nil

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.

Source
mkdir_p(path : String | Path, mode : Int32 = 511) : Nil

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.

Source
temp_path(target : String | Path) : String

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.

Source
write_new(path : String | Path, content : String) : Nil

atomic_write for a target that does not exist yet and that no reader can be watching: a plain create-and-write. Same classified error when a directory stands where the file must go.

Source