Chasing a five-year-old Windows-only race condition in Zola

Chasing a five-year-old Windows-only race condition in Zola

· 1,030 words · 5 minutes reading time

A few weeks ago I started to run into constant build failures in Zola, the Rust static site generator that I planned to use for this blog:

Error: Was not able to copy file ...\content\entertainment\filename\2.png to ...\public\entertainment\filename\2.png
Reason: Another program is using this file and the process cannot access it (os error 32)

Windows error 32 is ERROR_SHARING_VIOLATION — it means another process (or another thread in the same process) had the file open when something tried to overwrite it. The build didn't fail every time, which is the classic signature of a race condition: sometimes the timing lines up badly, sometimes it doesn't.

A quick search turned up issue #1599, open since 2021, reported by another Windows user hitting the exact same error on a page with a dozen resize_image() calls. It had sat there for years labeled "Need Windows help" — understandable, since intermittent, platform-specific races are miserable to debug from a distance, and it seems that Zola's main maintainer does not run Windows day to day.

Reproducing it

I grabbed a copy of zola, set up a rust deelopment environment, and with a bit of help from our AI friends - set up a repository to troubleshoot the issue - my repository. I started out instrumenting Zola's file-copy code directly, since the error message alone didn't say much beyond "something else has this file open." I had my AI assistant add:

  • a small dbgfs! macro that logs a timestamp and thread ID alongside each filesystem operation, so I could see which threads were touching which paths and in what order
  • a retry-with-backoff wrapper around the copy/remove/hard-link calls, specifically catching raw OS error 32 and giving Windows a few tens of milliseconds to release the lock before giving up.

The retry wrapper idea came from reviewing github and spotting that Tauri does something similar - they found that antivirus services were keeping the file locked. This was a temporary issue to try and seew whether we could get past the lock — I flagged it in the commit as temporary scaffolding to confirm the theory, not something to actually ship. But the debug logging was the useful part: it showed two threads copying the same asset to the same destination path at effectively the same moment.

At this point, it pointed at Zola's and it's build system itself being the issue - rather then a 3rd party (antivirus / indexing etc). Great news!. The AI assistant did a good job at this point of working out what was going on - Pages are rendered and their assets copied in parallel via rayon, and each page is normally queued exactly once. But Zola has a transparent section feature, where a page can effectively belong to more than one section in the content tree. So, between myself, the AI, the other users on the bug and the zola developer - they had a working theory that a page under a transparent section was showing up in the page list of every transparent ancestor section, not just its immediate parent — so it was getting pushed onto the job queue once per ancestor. On Linux and macOS, two threads writing the same bytes to the same path at nearly the same time is harmless. On Windows, it's a sharing violation.

I left the diagnosis and the repro for the maintainers, since confirming and fixing it properly needed someone who understand the project and the RUST programming language. My work was done - I could build my set, and the developers hopefully had everything they needed to implement the proper fix.

The real fix

Keats picked it up and landed the fix in next: c1d0438, "Render pages only once when transparent=true." It confirmed the theory — his commit message points straight at #1599.

The fix itself is small. add_section_jobs() now takes a seen_pages: &mut HashSet<&'a Path> parameter, and every page gets checked against it before being pushed as a job:

if page.meta.render && seen_pages.insert(&page.file.path) {
    self.jobs.push(Job::Page(page));
}

HashSet::insert returns false if the value was already present, so this one line does double duty: it's both the "have we seen this page already" check and the "mark it as seen" step. Each caller — single_section, full_build, and the orphan-page pass — now threads a HashSet through the section-walking code, so a page reachable through multiple transparent ancestors only ever gets queued, rendered, and copied once.

It's a cleaner shape than my version, too — I'd stored the dedup set as a field on the Queue struct itself; threading it through as a parameter keeps it scoped to a single build pass instead of living on the struct for its whole lifetime.

An hour of work, a fix a day later, a five-year-old bug closed

It's a good reminder that "stale" and "hard" aren't the same thing. This one had been open for five years, but once it had a solid investigation, Keats landed the actual fix in about a day. I doesn't look like the bug was hard to fix — it was hard to see, because a few things had to line up before it would even show its face:

  • Windows, where concurrent access to the same file path is enforced at the OS level, unlike POSIX systems where it's usually silently fine
  • transparent sections - i'm guessing possibly a niche feature that not every project in Zola uses
  • Multiple images or assets on one page, since the race window is small and needs several parallel copy jobs racing for the same file to actually collide in practice

Any one of those alone wouldn't have reproduced it — which is exactly why "Need Windows help" sat unclaimed for so long despite being a completely deterministic bug once you knew where to look. The fix that mattered was in the queue's job-dedup logic, not in retrying failed filesystem calls — the retries in my diagnostic commit would have papered over the symptom without addressing why two threads were fighting over the same file in the first place.

It's a nice example of how "flaky" bugs are often not flaky at all — they're deterministic bugs whose triggering conditions are just rare enough that a good repro, not more retries, is what actually finds them.