Skip to content

fs.copy triggers unhandled rejection warnings when files are deleted mid-copy #1056

@Adi1231234

Description

@Adi1231234

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.copy may reject when files disappear — that’s fine.
  • The caller should be able to catch this with try/catch.
  • No UnhandledPromiseRejectionWarning should 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 late

Proposed 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 their try/catch.
  • Spurious UnhandledPromiseRejectionWarning logs confuse monitoring and error handling.
  • With this change, the semantics of fs.copy stay the same — errors still bubble up — but without noisy unhandled warnings.

Thanks for this project 🙏
I’d be glad to hear your thoughts!

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions