Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions patches/node/.patches
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,4 @@ src_refactor_wasmstreaming_finish_to_accept_a_callback.patch
src_stop_using_v8_propertycallbackinfo_t_this.patch
build_restore_macos_deployment_target_to_12_0.patch
fix_add_externalpointertypetag_to_v8_external_api_calls.patch
fs_skip_native_write_for_zero-length_callback_writefile.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Caner Altinbasak <cal@brightsign.biz>
Date: Thu, 25 Jun 2026 10:51:20 +0100
Subject: fs: skip native write for zero-length callback writeFile()

Callback fs.writeFile()/writeAll() always issued a native
fs.write(fd, buffer, 0, 0, ...) syscall even when the data was empty.
On the Electron-based roHtmlWidget Node integration that zero-length
write returns "EFAULT: bad address in system call argument, write",
whereas it succeeds under the standalone roNodeJs runtime and under
fs/promises.writeFile().

Add a zero-length guard to writeAll() that skips the syscall and runs
the normal completion path (optional fsync, close the fd only when
writeFile() opened it, invoke callback(null)), matching the early
return already present in fs/promises.writeFile(). Non-empty writes,
validation, abort/signal handling and caller-owned fd semantics are
unchanged.

diff --git a/lib/fs.js b/lib/fs.js
index 6e688375f48d2d6a12e0af6ac0c94c84b9c3c9f4..05c0b69e68cd26a862153dd3cf10cc94d7c0da32 100644
--- a/lib/fs.js
+++ b/lib/fs.js
@@ -2283,6 +2283,41 @@ function writeAll(fd, isUserFd, buffer, offset, length, signal, flush, callback)
}
return;
}
+ // Shared completion path: optionally flush, close the fd if writeFile()
+ // opened it internally, and invoke the callback.
+ const onWriteSucceeded = () => {
+ if (!flush) {
+ if (isUserFd) {
+ callback(null);
+ } else {
+ fs.close(fd, callback);
+ }
+ } else {
+ fs.fsync(fd, (syncErr) => {
+ if (syncErr) {
+ if (isUserFd) {
+ callback(syncErr);
+ } else {
+ fs.close(fd, (err) => {
+ callback(aggregateTwoErrors(err, syncErr));
+ });
+ }
+ } else if (isUserFd) {
+ callback(null);
+ } else {
+ fs.close(fd, callback);
+ }
+ });
+ }
+ };
+ // A zero-length write would still issue a native fs.write(fd, buffer, 0, 0,
+ // ...) syscall, which returns EFAULT on some runtimes (notably the
+ // Electron-based roHtmlWidget Node integration). Skip the syscall and finish
+ // as a successful 0-byte write, matching fs/promises.writeFile().
+ if (length === 0) {
+ onWriteSucceeded();
+ return;
+ }
// write(fd, buffer, offset, length, position, callback)
fs.write(fd, buffer, offset, length, null, (writeErr, written) => {
if (writeErr) {
@@ -2294,29 +2329,7 @@ function writeAll(fd, isUserFd, buffer, offset, length, signal, flush, callback)
});
}
} else if (written === length) {
- if (!flush) {
- if (isUserFd) {
- callback(null);
- } else {
- fs.close(fd, callback);
- }
- } else {
- fs.fsync(fd, (syncErr) => {
- if (syncErr) {
- if (isUserFd) {
- callback(syncErr);
- } else {
- fs.close(fd, (err) => {
- callback(aggregateTwoErrors(err, syncErr));
- });
- }
- } else if (isUserFd) {
- callback(null);
- } else {
- fs.close(fd, callback);
- }
- });
- }
+ onWriteSucceeded();
} else {
offset += written;
length -= written;
109 changes: 109 additions & 0 deletions spec/node-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,115 @@ describe('node feature', () => {
);
});

// Regression test for a zero-length callback fs.writeFile() that failed
// with "EFAULT: bad address in system call argument, write" only in the
// renderer (nodeIntegration / roHtmlWidget) Node integration, while it
// worked in a standalone Node process and via fs/promises.writeFile().
// Callback fs.writeFile() now skips the native zero-length write, matching
// the early return already present in fs/promises.writeFile().
describe('zero-length fs.writeFile in renderer', () => {
itremote('fs.writeFile(path, "", cb) succeeds and creates an empty file', async () => {
const fs = require('node:fs');
const path = require('node:path');
const file = path.join(require('node:os').tmpdir(), `electron-fswrite-empty-string-${Date.now()}`);
try {
await new Promise<void>((resolve, reject) => {
fs.writeFile(file, '', (err: Error | null) => (err ? reject(err) : resolve()));
});
expect(fs.statSync(file).size).to.equal(0);
} finally {
try {
fs.unlinkSync(file);
} catch {
/* ignore */
}
}
});

itremote('fs.writeFile(path, Buffer.alloc(0), cb) succeeds', async () => {
const fs = require('node:fs');
const path = require('node:path');
const file = path.join(require('node:os').tmpdir(), `electron-fswrite-empty-buffer-${Date.now()}`);
try {
await new Promise<void>((resolve, reject) => {
fs.writeFile(file, Buffer.alloc(0), (err: Error | null) => (err ? reject(err) : resolve()));
});
expect(fs.statSync(file).size).to.equal(0);
} finally {
try {
fs.unlinkSync(file);
} catch {
/* ignore */
}
}
});

itremote('fs.writeFile(path, "1", cb) still writes non-empty data', async () => {
const fs = require('node:fs');
const path = require('node:path');
const file = path.join(require('node:os').tmpdir(), `electron-fswrite-non-empty-${Date.now()}`);
try {
await new Promise<void>((resolve, reject) => {
fs.writeFile(file, '1', (err: Error | null) => (err ? reject(err) : resolve()));
});
expect(fs.readFileSync(file, 'utf-8')).to.equal('1');
} finally {
try {
fs.unlinkSync(file);
} catch {
/* ignore */
}
}
});

itremote('fs.writeFile(path, "", cb) truncates an existing file', async () => {
const fs = require('node:fs');
const path = require('node:path');
const file = path.join(require('node:os').tmpdir(), `electron-fswrite-truncate-${Date.now()}`);
try {
fs.writeFileSync(file, 'non-empty contents');
expect(fs.statSync(file).size).to.be.greaterThan(0);
await new Promise<void>((resolve, reject) => {
fs.writeFile(file, '', (err: Error | null) => (err ? reject(err) : resolve()));
});
expect(fs.statSync(file).size).to.equal(0);
} finally {
try {
fs.unlinkSync(file);
} catch {
/* ignore */
}
}
});

itremote('fs.writeFile(fd, Buffer.alloc(0), cb) succeeds without closing a caller-owned fd', async () => {
const fs = require('node:fs');
const path = require('node:path');
const file = path.join(require('node:os').tmpdir(), `electron-fswrite-user-fd-${Date.now()}`);
const fd = fs.openSync(file, 'w');
try {
await new Promise<void>((resolve, reject) => {
fs.writeFile(fd, Buffer.alloc(0), (err: Error | null) => (err ? reject(err) : resolve()));
});
// If writeFile had closed our fd this write would throw EBADF.
fs.writeSync(fd, 'still open');
fs.closeSync(fd);
expect(fs.readFileSync(file, 'utf-8')).to.equal('still open');
} finally {
try {
fs.closeSync(fd);
} catch {
/* already closed */
}
try {
fs.unlinkSync(file);
} catch {
/* ignore */
}
}
});
});

describe('error thrown in renderer process node context', () => {
itremote(
'gets emitted as a process uncaughtException event',
Expand Down
Loading