From edf308a74ee97d4961b5ed5e7c93e93b34af8f68 Mon Sep 17 00:00:00 2001 From: Varun Chawla Date: Fri, 13 Feb 2026 00:52:56 -0800 Subject: [PATCH] Retry fs operations on EAGAIN error Filesystem operations can return EAGAIN to indicate a temporary resource unavailability. This is similar to EMFILE/ENFILE but was not being retried by graceful-fs, causing spurious failures in tools like pnpm. Add EAGAIN to the set of retryable error codes for open, readFile, writeFile, appendFile, copyFile, and readdir operations. The existing retry/backoff/timeout logic already handles this correctly - EAGAIN just needed to be recognized as a retryable condition. Fixes #258 --- graceful-fs.js | 12 ++--- test/eagain.js | 118 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 6 deletions(-) create mode 100644 test/eagain.js diff --git a/graceful-fs.js b/graceful-fs.js index 8d5b89e..0898dcf 100644 --- a/graceful-fs.js +++ b/graceful-fs.js @@ -116,7 +116,7 @@ function patch (fs) { function go$readFile (path, options, cb, startTime) { return fs$readFile(path, options, function (err) { - if (err && (err.code === 'EMFILE' || err.code === 'ENFILE')) + if (err && (err.code === 'EMFILE' || err.code === 'ENFILE' || err.code === 'EAGAIN')) enqueue([go$readFile, [path, options, cb], err, startTime || Date.now(), Date.now()]) else { if (typeof cb === 'function') @@ -136,7 +136,7 @@ function patch (fs) { function go$writeFile (path, data, options, cb, startTime) { return fs$writeFile(path, data, options, function (err) { - if (err && (err.code === 'EMFILE' || err.code === 'ENFILE')) + if (err && (err.code === 'EMFILE' || err.code === 'ENFILE' || err.code === 'EAGAIN')) enqueue([go$writeFile, [path, data, options, cb], err, startTime || Date.now(), Date.now()]) else { if (typeof cb === 'function') @@ -157,7 +157,7 @@ function patch (fs) { function go$appendFile (path, data, options, cb, startTime) { return fs$appendFile(path, data, options, function (err) { - if (err && (err.code === 'EMFILE' || err.code === 'ENFILE')) + if (err && (err.code === 'EMFILE' || err.code === 'ENFILE' || err.code === 'EAGAIN')) enqueue([go$appendFile, [path, data, options, cb], err, startTime || Date.now(), Date.now()]) else { if (typeof cb === 'function') @@ -179,7 +179,7 @@ function patch (fs) { function go$copyFile (src, dest, flags, cb, startTime) { return fs$copyFile(src, dest, flags, function (err) { - if (err && (err.code === 'EMFILE' || err.code === 'ENFILE')) + if (err && (err.code === 'EMFILE' || err.code === 'ENFILE' || err.code === 'EAGAIN')) enqueue([go$copyFile, [src, dest, flags, cb], err, startTime || Date.now(), Date.now()]) else { if (typeof cb === 'function') @@ -212,7 +212,7 @@ function patch (fs) { function fs$readdirCallback (path, options, cb, startTime) { return function (err, files) { - if (err && (err.code === 'EMFILE' || err.code === 'ENFILE')) + if (err && (err.code === 'EMFILE' || err.code === 'ENFILE' || err.code === 'EAGAIN')) enqueue([ go$readdir, [path, options, cb], @@ -355,7 +355,7 @@ function patch (fs) { function go$open (path, flags, mode, cb, startTime) { return fs$open(path, flags, mode, function (err, fd) { - if (err && (err.code === 'EMFILE' || err.code === 'ENFILE')) + if (err && (err.code === 'EMFILE' || err.code === 'ENFILE' || err.code === 'EAGAIN')) enqueue([go$open, [path, flags, mode, cb], err, startTime || Date.now(), Date.now()]) else { if (typeof cb === 'function') diff --git a/test/eagain.js b/test/eagain.js new file mode 100644 index 0000000..010fd0b --- /dev/null +++ b/test/eagain.js @@ -0,0 +1,118 @@ +'use strict' + +var importFresh = require('import-fresh') +var path = require('path') +var realFs = require('fs') +var test = require('tap').test + +var EAGAIN = Object.assign(new Error('FAKE EAGAIN'), { code: 'EAGAIN' }) + +test('EAGAIN readFile is retried and eventually succeeds', function (t) { + var readFile = realFs.readFile + var eagainCount = 0 + + t.teardown(function () { + realFs.readFile = readFile + }) + + realFs.readFile = function (path, options, cb) { + process.nextTick(function () { + if (eagainCount < 3) { + eagainCount++ + return cb(EAGAIN) + } + // succeed after 3 retries + cb(null, Buffer.from('test content')) + }) + } + + var fs = importFresh(path.dirname(__dirname)) + fs.readFile('literally anything', function (err, data) { + t.notOk(err, 'should not get an error') + t.equal(eagainCount, 3, 'should have retried 3 times') + t.equal(data.toString(), 'test content', 'should get the content') + t.end() + }) +}) + +test('EAGAIN eventually times out and returns error', function (t) { + var readFile = realFs.readFile + var realNow = Date.now + + t.teardown(function () { + realFs.readFile = readFile + Date.now = realNow + }) + + realFs.readFile = function (path, options, cb) { + process.nextTick(function () { + cb(EAGAIN) + // hijack Date.now _after_ we call the callback, the callback will + // call it when adding the job to the queue, we want to capture it + // any time after that first call so we can pretend it's been 60s + Date.now = function () { + return realNow() + 60000 + } + }) + } + + var fs = importFresh(path.dirname(__dirname)) + fs.readFile('literally anything', function (err) { + t.equal(err.code, 'EAGAIN', 'eventually got the EAGAIN') + t.end() + }) +}) + +test('EAGAIN open is retried and eventually succeeds', function (t) { + var open = realFs.open + var eagainCount = 0 + + t.teardown(function () { + realFs.open = open + }) + + realFs.open = function (path, flags, mode, cb) { + process.nextTick(function () { + if (eagainCount < 2) { + eagainCount++ + return cb(EAGAIN) + } + // succeed after 2 retries with a fake fd + cb(null, 42) + }) + } + + var fs = importFresh(path.dirname(__dirname)) + fs.open('literally anything', 'r', function (err, fd) { + t.notOk(err, 'should not get an error') + t.equal(eagainCount, 2, 'should have retried 2 times') + t.equal(fd, 42, 'should get the fd') + t.end() + }) +}) + +test('EAGAIN writeFile is retried and eventually succeeds', function (t) { + var writeFile = realFs.writeFile + var eagainCount = 0 + + t.teardown(function () { + realFs.writeFile = writeFile + }) + + realFs.writeFile = function (path, data, options, cb) { + process.nextTick(function () { + if (eagainCount < 2) { + eagainCount++ + return cb(EAGAIN) + } + cb(null) + }) + } + + var fs = importFresh(path.dirname(__dirname)) + fs.writeFile('literally anything', 'data', function (err) { + t.notOk(err, 'should not get an error') + t.equal(eagainCount, 2, 'should have retried 2 times') + t.end() + }) +})