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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions