openclaw - 💡(How to fix) Fix Telegram isolated polling spool drain: ENOENT race in recoverStaleTelegramSpooledUpdateClaims

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…

recoverStaleTelegramSpooledUpdateClaims in extensions/telegram/src/telegram-ingress-spool.ts has a TOCTOU race between fs.readdir and the final fs.rename(claimedPath, pendingPath). When a concurrent operation removes the .processing file between those two points, the rename fails with ENOENT and surfaces as a [telegram][diag] isolated polling spool drain failed log entry.

Error Message

try { if (await pathExists(pendingPath)) await unlinkIfPresent(claimedPath); else await fs.rename(claimedPath, pendingPath); } catch (err) { const code = (err as NodeJS.ErrnoException).code; if (code === "ENOENT") continue; if (code === "EEXIST") { await unlinkIfPresent(claimedPath); continue; } throw err; } recovered += 1;

Root Cause

In recoverStaleTelegramSpooledUpdateClaims:

let entries;
try {
    entries = await fs.readdir(params.spoolDir);
} catch (err) {
    if (err.code === "ENOENT") return 0;
    throw err;
}
// ...
for (const entry of entries.filter(isProcessingFileName).toSorted()) {
    const claimedPath = path.join(params.spoolDir, entry);
    const pendingPath = path.join(params.spoolDir, pendingFileNameFromProcessing(entry));
    // ...
    let stat;
    try {
        stat = await fs.stat(claimedPath);
    } catch (err) {
        if (err.code === "ENOENT") continue; // handled here
        throw err;
    }
    if (now - stat.mtimeMs < staleMs) continue;
    if (params.shouldRecover) {
        const { value } = await readJsonFileWithFallback(claimedPath, null);
        const parsed = parseSpooledUpdate(value, claimedPath);
        if (parsed && !await params.shouldRecover({ ...parsed, pendingPath })) continue;
        // NOTE: if `parsed` is null (file gone or unreadable), this falls through
    }
    if (await pathExists(pendingPath)) await unlinkIfPresent(claimedPath);
    else await fs.rename(claimedPath, pendingPath); // <-- ENOENT here if file vanished
    recovered += 1;
}

The drain loop in monitor-polling.runtime calls this with staleMs: 0, so every .processing file is considered eligible for recovery every cycle. Concurrent paths that can remove or rename the .processing file between readdir and rename:

  • claimTelegramSpooledUpdate running in the same process from a different drain lane
  • failTelegramSpooledUpdateClaim finalizing a tombstone
  • releaseTelegramSpooledUpdateClaim putting the claim back to pending
  • Multi-process bot configurations performing the same recovery

When readJsonFileWithFallback returns null (file already gone), parsed is null, the shouldRecover guard short-circuits the inner && to false, no continue runs, and execution falls into the final pathExists + rename block — which then crashes on the missing source.

Code Example

15:14:28+00:00 error channels/telegram {"subsystem":"channels/telegram"} [telegram][diag] isolated polling spool drain failed (1): ENOENT: no such file or directory, rename '<spoolDir>/0000000341719252.json.processing' -> '<spoolDir>/0000000341719252.json'

---

let entries;
try {
    entries = await fs.readdir(params.spoolDir);
} catch (err) {
    if (err.code === "ENOENT") return 0;
    throw err;
}
// ...
for (const entry of entries.filter(isProcessingFileName).toSorted()) {
    const claimedPath = path.join(params.spoolDir, entry);
    const pendingPath = path.join(params.spoolDir, pendingFileNameFromProcessing(entry));
    // ...
    let stat;
    try {
        stat = await fs.stat(claimedPath);
    } catch (err) {
        if (err.code === "ENOENT") continue; // handled here
        throw err;
    }
    if (now - stat.mtimeMs < staleMs) continue;
    if (params.shouldRecover) {
        const { value } = await readJsonFileWithFallback(claimedPath, null);
        const parsed = parseSpooledUpdate(value, claimedPath);
        if (parsed && !await params.shouldRecover({ ...parsed, pendingPath })) continue;
        // NOTE: if `parsed` is null (file gone or unreadable), this falls through
    }
    if (await pathExists(pendingPath)) await unlinkIfPresent(claimedPath);
    else await fs.rename(claimedPath, pendingPath); // <-- ENOENT here if file vanished
    recovered += 1;
}

---

try {
    if (await pathExists(pendingPath)) await unlinkIfPresent(claimedPath);
    else await fs.rename(claimedPath, pendingPath);
} catch (err) {
    const code = (err as NodeJS.ErrnoException).code;
    if (code === "ENOENT") continue;
    if (code === "EEXIST") {
        await unlinkIfPresent(claimedPath);
        continue;
    }
    throw err;
}
recovered += 1;
RAW_BUFFERClick to expand / collapse

Summary

recoverStaleTelegramSpooledUpdateClaims in extensions/telegram/src/telegram-ingress-spool.ts has a TOCTOU race between fs.readdir and the final fs.rename(claimedPath, pendingPath). When a concurrent operation removes the .processing file between those two points, the rename fails with ENOENT and surfaces as a [telegram][diag] isolated polling spool drain failed log entry.

Observed log

15:14:28+00:00 error channels/telegram {"subsystem":"channels/telegram"} [telegram][diag] isolated polling spool drain failed (1): ENOENT: no such file or directory, rename '<spoolDir>/0000000341719252.json.processing' -> '<spoolDir>/0000000341719252.json'

The error is harmless in practice (drain retries on the next interval and the spool ends up empty), but it (1) bumps consecutiveDrainFailures, (2) clutters logs and may trigger alerting, and (3) the same path in releaseTelegramSpooledUpdateClaim already handles ENOENT/EEXIST — so the inconsistency looks like an oversight.

Versions

Reproduced on 2026.5.22. The relevant code is byte-identical in 2026.5.27 (the latest stable as of writing) — confirmed by diffing dist/telegram-ingress-spool-*.js between the two published tarballs (only the import-hash filenames change).

Affected file in the installed package: dist/telegram-ingress-spool-*.js (built from extensions/telegram/src/telegram-ingress-spool.ts).

Root cause

In recoverStaleTelegramSpooledUpdateClaims:

let entries;
try {
    entries = await fs.readdir(params.spoolDir);
} catch (err) {
    if (err.code === "ENOENT") return 0;
    throw err;
}
// ...
for (const entry of entries.filter(isProcessingFileName).toSorted()) {
    const claimedPath = path.join(params.spoolDir, entry);
    const pendingPath = path.join(params.spoolDir, pendingFileNameFromProcessing(entry));
    // ...
    let stat;
    try {
        stat = await fs.stat(claimedPath);
    } catch (err) {
        if (err.code === "ENOENT") continue; // handled here
        throw err;
    }
    if (now - stat.mtimeMs < staleMs) continue;
    if (params.shouldRecover) {
        const { value } = await readJsonFileWithFallback(claimedPath, null);
        const parsed = parseSpooledUpdate(value, claimedPath);
        if (parsed && !await params.shouldRecover({ ...parsed, pendingPath })) continue;
        // NOTE: if `parsed` is null (file gone or unreadable), this falls through
    }
    if (await pathExists(pendingPath)) await unlinkIfPresent(claimedPath);
    else await fs.rename(claimedPath, pendingPath); // <-- ENOENT here if file vanished
    recovered += 1;
}

The drain loop in monitor-polling.runtime calls this with staleMs: 0, so every .processing file is considered eligible for recovery every cycle. Concurrent paths that can remove or rename the .processing file between readdir and rename:

  • claimTelegramSpooledUpdate running in the same process from a different drain lane
  • failTelegramSpooledUpdateClaim finalizing a tombstone
  • releaseTelegramSpooledUpdateClaim putting the claim back to pending
  • Multi-process bot configurations performing the same recovery

When readJsonFileWithFallback returns null (file already gone), parsed is null, the shouldRecover guard short-circuits the inner && to false, no continue runs, and execution falls into the final pathExists + rename block — which then crashes on the missing source.

Proposed fix

Mirror the error handling already present in releaseTelegramSpooledUpdateClaim:

try {
    if (await pathExists(pendingPath)) await unlinkIfPresent(claimedPath);
    else await fs.rename(claimedPath, pendingPath);
} catch (err) {
    const code = (err as NodeJS.ErrnoException).code;
    if (code === "ENOENT") continue;
    if (code === "EEXIST") {
        await unlinkIfPresent(claimedPath);
        continue;
    }
    throw err;
}
recovered += 1;

Optionally also skip the entry when parsed is null inside the shouldRecover branch, since a vanished or unparseable file shouldn't be eligible for blind rename.

Impact / severity

Low. The error is recoverable on the next drain interval (~500 ms) and message delivery is not lost. Mainly noise + spurious drain-failure counters.

Repro

Hard to repro deterministically because it's a race, but it surfaces under normal load on isolated polling accounts with a moderate update rate. Spool dir was empty by the time I inspected, confirming the update had been processed successfully before the recovery rename attempt.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING