-
Notifications
You must be signed in to change notification settings - Fork 779
Description
When copying a directory that is being mutated concurrently (files created/deleted), fs.copy sometimes emits:
UnhandledPromiseRejectionWarning: Error: ENOENT: no such file or directory, lstat ...
PromiseRejectionHandledWarning: Promise rejection was handled asynchronously (rejection id: N)
This happens even though the user wraps the call in try/catch:
try {
await fs.copy(src, dest, { overwrite: true });
} catch (err) {
console.log("caught:", err);
}It is expected and correct that fs.copy rejects with an error like ENOENT.
It is not expected that Node logs UnhandledPromiseRejectionWarning on top of the caught error.
The caller should be able to handle errors with try/catch alone.
Minimal reproduction
import fs from "fs";
import path from "path";
import fse from "fs-extra";
const src = "C:\\tmp\\chaos-src";
const dest = "C:\\tmp\\chaos-dest";
fs.mkdirSync(src, { recursive: true });
// background: create & delete files quickly
async function chaos() {
while (true) {
const file = path.join(src, `f-${Math.random()}.txt`);
fs.writeFileSync(file, "data");
setTimeout(() => { try { fs.unlinkSync(file); } catch {} }, Math.random() * 100);
await new Promise(r => setTimeout(r, 10));
}
}
// foreground: repeatedly copy
async function run() {
chaos(); // run in background
while (true) {
try {
await fse.copy(src, dest, { overwrite: true });
} catch (err) {
console.log("caught:", err.code, err.path);
}
}
}
run();Actual behavior
Along with the caught error, Node also prints:
UnhandledPromiseRejectionWarning: Error: ENOENT: no such file or directory, lstat ...
PromiseRejectionHandledWarning: Promise rejection was handled asynchronously (rejection id: N)
Expected behavior
fs.copymay reject when files disappear — that’s fine.- The caller should be able to catch this with
try/catch. - No
UnhandledPromiseRejectionWarningshould appear if the user is already handling rejections.
Root cause
In [copy.js](https://github.com/jprichardson/node-fs-extra/blob/master/lib/copy/copy.js), onDir builds an array of promises during iteration and only later awaits them via Promise.all. If a promise rejects immediately (e.g. ENOENT), it is temporarily unhandled until Promise.all attaches a handler, which causes the warnings.
Pattern (simplified):
const promises = [];
for (const item of items) {
promises.push(doSomething(item)); // may reject immediately
}
await Promise.all(promises); // handler attached too lateProposed fix
Attach a handler immediately when creating each promise, so no rejection is ever “unhandled”:
promises.push(
doSomething(item).catch(err => { throw err; })
);This way, rejections still propagate to Promise.all and eventually to the caller, but Node never reports them as unhandled.
Alternatively, items could be processed sequentially or via a concurrency-limited queue, both of which would avoid the race.
Why this matters
- Users expect to catch operational errors (e.g.
ENOENT) in theirtry/catch. - Spurious
UnhandledPromiseRejectionWarninglogs confuse monitoring and error handling. - With this change, the semantics of
fs.copystay the same — errors still bubble up — but without noisy unhandled warnings.
Thanks for this project 🙏
I’d be glad to hear your thoughts!