Skip to content

Fixes race in scripts/install-binary.mjs#766

Open
qwerdenkerXD wants to merge 1 commit into
sanack:mainfrom
qwerdenkerXD:fix-bin-install-race
Open

Fixes race in scripts/install-binary.mjs#766
qwerdenkerXD wants to merge 1 commit into
sanack:mainfrom
qwerdenkerXD:fix-bin-install-race

Conversation

@qwerdenkerXD

Copy link
Copy Markdown

Hello there, I found a race in the scripts/install-binary.mjs in WSL.

npm error code 1                                                                                                                                                                                                                   
npm error path /mnt/c/Users/.../node_modules/node-jq                                                                                                                     
npm error command failed                                                                                                                                                                                                           
npm error command sh -c npm run install-binary                                                                                                                                                                                     
npm error > node-jq@6.3.1 install-binary                                                                                                                                                                                           
npm error > node scripts/install-binary.mjs                                                                                                                                                                                        
npm error                                                                                                                                                                                                                          
npm error /mnt/c/Users/.../node_modules/node-jq/bin directory was created                                                                                                
npm error Downloading jq from https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux-amd64                                                                                                                               
Downloading jq: 100.00%q: 0.06%                                                                                                                                                                                                    
npm error Download complete                                                                                                                                                                                                        
npm error node:fs:2064                                                                                                                                                                                                             
npm error   binding.chmod(path, mode);                                                                                                                                                                                             
npm error           ^                                                                                                                                                                                                              
npm error                                                                                                                                                                                                                          
npm error Error: ENOENT: no such file or directory, chmod '/mnt/c/Users/.../node_modules/node-jq/bin/jq'                                                                 
npm error     at chmodSync (node:fs:2064:11)                                                                                                                                                                                       
npm error     at downloadJqBinary (file:///mnt/c/Users/Franz-Eric%20Sill/Documents/Git%20Repos/node_modules/node-jq/scripts/install-binary.mjs:123:5)                                                    
npm error     at process.processTicksAndRejections (node:internal/process/task_queues:105:5) {                                                                                                                                     
npm error   errno: -2,                                                                                                                                                                                                             
npm error   code: 'ENOENT',                                                                                                                                                                                                        
npm error   syscall: 'chmod',                                                                                                                                                                                                      
npm error   path: '/mnt/c/Users/.../node_modules/node-jq/bin/jq'                                                                                                         
npm error }                                                                                                                                                                                                                        
npm error                                                                                                                                                                                                                          
npm error Node.js v23.9.0                                                                                                                                                                                                          
npm error A complete log of this run can be found in: /home/franz/.npm/_logs/2026-07-13T06_22_28_283Z-debug-0.log

I have a script to reproduce this. It adds a timeout to the end function of the file stream writer. It takes the target path as first argument. With a mounted path like node race-repro.js /mnt/c/Users/<name>/Desktop it fails, but with a native path like node race-repro.js ~ it succeeds.

See code
// Reproduction of a real race condition in node-jq's install script
// (node_modules/node-jq/scripts/install-binary.mjs, downloadJqBinary()).
//
// That script does:
//   dataStream.on("end", () => {
//     fileStream.end();   // schedules an async flush+close, does NOT wait for it
//     resolve();          // resolves immediately, same tick
//   })
// then, back in the caller:
//   renameSync(...)
//   if (fileExist(distPath)) chmodSync(distPath, 0o755)
//
// fileStream.end() with no callback/await only *schedules* the flush+close;
// it does not block until the file is actually finished being written. Since
// resolve() fires synchronously right after, the awaited promise can resolve
// before the file is fully flushed and its descriptor closed.
//
// On a real POSIX filesystem this is usually invisible: rename()/chmod() only
// need the directory entry to exist (created the moment the file was opened),
// not for pending writes to be flushed, so this race rarely manifests.
//
// On WSL's /mnt/c mount (DrvFs, a 9P-protocol passthrough to the Windows
// filesystem), renaming/chmod'ing a file while its write handle is still open
// is NOT reliable the same way -- which is exactly the ENOENT error node-jq's
// install script throws there.
//
// Usage:
//   node node-jq-install-race-repro.js                  # runs against os.tmpdir()
//   node node-jq-install-race-repro.js /mnt/c/some/dir   # runs against a DrvFs path
//
// On a native Linux filesystem (e.g. /tmp) both the "buggy" and "fixed"
// variants below succeed. On a DrvFs path, the buggy variant reproduces the
// same ENOENT node-jq hits; the fixed variant (which waits for end()'s
// callback) succeeds every time.

const fs = require("fs");
const os = require("os");
const path = require("path");
const { Readable } = require("stream");

const TARGET_DIR = process.argv[2] || os.tmpdir();

// A Writable that behaves like fs.createWriteStream, but artificially delays
// the moment it actually finishes flushing -- simulating a slow/high-latency
// filesystem deterministically, regardless of what disk this actually runs on.
function slowWriteStream(dest, delayMs) {
  const real = fs.createWriteStream(dest);
  const originalEnd = real.end.bind(real);
  real.end = (...args) => {
    const cb = typeof args[args.length - 1] === "function" ? args.pop() : null;
    setTimeout(() => {
      originalEnd(...args, () => {
        if (cb) cb();
      });
    }, delayMs);
    return real;
  };
  return real;
}

async function buggyDownload(dest) {
  const fileStream = slowWriteStream(dest, 2000); // simulate a slow flush
  const dataStream = Readable.from([Buffer.from("x".repeat(1000))]);

  return new Promise((resolve, reject) => {
    dataStream.pipe(fileStream);
    dataStream.on("end", () => {
      fileStream.end(); // <-- the bug: no callback, no await (matches install-binary.mjs)
      resolve(); // resolves immediately, NOT when the file is actually flushed
    });
    dataStream.on("error", reject);
  });
}

async function fixedDownload(dest) {
  const fileStream = slowWriteStream(dest, 2000);
  const dataStream = Readable.from([Buffer.from("x".repeat(1000))]);

  return new Promise((resolve, reject) => {
    dataStream.pipe(fileStream);
    dataStream.on("end", () => {
      fileStream.end(() => resolve()); // <-- fixed: wait for the callback
    });
    dataStream.on("error", reject);
  });
}

async function demo(label, downloadFn) {
  const src = path.join(TARGET_DIR, `race-src-${Date.now()}`);
  const dest = path.join(TARGET_DIR, `race-dest-${Date.now()}`);
  await downloadFn(src);
  try {
    fs.renameSync(src, dest); // mirrors install-binary.mjs's renameSync(...)
    fs.chmodSync(dest, 0o755); // mirrors install-binary.mjs's chmodSync(distPath, 0o755)
    console.log(`[${label}] OK — chmod succeeded immediately after "download"`);
    fs.unlinkSync(dest);
  } catch (err) {
    console.log(`[${label}] FAILED — ${err.code}: ${err.message}`);
  }
}

(async () => {
  console.log(`Target directory: ${TARGET_DIR}`);
  await demo("buggy (no await on end())", buggyDownload);
  await demo("fixed (waits for end() callback)", fixedDownload);
})();

Fix: resolve in callback function, so:
Turn this:

    dataStream.on('end', () => {
      console.log('\nDownload complete')
      fileStream.end()
      resolve()
    })

    dataStream.on('error', (err) => {
      fileStream.end()
      reject(err)
    })

into this:

    dataStream.on('end', () => {
      console.log('\nDownload complete')
      fileStream.end(() => resolve())
    })

    dataStream.on('error', (err) => {
      fileStream.end(() => reject(err))
    })

This resolves cleanly.

In WSL on mounted paths like /mnt/c/... it happened that the file stream of the downloaded jq binary wasn't closed before chmod was called, leading to:
npm error Error: ENOENT: no such file or directory, chmod '/mnt/c/.../node_modules/node-jq/bin/jq'
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant