Skip to content

fs: first promisified fd rejection awaited from inside an fs callback loses err.code/err.syscall #6401

Description

@proggeramlug

Perry prints undefined undefined where node prints EBADF ftruncate, for the first promisified fs rejection awaited after entering an async chain from inside an fs callback. The futimes / fchown lines that follow are correct — only the first one loses err.code / err.syscall.

Found while de-flaking test_gap_fs_fd_2749 (#6400). It is not hit by the shipped test — that keeps main() at top level — so this is not blocking anything. Filing it because it is a real divergence someone will hit.

Repro

Self-contained; it is test-files/test_gap_fs_fd_2749.ts with the callback results buffered and main() invoked from the last callback instead of at top level:

// Gap test: node:fs fd-mutator error propagation for ftruncate / fchown /
// futimes (#2749). Operating on a closed/invalid fd must surface EBADF with
// Node-compatible `err.code`/`err.syscall` across the sync, callback, and
// promisified (await) forms instead of silently succeeding.
//
// We assert only `err.code` and `err.syscall` (plus success markers), never
// volatile message text. Node names the futimes syscall "futime" (singular).
import * as fs from "node:fs";
import * as fsp from "node:fs/promises";
import { promisify } from "node:util";

const dir = "/tmp/perry_fs_fd_2749";
fs.rmSync(dir, { recursive: true, force: true });
fs.mkdirSync(dir, { recursive: true });

const file = dir + "/file.txt";
fs.writeFileSync(file, "hello world");

// Open then immediately close to confirm the descriptor lifecycle works;
// the resulting fd number can be recycled by internal opens (libuv, the
// promisify machinery), so the actual error assertions below operate on a
// deterministically-invalid descriptor that was never allocated.
const opened = fs.openSync(file, "r+");
fs.closeSync(opened);
const fd = 987654321;

function syncCode(label: string, fn: () => void): void {
  try {
    fn();
    console.log(label + ": OK");
  } catch (e: any) {
    console.log(label + ": " + e.code + " " + e.syscall);
  }
}

// The three callback-form calls below are dispatched to the libuv threadpool
// concurrently, so their callbacks fire in *completion* order, which is not
// deterministic — node itself emits `fchown closed cb` before `futimes closed
// cb` on roughly one run in six. Comparing raw interleaving against a freshly
// run node oracle therefore fails at random on any PR.
//
// What this test is actually about is the error surface (`err.code` /
// `err.syscall`) of each fd mutator, not libuv's scheduling. So buffer the
// three results and flush them in a fixed order once all three have landed,
// then start the promisified forms — which makes the whole output stable
// without weakening a single assertion.
const CB_ORDER = ["ftruncate closed cb", "futimes closed cb", "fchown closed cb"];
const cbLines = new Map<string, string>();

function cbCode(label: string, err: any): void {
  cbLines.set(label, err ? label + ": " + err.code + " " + err.syscall : label + ": OK");
  if (cbLines.size < CB_ORDER.length) {
    return;
  }
  for (const key of CB_ORDER) {
    console.log(cbLines.get(key));
  }
  // Sequence the promise forms after the callbacks so the two groups can't
  // interleave either (they raced through the same threadpool before).
  main();
}

async function asyncCode(label: string, fn: () => Promise<unknown>): Promise<void> {
  try {
    await fn();
    console.log(label + ": OK");
  } catch (e: any) {
    console.log(label + ": " + e.code + " " + e.syscall);
  }
}

// --- sync forms ---
syncCode("ftruncate closed sync", () => fs.ftruncateSync(fd, 1));
syncCode("futimes closed sync", () => fs.futimesSync(fd, 1, 2));
syncCode("fchown closed sync", () => fs.fchownSync(fd, 0, 0));

// keep `fsp` referenced so the namespace import is exercised
console.log("fsp.readFile typeof=" + typeof fsp.readFile);

// --- callback forms ---
fs.ftruncate(fd, 1, (err: any) => cbCode("ftruncate closed cb", err));
fs.futimes(fd, 1, 2, (err: any) => cbCode("futimes closed cb", err));
fs.fchown(fd, 0, 0, (err: any) => cbCode("fchown closed cb", err));

// --- promisified (await) forms ---
const ftruncateP = promisify(fs.ftruncate);
const futimesP = promisify(fs.futimes);
const fchownP = promisify(fs.fchown);

async function main(): Promise<void> {
  await asyncCode("ftruncate closed promise", () => ftruncateP(fd, 1));
  await asyncCode("futimes closed promise", () => futimesP(fd, 1, 2));
  await asyncCode("fchown closed promise", () => fchownP(fd, 0, 0));

  fs.rmSync(dir, { recursive: true, force: true });
}

// `main()` is invoked from `cbCode` once all three callbacks have reported.

Observed

$ node --experimental-strip-types repro.ts | sed -n 8p
ftruncate closed promise: EBADF ftruncate

$ perry repro.ts -o r && ./r | sed -n 8p
ftruncate closed promise: undefined undefined

Deterministic — 40/40 runs. Every other line, including futimes closed promise and fchown closed promise, is correct.

What I ruled out (each reproduces nothing on its own)

I tried to minimize this and could not; these variants all match node exactly:

  1. await promisify(fs.ftruncate)(fd, 1) in a try/catch, entered from an fs callback.
  2. Same, but awaited through a thunk-taking async function asyncCode(label, fn) helper — the shape the test uses.
  3. Same, plus three separate promisified fns (ftruncateP / futimesP / fchownP) awaited in sequence.
  4. Same, plus a preceding sync try/catch section (i.e. with setjmp/longjmp in the picture) and three concurrent fs callbacks with main() fired from the last.

So it is not simply "await a promisified fs rejection from a callback". Something about the full combination is required — the remaining untested deltas are the import * as fsp from "node:fs/promises" namespace import and the real file setup (mkdirSync/writeFileSync/openSync/closeSync) at the top.

Environment: macOS arm64, perry built from main @ 205473f1c + #6397.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions