<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Bug: rsync backup fails with ENOENT on unlink when a deleted folder has a sibling like "name-2" (syncer.js compares paths in the wrong order)]]></title><description><![CDATA[<p dir="auto">On one of my servers I added a second backup site of type filesystem (rsync format, local disk) next to the regular sshfs one. That local site fails now and then with an error like this, always inside a WP Rocket page cache folder:</p>
<pre><code>ENOENT: no such file or directory, unlink '/var/backups/cloudron/snapshot/app_&lt;id&gt;/data/public/wp-content/cache/wp-rocket/&lt;site&gt;/evenement/&lt;post-slug&gt;/index-https.html_gzip'
</code></pre>
<p dir="auto">It failed 2 out of about 11 runs. One of them was the backup before the platform update to 10.1.3 (the site is enabled for updates), so that update attempt was aborted and only went through an hour later. The sshfs site on the same server succeeded 150 out of 150 runs in the same period.</p>
<p dir="auto">I traced it to <code>src/syncer.js</code> and could reproduce it with Cloudron's own code. Everything below is from 10.1.3; the file is identical in 10.0.5.</p>
<p dir="auto"><strong>What goes wrong</strong></p>
<p dir="auto">The sync cache is written in traversal order: depth first, with the names in each folder sorted. For a folder <code>romeo-julia</code> with a sibling <code>romeo-julia-2</code> that order is:</p>
<pre><code>evenement/romeo-julia
evenement/romeo-julia/index-https.html
evenement/romeo-julia/index-https.html_gzip
evenement/romeo-julia-2
evenement/romeo-julia-2/index-https.html
</code></pre>
<p dir="auto"><code>traverse()</code> and <code>advanceCache()</code> then compare cache entries and the current entry with plain string comparison (<code>cache[curCacheIndex].path &lt; entryPath</code> on lines 99 and 124, <code>cachePath &gt; entryPath</code> on line 131). In plain string order <code>-</code> (0x2D) and <code>.</code> (0x2E) sort before <code>/</code> (0x2F), so <code>romeo-julia-2</code> &lt; <code>romeo-julia/index-https.html</code>. That does not match the cache order.</p>
<p dir="auto">When <code>romeo-julia</code> disappears (WP Rocket purges the cache of that page) and <code>romeo-julia-2</code> still exists, the next run produces this delete queue:</p>
<pre><code>removedir evenement/romeo-julia                     (missing)
remove    evenement/romeo-julia/index-https.html    (missing)
remove    evenement/romeo-julia/index-https.html_gzip   (missing)
removedir evenement/romeo-julia-2                   (missing)
</code></pre>
<p dir="auto">and this add queue:</p>
<pre><code>add       evenement/romeo-julia-2/index-https.html  (new)
</code></pre>
<p dir="auto">So there are two problems:</p>
<ol>
<li>The files inside the removed folder are queued again as separate <code>remove</code> operations, because <code>lastRemovedDir</code> is local to each <code>advanceCache()</code> call and the comparison stops halfway. <code>backupformat/rsync.js</code> processes the delete queue with <code>async.eachLimit(…, concurrency)</code>. The <code>removedir</code> (<code>rm -rf</code>) therefore runs in parallel with the <code>remove</code> of a file inside that folder. <code>storage/filesystem.js</code> <code>remove()</code> does a <code>statSync</code> followed by an <code>unlinkSync</code> and throws on the error. If <code>rm -rf</code> deletes the file between the two calls, the whole backup fails with ENOENT. On sshfs, <code>removeDir</code> runs <code>rm -rf</code> over a separate ssh connection, which is much slower, so the individual removes almost always finish first. That is presumably why I only see the failure on the local target.</li>
<li>The sibling <code>romeo-julia-2</code>, which did not change, is removed from the snapshot and uploaded again as "new". That is not an error, but it is unnecessary work on every backup where this happens, on every target.</li>
</ol>
<p dir="auto">With a sibling without a dash (<code>romeo-julia2</code>) the delete queue is a single <code>removedir</code>, as expected.</p>
<p dir="auto">This is a common pattern on WordPress: posts and events with the same title get the slugs <code>name</code>, <code>name-2</code>, <code>name-3</code>, and every caching plugin writes a folder per slug.</p>
<p dir="auto"><strong>Reproduction</strong></p>
<p dir="auto">The script uses the real <code>syncer.js</code>, <code>datalayout.js</code> and <code>storage/filesystem.js</code> from <code>/home/yellowtent/box</code> and works only in <code>/tmp/syncer-repro</code>. Run it as yellowtent with the box environment:</p>
<pre><code>sudo -u yellowtent env HOME=/home/yellowtent BOX_ENV=cloudron NODE_ENV=production /usr/local/node-24.19.0/bin/node /tmp/syncer-repro.mjs
</code></pre>
<pre><code class="language-js">import { createRequire } from 'node:module';
import fs from 'node:fs';
import path from 'node:path';

const BOX = '/home/yellowtent/box';
const async = createRequire(BOX + '/package.json')('async');
const { default: syncer } = await import(process.env.SYNCER || (BOX + '/src/syncer.js'));
const { default: DataLayout } = await import(BOX + '/src/datalayout.js');
const { default: storage } = await import(BOX + '/src/storage/filesystem.js');

const ROOT = '/tmp/syncer-repro', SRC = ROOT + '/src', DEST = ROOT + '/dest', CACHE = ROOT + '/test.sync.cache';
const reset = () =&gt; { fs.rmSync(ROOT, { recursive: true, force: true }); fs.mkdirSync(SRC, { recursive: true }); fs.mkdirSync(DEST, { recursive: true }); };
const write = (rel) =&gt; { for (const base of [ SRC, DEST ]) { const p = path.join(base, rel); fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, 'x'); } };
const sync = () =&gt; syncer.sync(new DataLayout(SRC, []), CACHE);
async function syncAndFinalize() {
    const r = await sync();
    for (const a of r.addQueue) r.integrityMap.set(a.path, { size: 1, sha256: 'x' });
    await syncer.finalize(r.integrityMap, CACHE);
}

// 1. the queues
reset();
write('evenement/romeo-julia/index-https.html');
write('evenement/romeo-julia/index-https.html_gzip');
write('evenement/romeo-julia-2/index-https.html');
await syncAndFinalize();
fs.rmSync(path.join(SRC, 'evenement/romeo-julia'), { recursive: true });
const { delQueue, addQueue } = await sync();
console.log(delQueue, addQueue);

// 2. the failure: process the delete queue like backupformat/rsync.js does
const config = { _provider: 'filesystem', backupDir: DEST, prefix: '' };
let failed = 0;
for (let run = 0; run &lt; 50; run++) {
    reset();
    for (let i = 0; i &lt; 200; i++) { write(`e/p${i}/index-https.html`); write(`e/p${i}/index-https.html_gzip`); write(`e/p${i}-2/index-https.html`); }
    await syncAndFinalize();
    for (let i = 0; i &lt; 200; i++) fs.rmSync(path.join(SRC, `e/p${i}`), { recursive: true });
    const { delQueue } = await sync();
    await async.eachLimit(delQueue, 10, async (c) =&gt; {
        if (c.operation === 'removedir') await storage.removeDir(config, {}, c.path, () =&gt; {});
        else if (c.operation === 'remove') await storage.remove(config, c.path);
    }).catch((e) =&gt; { failed++; if (failed === 1) console.log(e.message); });
}
console.log(`failed runs: ${failed}/50`);
fs.rmSync(ROOT, { recursive: true, force: true });
</code></pre>
<p dir="auto">Result on 10.1.3: part 1 prints exactly the queues shown above. Part 2 failed in 46, 47 and 50 of the 50 runs (three attempts), with errors like <code>ENOENT: no such file or directory, unlink '/tmp/syncer-repro/dest/e/p72/index-https.html'</code>. With <code>p${i}2</code> instead of <code>p${i}-2</code> as the sibling name it fails 0 out of 50 times.</p>
<p dir="auto"><strong>Suggested fix</strong></p>
<p dir="auto">Compare paths per component, so that the comparison follows the same order as the traversal:</p>
<pre><code class="language-js">// compare paths per component, so that the order matches the depth-first traversal order
// ("a" &lt; "a/x" &lt; "a-2"), instead of plain string order ("a" &lt; "a-2" &lt; "a/x")
function comparePaths(a, b) {
    const x = a.split('/'), y = b.split('/');
    for (let i = 0; i &lt; Math.min(x.length, y.length); i++) {
        if (x[i] !== y[i]) return x[i] &lt; y[i] ? -1 : 1;
    }
    return x.length - y.length;
}
</code></pre>
<p dir="auto">and use it in the three places:</p>
<pre><code class="language-diff">-        for (; curCacheIndex !== cache.length &amp;&amp; (entryPath === '' || cache[curCacheIndex].path &lt; entryPath); ++curCacheIndex) {
+        for (; curCacheIndex !== cache.length &amp;&amp; (entryPath === '' || comparePaths(cache[curCacheIndex].path, entryPath) &lt; 0); ++curCacheIndex) {
-            if (curCacheIndex !== cache.length &amp;&amp; cache[curCacheIndex].path &lt; entryPath) { // files disappeared. first advance cache as needed
+            if (curCacheIndex !== cache.length &amp;&amp; comparePaths(cache[curCacheIndex].path, entryPath) &lt; 0) { // files disappeared. first advance cache as needed
-            if (cachePath === null || cachePath &gt; entryPath) { // new files appeared
+            if (cachePath === null || comparePaths(cachePath, entryPath) &gt; 0) { // new files appeared
</code></pre>
<p dir="auto">With this change, on a copy of <code>syncer.js</code>, part 1 gives a single <code>removedir evenement/romeo-julia</code> and an empty add queue, and part 2 fails 0 out of 50 times (both with and without the dash). Existing cache files are already in traversal order, so they don't need to change.</p>
<p dir="auto">As extra hardening it might also make sense to treat ENOENT from <code>unlinkSync</code> in <code>filesystem.js</code> <code>remove()</code> as success, since the file being gone is the desired end state.</p>
]]></description><link>https://forum.cloudron.io/topic/16089/bug-rsync-backup-fails-with-enoent-on-unlink-when-a-deleted-folder-has-a-sibling-like-name-2-syncer.js-compares-paths-in-the-wrong-order</link><generator>RSS for Node</generator><lastBuildDate>Fri, 09 Oct 2026 09:54:19 GMT</lastBuildDate><atom:link href="https://forum.cloudron.io/topic/16089.rss" rel="self" type="application/rss+xml"/><pubDate>Thu, 08 Oct 2026 08:50:46 GMT</pubDate><ttl>60</ttl><item><title><![CDATA[Reply to Bug: rsync backup fails with ENOENT on unlink when a deleted folder has a sibling like "name-2" (syncer.js compares paths in the wrong order) on Thu, 08 Oct 2026 22:03:25 GMT]]></title><description><![CDATA[<p dir="auto">Good catch. Was easy to create a test and fix.</p>
]]></description><link>https://forum.cloudron.io/post/130711</link><guid isPermaLink="true">https://forum.cloudron.io/post/130711</guid><dc:creator><![CDATA[girish]]></dc:creator><pubDate>Thu, 08 Oct 2026 22:03:25 GMT</pubDate></item></channel></rss>