11ty / 11ty/fetch

A truncated cache file is a permanent, unrecoverable build failure

Open
#102 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
JavaScript
Stars
166
Forks
21
PR merge metrics
No merged PRs in 30d

Description

Verified against v5.1.3 (current). Same flaw in v4.0.1.


What happens

If a cached json body file is ever left empty, every subsequent build throws:

Unexpected end of JSON input (via SyntaxError)

…surfaced through whichever plugin triggered the fetch. The build never recovers on its own: restarting doesn't help, and the cache duration is irrelevant. It fails identically on every run until the file is deleted by hand.

Why it can't recover

Validity is decided without looking at the contents:

// FileCache.js:148
hasContents(type) {
  if (this.#contents) return true;
  if (this.get()?.contents) return true;
  return existsCache.exists(this.getContentsPath(type));   // existsSync
}

A zero-byte file exists, so hasContents()isCacheValid()AssetCache.js:239 takes the cache-hit branch and never re-fetches. Then:

// FileCache.js:191
let data = fs.readFileSync(this.contentsPath, type !== "buffer" ? "utf8" : null);
if (type === "json" || type === "parsed-xml") {
  data = JSON.parse(data);        // unguarded — throws on ""
}

The entry is simultaneously "valid" and unreadable. That combination is what makes the failure permanent rather than transient.

How the file gets truncated

// FileCache.js:216
fs.writeFileSync(this.contentsPath, contents);

Not atomic: writeFileSync truncates to zero, then writes. If the process dies in that window, the body is left empty. Metadata is written separately (FileCache.js:221), so it survives pointing at the now-empty body.

Encountered in production; the corrupted entry showed exactly that signature — metadata cachedAt several days older than the body file's mtime.

Suggested fixes

1. Treat an empty body as a cache miss — makes it self-healing.

   hasContents(type) {
     if (this.#contents) return true;
     if (this.get()?.contents) return true;
-    return existsCache.exists(this.getContentsPath(type));
+    let p = this.getContentsPath(type);
+    if (!existsCache.exists(p)) return false;
+    // A zero-byte body is a truncated write, not a usable cache entry.
+    // Treat it as a miss so the next fetch repairs it.
+    return fs.statSync(p).size > 0;
   }

A statSync keeps the existing design intent — the comment at FileCache.js:186 deliberately avoids reading contents to check validity, and this doesn't read them.

2. Make the write atomic — stops the empty file existing at all.

-    fs.writeFileSync(this.contentsPath, contents);
+    let tmp = `${this.contentsPath}.tmp`;
+    fs.writeFileSync(tmp, contents);
+    fs.renameSync(tmp, this.contentsPath);   // rename(2) is atomic

(1) alone fixes the failure. (2) removes the cause. Independent; either is useful alone.

Reproduce

# after any successful cached json fetch
: > .cache/eleventy-fetch-<hash>.json    # truncate, leave metadata alone
npx eleventy                              # → Unexpected end of JSON input, every time

Notes

  • Only caches that persist between runs get stuck — containers, or CI that restores a cache. A fresh cache per run hides this entirely.
  • Related: #62 (option to force a single file for metadata and contents) touches the same metadata/contents split that this bug depends on.
  • getContents() could also wrap the parse in try/catch as belt-and-braces, but the hasContents check is enough and leaves the return contract unchanged.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in FileCache.js at hasContents() and the contents write path, then trace the cache-hit decision in AssetCache.js. Reproduce the issue with the provided truncation command and run the project’s existing tests if available. Done means a zero-byte cached JSON body no longer causes repeated build failures and the cache can recover without manual deletion.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript
Domain
backend
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
84/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.