Commit 6c84c341bd1 for nodejs
commit 6c84c341bd18602020959bc87cf4286b129202d1
Author: James M Snell <jasnell@gmail.com>
Date: Sat Oct 3 01:40:48 2026 +0000
stream: fix Utf8Stream flush handling
Fix several issues with `Utf8Stream` flushing:
* `flush()` now writes buffered data regardless of `minLength`
and invokes the callback only after pending writes complete,
including when `minLength` is zero and a write is in flight.
* Multiple concurrent `flush()` calls are tracked correctly and
`end()` waits for pending flushes before closing.
* `flushSync()` throws `ERR_INVALID_STATE` if called while an
asynchronous write is in progress instead of corrupting output.
* Periodic flushes no longer stack up while a flush is pending.
* `fsync` is skipped for stdout/stderr file descriptors.
* `reopen()`, `end()` and `destroy()` behave correctly when the
stream is destroyed while still opening.
Signed-off-by: James M Snell <jasnell@gmail.com>
Assisted-by: Opencode
PR-URL: https://github.com/nodejs/node/pull/66473
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
diff --git a/doc/api/fs.md b/doc/api/fs.md
index d6eb4208ef0..f49e131ee7b 100644
--- a/doc/api/fs.md
+++ b/doc/api/fs.md
@@ -8563,13 +8563,20 @@ Close the stream gracefully, flushing the internal buffer before closing.
* `callback` {Function}
* `err` {Error|null} An error if the flush failed, otherwise `null`.
-Writes the current buffer to the file if a write was not in progress. Do
-nothing if `minLength` is zero or if it is already writing.
+Writes the current buffer to the file. The callback is invoked after pending
+writes complete and, unless the `fsync` option is enabled, after the data has
+been synchronized with `fs.fsync()`. `fsync` errors indicating that the file
+descriptor does not support synchronization (for example, a pipe or a TTY)
+are ignored.
#### `utf8Stream.flushSync()`
Flushes the buffered data synchronously. This is a costly operation.
+If an asynchronous write is in flight when `flushSync()` is called, only the
+data queued after it is written, and it may reach the file before the data
+of the in-flight write.
+
#### `utf8Stream.fsync`
* {boolean} Whether the stream is performing a `fs.fsyncSync()` after every
diff --git a/lib/internal/streams/fast-utf8-stream.js b/lib/internal/streams/fast-utf8-stream.js
index d97f1cae325..004ff14781f 100644
--- a/lib/internal/streams/fast-utf8-stream.js
+++ b/lib/internal/streams/fast-utf8-stream.js
@@ -6,7 +6,9 @@
const {
ArrayPrototypePush,
+ ArrayPrototypeUnshift,
MathMax,
+ Symbol,
SymbolDispose,
} = primordials;
@@ -28,6 +30,7 @@ const EventEmitter = require('events');
const path = require('path');
const {
clearInterval,
+ clearTimeout,
setInterval,
setTimeout,
} = require('timers');
@@ -58,6 +61,38 @@ const kMaxWrite = 16 * 1024;
const kContentModeBuffer = 'buffer';
const kContentModeUtf8 = 'utf8';
const kNullPrototype = { __proto__: null };
+const kFlush = Symbol('kFlush');
+
+// States returned by #beginFlushSync().
+// No write is in flight.
+const kFlushSyncIdle = 0;
+// An EAGAIN/EBUSY retry timer was pending and has been cancelled. Nothing is
+// in flight, so the remainder of #writingBuf can be written synchronously.
+const kFlushSyncRetry = 1;
+// Called from a 'write' listener. The completed write has been released and
+// nothing is in flight, so the remainder of #writingBuf can be written
+// synchronously.
+const kFlushSyncInWriteEvent = 2;
+// An asynchronous fs.write() of #writingBuf is in flight. Only the queued
+// buffers can be written; ordering relative to the in-flight chunk is not
+// guaranteed.
+const kFlushSyncInFlight = 3;
+
+// fsync() error codes meaning the file descriptor cannot be synchronized
+// (e.g. it is a pipe, socket, or TTY) or has already been closed. These are
+// not treated as flush failures.
+function isIgnorableFsyncError(err) {
+ switch (err?.code) {
+ case 'EBADF':
+ case 'EINVAL':
+ case 'ENOTSUP':
+ case 'EOPNOTSUPP':
+ case 'EROFS':
+ return true;
+ default:
+ return false;
+ }
+}
// Utf8Stream is a port of the original SonicBoom module
// (https://github.com/pinojs/sonic-boom) that provides a fast and efficient
@@ -71,7 +106,11 @@ class Utf8Stream extends EventEmitter {
#ending = false;
#reopening = false;
#asyncDrainScheduled = false;
- #flushPending = false;
+ #flushPending = 0;
+ #flushInProgress = 0;
+ #emittingFlush = false;
+ #emittingWrite = false;
+ #retryTimer = null;
#hwm = 16387; // 16 KB
#file = null;
#destroyed = false;
@@ -233,7 +272,11 @@ class Utf8Stream extends EventEmitter {
});
if (this.#periodicFlush !== 0) {
- this.#periodicFlushTimer = setInterval(() => this.flush(null), this.#periodicFlush);
+ this.#periodicFlushTimer = setInterval(() => {
+ if (this.#flushPending === 0 && this.#flushInProgress === 0) {
+ this.flush();
+ }
+ }, this.#periodicFlush);
this.#periodicFlushTimer.unref();
}
}
@@ -268,7 +311,9 @@ class Utf8Stream extends EventEmitter {
}
if (this.#opening) {
- this.once('ready', () => this.reopen(file));
+ this.once('ready', () => {
+ if (!this.#destroyed) this.reopen(file);
+ });
return;
}
@@ -311,7 +356,7 @@ class Utf8Stream extends EventEmitter {
if (this.#opening) {
this.once('ready', () => {
- this.end();
+ if (!this.#destroyed) this.end();
});
return;
}
@@ -329,7 +374,7 @@ class Utf8Stream extends EventEmitter {
if (this.#len > 0 && this.#fd >= 0) {
this.#actualWrite();
} else {
- this.#actualClose();
+ this.#finishEnding();
}
}
@@ -337,7 +382,11 @@ class Utf8Stream extends EventEmitter {
if (this.#destroyed) {
return;
}
+ const opening = this.#opening;
this.#actualClose();
+ if (opening && this.#flushPending > 0) {
+ this.emit('error', new ERR_INVALID_STATE('Utf8Stream is destroyed'));
+ }
}
/** @type {number} */
@@ -403,7 +452,10 @@ class Utf8Stream extends EventEmitter {
}
} else {
// Let's give the destination some time to process the chunk.
- setTimeout(() => this.#fsWrite(), BUSY_WRITE_TIMEOUT);
+ this.#retryTimer = setTimeout(() => {
+ this.#retryTimer = null;
+ this.#fsWrite();
+ }, BUSY_WRITE_TIMEOUT);
}
} else {
this.#writing = false;
@@ -420,11 +472,20 @@ class Utf8Stream extends EventEmitter {
this.#writeRetries = 0;
}
- this.emit('write', n);
const releasedBufObj = releaseWritingBuf(this.#writingBuf, this.#len, n);
this.#len = releasedBufObj.len;
this.#writingBuf = releasedBufObj.writingBuf;
+ // Emit 'write' after the written bytes have been released so that a
+ // listener calling flushSync() sees a consistent state with no I/O in
+ // flight. flushSync() may consume the remainder of #writingBuf.
+ this.#emittingWrite = true;
+ try {
+ this.emit('write', n);
+ } finally {
+ this.#emittingWrite = false;
+ }
+
if (this.#writingBuf.length) {
if (!this.#sync) {
this.#fsWrite();
@@ -444,6 +505,18 @@ class Utf8Stream extends EventEmitter {
}
}
+ if (this.#destroyed) {
+ this.#writing = false;
+ if (this.#flushPending > 0) {
+ if (this.#len === 0) {
+ this.#scheduleDrain();
+ } else {
+ this.emit('error', new ERR_INVALID_STATE('Utf8Stream is destroyed'));
+ }
+ }
+ return;
+ }
+
if (this.#fsync) {
this.#fs.fsyncSync(this.#fd);
}
@@ -453,25 +526,19 @@ class Utf8Stream extends EventEmitter {
this.#writing = false;
this.#reopening = false;
this.reopen();
- } else if (len > this.#minLength) {
+ } else if (len > 0 &&
+ (len > this.#minLength || this.#flushPending)) {
this.#actualWrite();
} else if (this.#ending) {
if (len > 0) {
this.#actualWrite();
} else {
this.#writing = false;
- this.#actualClose();
+ this.#finishEnding();
}
} else {
this.#writing = false;
- if (this.#sync) {
- if (!this.#asyncDrainScheduled) {
- this.#asyncDrainScheduled = true;
- process.nextTick(() => this.#emitDrain());
- }
- } else {
- this.emit('drain');
- }
+ this.#scheduleDrain();
}
}
@@ -520,12 +587,13 @@ class Utf8Stream extends EventEmitter {
}
// start
- if ((!this.#writing && this.#len > this.#minLength) || this.#flushPending) {
+ if (!this.#writing &&
+ (this.#len > this.#minLength || this.#flushPending)) {
this.#actualWrite();
} else if (reopening && !this.#writing) {
// Do not emit 'drain' if a 'ready' listener started a write:
// #release() will emit the real 'drain' when that write completes.
- process.nextTick(() => this.emit('drain'));
+ process.nextTick(() => this.#emitDrain());
}
};
@@ -551,16 +619,59 @@ class Utf8Stream extends EventEmitter {
}
}
+ #scheduleDrain() {
+ if (this.#sync) {
+ if (!this.#asyncDrainScheduled) {
+ this.#asyncDrainScheduled = true;
+ process.nextTick(() => this.#emitDrain());
+ }
+ } else {
+ this.#emitDrain();
+ }
+ }
+
#emitDrain() {
+ this.#asyncDrainScheduled = false;
+ if (this.#writing) return;
+ this.#emitFlush();
+ if (this.#writing || this.#destroyed) return;
const hasListeners = this.listenerCount('drain') > 0;
if (!hasListeners) return;
- this.#asyncDrainScheduled = false;
this.emit('drain');
}
+ #finishEnding() {
+ if (!this.#ending || this.#writing || this.#destroyed) return;
+ if (this.#len > 0) {
+ this.#actualWrite();
+ return;
+ }
+ if (this.#flushPending > 0) this.#emitFlush();
+ if (this.#flushPending === 0 && this.#flushInProgress === 0 &&
+ !this.#writing && this.#len === 0) {
+ this.#actualClose();
+ }
+ }
+
+ #emitFlush() {
+ if (this.#emittingFlush) return;
+ this.#emittingFlush = true;
+ try {
+ this.emit(kFlush);
+ } finally {
+ this.#emittingFlush = false;
+ }
+ }
+
#actualClose() {
+ if (this.#destroyed) return;
+
if (this.#fd === -1) {
- this.once('ready', () => this.#actualClose());
+ this.#destroyed = true;
+ this.once('ready', () => {
+ this.#destroyed = false;
+ this.#actualClose();
+ });
return;
}
@@ -636,7 +747,12 @@ class Utf8Stream extends EventEmitter {
}
}
- #flushBufferSync() {
+ /**
+ * Validates that flushSync() can run and determines how it must interact
+ * with any write already in progress.
+ * @returns {number} One of the kFlushSync* states.
+ */
+ #beginFlushSync() {
if (this.#destroyed) {
throw new ERR_INVALID_STATE('Utf8Stream is destroyed');
}
@@ -645,51 +761,114 @@ class Utf8Stream extends EventEmitter {
throw new ERR_INVALID_STATE('Invalid file descriptor');
}
- if (!this.#writing && this.#writingBuf.length > 0) {
- this.#bufs.unshift([this.#writingBuf]);
- this.#writingBuf = kEmptyBuffer;
+ // While reopening, #writing is set but no write is in flight and #fd still
+ // refers to the previous file, which is only closed once the new file is
+ // ready. Flush the buffered data to it.
+ if (!this.#writing || this.#opening) {
+ return kFlushSyncIdle;
}
- let buf = kEmptyBuffer;
- while (this.#bufs.length || buf.length) {
- if (buf.length <= 0) {
- buf = mergeBuf(this.#bufs[0], this.#lens[0]);
+ if (this.#retryTimer !== null) {
+ clearTimeout(this.#retryTimer);
+ this.#retryTimer = null;
+ return kFlushSyncRetry;
+ }
+
+ if (this.#emittingWrite) {
+ return kFlushSyncInWriteEvent;
+ }
+
+ return kFlushSyncInFlight;
+ }
+
+ /**
+ * Called after flushSync() has taken over a write whose retry timer it
+ * cancelled. Resumes whatever #release() would have done once that write
+ * completed.
+ */
+ #endFlushSyncRetry() {
+ this.#writing = false;
+ process.nextTick(() => {
+ if (this.#destroyed || this.#writing) return;
+ if (this.#reopening) {
+ this.#reopening = false;
+ this.reopen();
+ } else if (this.#ending) {
+ this.#finishEnding();
+ } else if (this.#len > 0 &&
+ (this.#len > this.#minLength || this.#flushPending)) {
+ this.#actualWrite();
+ } else {
+ this.#emitDrain();
}
- try {
- const n = this.#fs.writeSync(this.#fd, buf);
- this.#writeRetries = 0;
- buf = buf.subarray(n);
- this.#len = MathMax(this.#len - n, 0);
+ });
+ }
+
+ #flushBufferSync() {
+ const state = this.#beginFlushSync();
+
+ // Unless an fs.write() of #writingBuf is in flight, its unwritten
+ // remainder must be written first to preserve ordering.
+ if (state !== kFlushSyncInFlight && this.#writingBuf.length > 0) {
+ ArrayPrototypeUnshift(this.#bufs, [this.#writingBuf]);
+ ArrayPrototypeUnshift(this.#lens, this.#writingBuf.length);
+ this.#writingBuf = kEmptyBuffer;
+ }
+
+ try {
+ let buf = kEmptyBuffer;
+ while (this.#bufs.length || buf.length) {
if (buf.length <= 0) {
- this.#bufs.shift();
- this.#lens.shift();
- }
- } catch (err) {
- const shouldRetry = err.code === 'EAGAIN' || err.code === 'EBUSY';
- if (shouldRetry) {
- this.#writeRetries++;
- }
- const retriesExhausted = this.#maxWriteRetries > 0 && this.#writeRetries > this.#maxWriteRetries;
- if (!shouldRetry || retriesExhausted || !this.#retryEAGAIN(err, buf.length, this.#len - buf.length)) {
- throw err;
+ buf = mergeBuf(this.#bufs[0], this.#lens[0]);
}
+ try {
+ const n = this.#fs.writeSync(this.#fd, buf);
+ this.#writeRetries = 0;
+ buf = buf.subarray(n);
+ this.#len = MathMax(this.#len - n, 0);
+ if (buf.length <= 0) {
+ this.#bufs.shift();
+ this.#lens.shift();
+ }
+ } catch (err) {
+ const shouldRetry = err.code === 'EAGAIN' || err.code === 'EBUSY';
+ if (shouldRetry) {
+ this.#writeRetries++;
+ }
+ const retriesExhausted = this.#maxWriteRetries > 0 && this.#writeRetries > this.#maxWriteRetries;
+ if (!shouldRetry || retriesExhausted || !this.#retryEAGAIN(err, buf.length, this.#len - buf.length)) {
+ throw err;
+ }
- sleep(BUSY_WRITE_TIMEOUT);
+ sleep(BUSY_WRITE_TIMEOUT);
+ }
}
+ } finally {
+ if (state === kFlushSyncRetry) this.#endFlushSyncRetry();
}
}
#flushSyncUtf8() {
- if (this.#destroyed) {
- throw new ERR_INVALID_STATE('Utf8Stream is destroyed');
+ const state = this.#beginFlushSync();
+
+ try {
+ this.#flushSyncUtf8Bufs(state);
+ } finally {
+ if (state === kFlushSyncRetry) this.#endFlushSyncRetry();
}
- if (this.#fd < 0) {
- throw new ERR_INVALID_STATE('Invalid file descriptor');
+ try {
+ this.#fs.fsyncSync(this.#fd);
+ } catch {
+ // Skip the error. The fd might not support fsync.
}
+ }
- if (!this.#writing && this.#writingBuf.length > 0) {
- this.#bufs.unshift(this.#writingBuf);
+ #flushSyncUtf8Bufs(state) {
+ // Unless an fs.write() of #writingBuf is in flight, its unwritten
+ // remainder must be written first to preserve ordering.
+ if (state !== kFlushSyncInFlight && this.#writingBuf.length > 0) {
+ ArrayPrototypeUnshift(this.#bufs, this.#writingBuf);
this.#writingBuf = '';
}
@@ -720,46 +899,62 @@ class Utf8Stream extends EventEmitter {
sleep(BUSY_WRITE_TIMEOUT);
}
}
-
- try {
- this.#fs.fsyncSync(this.#fd);
- } catch {
- // Skip the error. The fd might not support fsync.
- }
}
#callFlushCallbackOnDrain(cb) {
- this.#flushPending = true;
+ this.#flushPending++;
+ let waiting = true;
+ let completed = false;
+ let flushing = false;
+ const stopWaiting = () => {
+ if (!waiting) return false;
+ waiting = false;
+ this.off(kFlush, onDrain);
+ this.off('error', onError);
+ return true;
+ };
+ const complete = (err, finish = true) => {
+ if (completed) return;
+ completed = true;
+ if (flushing) this.#flushInProgress--;
+ try {
+ cb(err);
+ } finally {
+ if (finish) this.#finishEnding();
+ }
+ };
const onDrain = () => {
+ if (!stopWaiting()) return;
+ this.#flushPending--;
+ this.#flushInProgress++;
+ flushing = true;
// Only if _fsync is false to avoid double fsync
if (!this.#fsync && !this.#destroyed) {
try {
this.#fs.fsync(this.#fd, (err) => {
- this.#flushPending = false;
- // If the fd is closed, we ignore the error.
- if (err?.code === 'EBADF') {
- cb();
+ // Ignore errors meaning the fd is closed or cannot be synced
+ // (e.g. stdout/stderr attached to a pipe or TTY). A regular
+ // file, including a redirected stdout/stderr, is still synced.
+ if (isIgnorableFsyncError(err)) {
+ complete();
return;
}
- cb(err);
+ complete(err);
});
} catch (err) {
- this.#flushPending = false;
- cb(err);
+ complete(err);
}
} else {
- this.#flushPending = false;
- cb();
+ complete();
}
- this.off('error', onError);
};
const onError = (err) => {
- this.#flushPending = false;
- cb(err);
- this.off('drain', onDrain);
+ if (!stopWaiting()) return;
+ this.#flushPending--;
+ complete(err, false);
};
- this.once('drain', onDrain);
+ this.once(kFlush, onDrain);
this.once('error', onError);
}
@@ -776,7 +971,7 @@ class Utf8Stream extends EventEmitter {
throw error;
}
- if (this.#minLength <= 0) {
+ if (this.#minLength <= 0 && !this.#writing && this.#len === 0) {
cb?.();
return;
}
@@ -810,7 +1005,7 @@ class Utf8Stream extends EventEmitter {
throw error;
}
- if (this.#minLength <= 0) {
+ if (this.#minLength <= 0 && !this.#writing && this.#len === 0) {
cb?.();
return;
}
diff --git a/test/parallel/test-fastutf8stream-flush-mocks.js b/test/parallel/test-fastutf8stream-flush-mocks.js
index 5d8e19fb1f9..3c6ad039cf4 100644
--- a/test/parallel/test-fastutf8stream-flush-mocks.js
+++ b/test/parallel/test-fastutf8stream-flush-mocks.js
@@ -3,9 +3,13 @@
const common = require('../common');
const tmpdir = require('../common/tmpdir');
const assert = require('node:assert');
+const { spawnSync } = require('node:child_process');
const {
+ closeSync,
openSync,
+ fsync,
fsyncSync,
+ readFileSync,
writeSync,
write,
} = require('node:fs');
@@ -13,6 +17,23 @@ const { join } = require('node:path');
const { Utf8Stream } = require('node:fs');
const { isMainThread } = require('node:worker_threads');
+if (process.argv[2] === 'child-stdout') {
+ // stdout is redirected to a regular file by the parent.
+ const stream = new Utf8Stream({
+ fd: 1,
+ minLength: 4096,
+ fs: {
+ fsync: common.mustCall((fd, cb) => {
+ assert.strictEqual(fd, 1);
+ fsync(fd, cb);
+ }),
+ },
+ });
+ stream.write('hello world\n');
+ stream.flush(common.mustSucceed());
+ return;
+}
+
tmpdir.refresh();
if (isMainThread) {
process.umask(0o000);
@@ -27,6 +48,38 @@ function getTempFile() {
runTests(false);
runTests(true);
+// Errors from fsync meaning the fd cannot be synchronized (e.g. a pipe or
+// TTY) or is already closed do not fail the flush.
+for (const code of ['EBADF', 'EINVAL', 'ENOTSUP', 'EOPNOTSUPP', 'EROFS']) {
+ const dest = getTempFile();
+ const fd = openSync(dest, 'w');
+ const fsOverride = {
+ fsync: common.mustCall((fd, cb) => {
+ const err = new Error(code);
+ err.code = code;
+ process.nextTick(cb, err);
+ }, 2),
+ };
+ const stream = new Utf8Stream({ fd, minLength: 4096, fs: fsOverride });
+
+ stream.on('ready', common.mustCall(() => {
+ assert.ok(stream.write('hello world\n'));
+ stream.flush(common.mustSucceed(() => stream.end()));
+ }));
+}
+
+// stdout redirected to a regular file is still fsynced by flush().
+{
+ const dest = getTempFile();
+ const fd = openSync(dest, 'w');
+ const child = spawnSync(process.execPath, [__filename, 'child-stdout'], {
+ stdio: ['ignore', fd, 'pipe'],
+ });
+ closeSync(fd);
+ assert.strictEqual(child.status, 0, child.stderr.toString());
+ assert.strictEqual(readFileSync(dest, 'utf8'), 'hello world\n');
+}
+
function runTests(sync) {
{
diff --git a/test/parallel/test-fastutf8stream-flush-sync.js b/test/parallel/test-fastutf8stream-flush-sync.js
index a48dac8e08e..6c7d728fec5 100644
--- a/test/parallel/test-fastutf8stream-flush-sync.js
+++ b/test/parallel/test-fastutf8stream-flush-sync.js
@@ -67,7 +67,7 @@ function runTests(sync) {
const stream = new Utf8Stream({
fd,
sync: false,
- minLength: 0,
+ minLength: 1000,
fs: fsOverride,
});
@@ -85,6 +85,156 @@ function runTests(sync) {
}));
}
+function toData(contentMode, text) {
+ return contentMode === 'buffer' ? Buffer.from(text) : text;
+}
+
+// flushSync() while an asynchronous write is in flight writes the queued
+// data without throwing, and the in-flight write still completes.
+for (const contentMode of ['utf8', 'buffer']) {
+ const dest = getTempFile();
+ const fd = openSync(dest, 'w');
+ let completeWrite;
+ const fsOverride = {
+ write: common.mustCall((fd, buf, ...args) => {
+ const cb = args.pop();
+ completeWrite = () => cb(null, writeSync(fd, buf));
+ }),
+ };
+ const stream = new Utf8Stream({
+ contentMode,
+ fd,
+ minLength: 0,
+ sync: false,
+ fs: fsOverride,
+ });
+
+ stream.on('ready', common.mustCall(() => {
+ assert.ok(stream.write(toData(contentMode, 'in flight\n')));
+ assert.strictEqual(stream.writing, true);
+ assert.ok(stream.write(toData(contentMode, 'queued\n')));
+ stream.flushSync();
+ assert.strictEqual(readFileSync(dest, 'utf8'), 'queued\n');
+
+ stream.flush(common.mustSucceed(() => {
+ stream.on('finish', common.mustCall(() => {
+ assert.strictEqual(readFileSync(dest, 'utf8'), 'queued\nin flight\n');
+ }));
+ stream.end();
+ }));
+ completeWrite();
+ }));
+}
+
+// flushSync() while waiting to retry an EAGAIN write cancels the retry and
+// writes everything synchronously, in order.
+for (const contentMode of ['utf8', 'buffer']) {
+ const dest = getTempFile();
+ const fd = openSync(dest, 'w');
+ const fsOverride = {
+ write: common.mustCallAtLeast((fd, buf, ...args) => {
+ const cb = args.pop();
+ const err = new Error('EAGAIN');
+ err.code = 'EAGAIN';
+ process.nextTick(cb, err);
+ }),
+ };
+ const stream = new Utf8Stream({
+ contentMode,
+ fd,
+ minLength: 0,
+ sync: false,
+ fs: fsOverride,
+ });
+
+ stream.on('ready', common.mustCall(() => {
+ assert.ok(stream.write(toData(contentMode, 'hello\n')));
+ // Wait for the EAGAIN to schedule the retry.
+ setImmediate(common.mustCall(() => {
+ assert.strictEqual(stream.writing, true);
+ assert.ok(stream.write(toData(contentMode, 'world\n')));
+ // A pending flush() completes once flushSync() takes over the write.
+ stream.flush(common.mustSucceed(() => {
+ stream.on('finish', common.mustCall(() => {
+ assert.strictEqual(readFileSync(dest, 'utf8'), 'hello\nworld\n');
+ }));
+ stream.end();
+ }));
+ stream.flushSync();
+ assert.strictEqual(stream.writing, false);
+ assert.strictEqual(readFileSync(dest, 'utf8'), 'hello\nworld\n');
+ }));
+ }));
+}
+
+// flushSync() from a 'write' listener writes the unwritten remainder of a
+// partial write before newly queued data.
+for (const sync of [true, false]) {
+ for (const contentMode of ['utf8', 'buffer']) {
+ const dest = getTempFile();
+ const fd = openSync(dest, 'w');
+ let partial = true;
+ const partialWrite = (fd, buf) => {
+ if (partial) {
+ partial = false;
+ return writeSync(fd, Buffer.from(buf).subarray(0, 5));
+ }
+ return writeSync(fd, buf);
+ };
+ const fsOverride = {
+ writeSync: (fd, buf, ...args) => partialWrite(fd, buf),
+ write: (fd, buf, ...args) => {
+ const cb = args.pop();
+ process.nextTick(cb, null, partialWrite(fd, buf));
+ },
+ };
+ const stream = new Utf8Stream({
+ contentMode,
+ fd,
+ minLength: 0,
+ sync,
+ fs: fsOverride,
+ });
+
+ stream.once('write', common.mustCall((n) => {
+ assert.strictEqual(n, 5);
+ assert.strictEqual(stream.writing, true);
+ assert.ok(stream.write(toData(contentMode, 'next\n')));
+ stream.flushSync();
+ assert.strictEqual(readFileSync(dest, 'utf8'), 'hello world\nnext\n');
+ }));
+
+ stream.on('ready', common.mustCall(() => {
+ assert.ok(stream.write(toData(contentMode, 'hello world\n')));
+ stream.on('finish', common.mustCall(() => {
+ assert.strictEqual(readFileSync(dest, 'utf8'), 'hello world\nnext\n');
+ }));
+ stream.end();
+ }));
+ }
+}
+
+// flushSync() while reopening writes the buffered data to the previous file,
+// which stays open until the new file is ready.
+{
+ const dest = getTempFile();
+ const stream = new Utf8Stream({ dest, minLength: 4096, sync: false });
+
+ stream.once('ready', common.mustCall(() => {
+ assert.ok(stream.write('before reopen\n'));
+ stream.reopen();
+ assert.strictEqual(stream.writing, true);
+ stream.flushSync();
+ assert.strictEqual(readFileSync(dest, 'utf8'), 'before reopen\n');
+ stream.once('ready', common.mustCall(() => {
+ stream.on('finish', common.mustCall(() => {
+ assert.strictEqual(readFileSync(dest, 'utf8'), 'before reopen\n');
+ }));
+ stream.end();
+ }));
+ }));
+}
+
{
const dest = getTempFile();
const fd = openSync(dest, 'w');
diff --git a/test/parallel/test-fastutf8stream-flush.js b/test/parallel/test-fastutf8stream-flush.js
index 60238ed7690..14d77166c4c 100644
--- a/test/parallel/test-fastutf8stream-flush.js
+++ b/test/parallel/test-fastutf8stream-flush.js
@@ -53,6 +53,173 @@ runTests(true);
stream.on('ready', common.mustCall());
}
+{
+ const dest = getTempFile();
+ const fd = openSync(dest, 'w');
+ const stream = new Utf8Stream({ fd, minLength: 10, sync: false });
+ let flushed = false;
+
+ assert.ok(stream.write('asynchronous flush\n'));
+ assert.ok(stream.write('queued\n'));
+ assert.strictEqual(stream.writing, true);
+ stream.flush(common.mustSucceed(() => {
+ flushed = true;
+ assert.strictEqual(stream.writing, false);
+ readFile(dest, 'utf8', common.mustSucceed((data) => {
+ assert.strictEqual(data, 'asynchronous flush\nqueued\n');
+ stream.end();
+ }));
+ }));
+ assert.strictEqual(flushed, false);
+}
+
+{
+ const dest = getTempFile();
+ const fd = openSync(dest, 'w');
+ const stream = new Utf8Stream({ fd, minLength: 0, sync: false });
+
+ assert.ok(stream.write('flush before end\n'));
+ stream.flush(common.mustSucceed());
+ stream.on('finish', common.mustCall(() => {
+ readFile(dest, 'utf8', common.mustSucceed((data) => {
+ assert.strictEqual(data, 'flush before end\n');
+ }));
+ }));
+ stream.end();
+}
+
+{
+ const dest = getTempFile();
+ const fd = openSync(dest, 'w');
+ const writeError = new Error('write failed');
+ const stream = new Utf8Stream({
+ fd,
+ minLength: 0,
+ sync: false,
+ fs: {
+ write: common.mustCall((_fd, _data, _encoding, callback) => {
+ process.nextTick(callback, writeError);
+ }),
+ },
+ });
+
+ stream.on('error', common.mustCall((error) => {
+ assert.strictEqual(error, writeError);
+ }));
+ assert.ok(stream.write('failed write before end\n'));
+ stream.flush(common.mustCall((error) => {
+ assert.strictEqual(error, writeError);
+ }));
+ stream.end();
+}
+
+{
+ const dest = getTempFile();
+ const fd = openSync(dest, 'w');
+ let fsyncCalls = 0;
+ const stream = new Utf8Stream({
+ fd,
+ minLength: 10,
+ sync: false,
+ fs: {
+ fsync: common.mustCall((_fd, callback) => {
+ fsyncCalls++;
+ if (fsyncCalls === 1) {
+ assert.ok(stream.write('late\n'));
+ }
+ process.nextTick(callback);
+ }, 2),
+ },
+ });
+
+ assert.ok(stream.write('initial write\n'));
+ stream.flush(common.mustSucceed());
+ stream.on('finish', common.mustCall(() => {
+ readFile(dest, 'utf8', common.mustSucceed((data) => {
+ assert.strictEqual(data, 'initial write\nlate\n');
+ }));
+ }));
+ stream.end();
+}
+
+{
+ const dest = getTempFile();
+ const fd = openSync(dest, 'w');
+ const flushError = new Error('flush failed');
+ let fsyncCalls = 0;
+ const stream = new Utf8Stream({
+ fd,
+ minLength: 0,
+ sync: false,
+ fs: {
+ fsync: common.mustCall((_fd, callback) => {
+ fsyncCalls++;
+ process.nextTick(callback, fsyncCalls === 1 ? flushError : null);
+ }, 2),
+ },
+ });
+
+ assert.ok(stream.write('failed flush before end\n'));
+ stream.flush(common.mustCall((error) => {
+ assert.strictEqual(error, flushError);
+ }));
+ stream.on('close', common.mustCall());
+ stream.end();
+}
+
+{
+ const dest = getTempFile();
+ const stream = new Utf8Stream({
+ dest,
+ fs: {
+ open(...args) {
+ setImmediate(() => open(...args));
+ },
+ },
+ });
+
+ stream.flush(common.mustSucceed());
+ stream.on('close', common.mustCall());
+ stream.end();
+}
+
+{
+ const dest = getTempFile();
+ const stream = new Utf8Stream({
+ dest,
+ fs: {
+ open(...args) {
+ setImmediate(() => open(...args));
+ },
+ },
+ });
+
+ stream.flush(common.mustCall((error) => {
+ assert.strictEqual(error?.code, 'ERR_INVALID_STATE');
+ }));
+ stream.on('close', common.mustCall());
+ stream.destroy();
+ stream.flush(common.mustCall((error) => {
+ assert.strictEqual(error?.code, 'ERR_INVALID_STATE');
+ }));
+}
+
+{
+ const dest = getTempFile();
+ const stream = new Utf8Stream({
+ dest,
+ fs: {
+ open(...args) {
+ setImmediate(() => open(...args));
+ },
+ },
+ });
+
+ stream.on('close', common.mustCall());
+ stream.end();
+ stream.destroy();
+}
+
function runTests(sync) {
{
const dest = getTempFile();
diff --git a/test/parallel/test-fastutf8stream-periodicflush.js b/test/parallel/test-fastutf8stream-periodicflush.js
index f7029a21350..4889fa8b19e 100644
--- a/test/parallel/test-fastutf8stream-periodicflush.js
+++ b/test/parallel/test-fastutf8stream-periodicflush.js
@@ -75,4 +75,56 @@ function runTests(sync) {
stream.destroy();
}
+
+ {
+ const dest = getTempFile();
+ const fd = openSync(dest, 'w');
+ const stream = new Utf8Stream({
+ fd,
+ sync,
+ minLength: 5000,
+ periodicFlush: common.platformTimeout(10),
+ });
+ const timeout = setTimeout(
+ common.mustNotCall('periodic flush did not complete'),
+ common.platformTimeout(1000),
+ );
+
+ stream.once('drain', common.mustCall(() => {
+ clearTimeout(timeout);
+ stream.destroy();
+ readFile(dest, 'utf8', common.mustSucceed((data) => {
+ assert.strictEqual(data, 'periodic flush\n');
+ }));
+ }));
+ assert.ok(stream.write('periodic flush\n'));
+ }
+
+ if (!sync) {
+ const dest = getTempFile();
+ const fd = openSync(dest, 'w');
+ let fsyncCalls = 0;
+ const stream = new Utf8Stream({
+ fd,
+ minLength: 5000,
+ periodicFlush: common.platformTimeout(10),
+ fs: {
+ fsync: common.mustCall((_fd, callback) => {
+ fsyncCalls++;
+ if (fsyncCalls === 1) {
+ setTimeout(common.mustCall(() => {
+ assert.strictEqual(fsyncCalls, 1);
+ stream.destroy();
+ callback();
+ }), common.platformTimeout(50));
+ } else {
+ process.nextTick(callback);
+ }
+ }, 2),
+ },
+ });
+
+ stream.on('close', common.mustCall());
+ assert.ok(stream.write('slow fsync\n'));
+ }
}