Skip to content

Commit 9fb687f

Browse files
committed
fs: write files in one thread pool round trip
fs.writeFile(path, data) took three libuv thread pool round trips (open, write, close), each its own request with its own queue wait, completion callback and JS/C++ crossing, and fs.promises.writeFile() did the same through a FileHandle. For the small files applications write most, the round trips are the cost, and each occupies a pool slot that concurrent fs, dns.lookup() and crypto work is also queueing for. Add WriteFileJob next to ReadFileJob: an AsyncWrap + ThreadPoolWork that opens, writes the whole buffer (looping on short writes) and closes as one pool task, keeping the buffer alive until it is done. fs.writeFile() uses it for path arguments without flush; fs.promises.writeFile() additionally keeps data above one write chunk (and iterables) on the FileHandle path, so large writes stay abortable between chunks as before. File descriptors, FileHandles, flush: true and an active VFS keep their existing paths. Behavior is otherwise kept: open failures report syscall 'open' with the path, write failures 'write'; permission errors are delivered through the callback/promise; an abort signalled while the write is in flight is still reported as an AbortError; the job is an FSREQCALLBACK resource for async_hooks and emits the 'write' fs trace event. Tests that used fs.writeFile() as a proxy for open/close trace events, or injected FileHandle faults for path-based writes, are adjusted to keep testing what they test. Signed-off-by: Shelley Vohr <shelley.vohr@gmail.com>
1 parent 21f0f27 commit 9fb687f

8 files changed

Lines changed: 282 additions & 6 deletions

lib/fs.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ const {
8282
const {
8383
FSReqCallback,
8484
ReadFileJob,
85+
WriteFileJob,
8586
} = binding;
8687
const { toPathIfFileURL } = require('internal/url');
8788
const {
@@ -2924,6 +2925,23 @@ function writeFile(path, data, options, callback) {
29242925
if (checkAborted(options.signal, callback))
29252926
return;
29262927

2928+
if (!flush) {
2929+
// Open + write + close in one thread pool round trip.
2930+
const signal = options.signal;
2931+
path = getValidatedPath(path);
2932+
const job = new WriteFileJob(path, stringToFlags(flag, 'options.flag'),
2933+
parseFileMode(options.mode, 'mode', 0o666), data);
2934+
job.ondone = signal == null ? callback : (err) => {
2935+
// An abort that arrived while the write was in flight still wins.
2936+
callback(signal.aborted && !err ? new AbortError(undefined, { cause: signal.reason }) : err);
2937+
};
2938+
const accessError = job.run(path);
2939+
if (accessError !== undefined) {
2940+
callback(accessError);
2941+
}
2942+
return;
2943+
}
2944+
29272945
fs.open(path, flag, options.mode, (openErr, fd) => {
29282946
if (openErr) {
29292947
callback(openErr);

lib/internal/fs/promises.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2107,6 +2107,14 @@ async function writeFile(path, data, options) {
21072107

21082108
checkAborted(options.signal);
21092109

2110+
if (!flush && !isCustomIterable(data) && data.byteLength <= kWriteFileMaxChunkSize) {
2111+
path = getValidatedPath(path);
2112+
await writeFileInOneRoundTrip(path, stringToFlags(flag, 'options.flag'),
2113+
parseFileMode(options.mode, 'mode', 0o666), data);
2114+
checkAborted(options.signal); // An abort during the write still wins.
2115+
return;
2116+
}
2117+
21102118
const fd = await open(path, flag, options.mode);
21112119
let writeOp = writeFileHandle(fd, data, options.signal, options.encoding);
21122120

@@ -2117,6 +2125,33 @@ async function writeFile(path, data, options) {
21172125
return handleFdClose(writeOp, fd.close);
21182126
}
21192127

2128+
/**
2129+
* Open + write + close as one thread pool round trip.
2130+
* @param {string|Buffer} path Validated path
2131+
* @param {number} flagsNumber
2132+
* @param {number} mode
2133+
* @param {ArrayBufferView} data
2134+
* @returns {Promise<void>}
2135+
*/
2136+
function writeFileInOneRoundTrip(path, flagsNumber, mode, data) {
2137+
return new Promise((resolve, reject) => {
2138+
const job = new binding.WriteFileJob(path, flagsNumber, mode, data);
2139+
job.ondone = (err) => {
2140+
if (err != null) {
2141+
ErrorCaptureStackTrace(err, writeFileInOneRoundTrip);
2142+
reject(err);
2143+
} else {
2144+
resolve();
2145+
}
2146+
};
2147+
const accessError = job.run(path);
2148+
if (accessError !== undefined) {
2149+
ErrorCaptureStackTrace(accessError, writeFileInOneRoundTrip);
2150+
reject(accessError);
2151+
}
2152+
});
2153+
}
2154+
21202155
function isCustomIterable(obj) {
21212156
return isIterable(obj) && !isArrayBufferView(obj) && typeof obj !== 'string';
21222157
}

src/node_file.cc

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ namespace node {
6464
namespace fs {
6565

6666
using v8::Array;
67+
using v8::ArrayBufferView;
6768
using v8::BigInt;
6869
using v8::Context;
6970
using v8::EscapableHandleScope;
@@ -3154,6 +3155,7 @@ class ReadFileJob final : public AsyncWrap, public ThreadPoolWork {
31543155
SET_SELF_SIZE(ReadFileJob)
31553156

31563157
private:
3158+
friend class WriteFileJob;
31573159
static constexpr size_t kUnknownSizeChunk = 64 * 1024;
31583160
static constexpr size_t kMaxReadChunk = 256 * 1024 * 1024;
31593161

@@ -3237,6 +3239,147 @@ class ReadFileJob final : public AsyncWrap, public ThreadPoolWork {
32373239
int fd_ = -1;
32383240
};
32393241

3242+
// Writes a whole buffer to a file in ONE thread pool round trip -- open +
3243+
// write (until everything is written) + close -- for fs.writeFile() and
3244+
// fs.promises.writeFile() with a path, which otherwise pay one round trip per
3245+
// step.
3246+
//
3247+
// JS: const job = new WriteFileJob(path, flags, mode, buffer);
3248+
// job.ondone = (err) => {...}; job.run(path);
3249+
// `err` carries the syscall that failed ('open', 'write' or 'close'); the file
3250+
// descriptor opened here is always closed.
3251+
class WriteFileJob final : public AsyncWrap, public ThreadPoolWork {
3252+
public:
3253+
static void New(const FunctionCallbackInfo<Value>& args) {
3254+
CHECK(args.IsConstructCall());
3255+
Environment* env = Environment::GetCurrent(args);
3256+
CHECK_GE(args.Length(), 4);
3257+
BufferValue path(env->isolate(), args[0]);
3258+
CHECK_NOT_NULL(*path);
3259+
ToNamespacedPath(env, &path);
3260+
CHECK(args[1]->IsInt32());
3261+
CHECK(args[2]->IsInt32());
3262+
CHECK(args[3]->IsArrayBufferView());
3263+
new WriteFileJob(env,
3264+
args.This(),
3265+
path.ToString(),
3266+
args[1].As<Int32>()->Value(),
3267+
args[2].As<Int32>()->Value(),
3268+
args[3].As<ArrayBufferView>());
3269+
}
3270+
3271+
// Returns undefined when the job was scheduled, or the ERR_ACCESS_DENIED
3272+
// error the asynchronous open() would have delivered (nothing is scheduled).
3273+
static void Run(const FunctionCallbackInfo<Value>& args) {
3274+
WriteFileJob* job;
3275+
ASSIGN_OR_RETURN_UNWRAP(&job, args.This());
3276+
Environment* env = job->AsyncWrap::env();
3277+
CHECK(!job->scheduled_);
3278+
BufferValue path(env->isolate(), args[0]);
3279+
CHECK_NOT_NULL(*path);
3280+
ToNamespacedPath(env, &path);
3281+
Local<Value> access_error;
3282+
if (ReadFileJob::OpenPermissionError(env, path, job->flags_)
3283+
.ToLocal(&access_error)) {
3284+
args.GetReturnValue().Set(access_error);
3285+
return;
3286+
}
3287+
job->scheduled_ = true;
3288+
job->ClearWeak();
3289+
FS_ASYNC_TRACE_BEGIN0(UV_FS_WRITE, job)
3290+
job->ScheduleWork();
3291+
}
3292+
3293+
void DoThreadPoolWork() override {
3294+
uv_fs_t req;
3295+
int fd = uv_fs_open(nullptr, &req, path_.c_str(), flags_, mode_, nullptr);
3296+
uv_fs_req_cleanup(&req);
3297+
if (fd < 0) return Fail("open", fd);
3298+
3299+
size_t written = 0;
3300+
while (written < length_) {
3301+
uv_buf_t buf = uv_buf_init(data_ + written,
3302+
static_cast<unsigned int>(std::min<size_t>(
3303+
length_ - written, kMaxWriteChunk)));
3304+
int r = uv_fs_write(nullptr, &req, fd, &buf, 1, -1, nullptr);
3305+
uv_fs_req_cleanup(&req);
3306+
if (r < 0) {
3307+
Fail("write", r);
3308+
break;
3309+
}
3310+
written += static_cast<size_t>(r);
3311+
}
3312+
3313+
int rc = uv_fs_close(nullptr, &req, fd, nullptr);
3314+
uv_fs_req_cleanup(&req);
3315+
if (rc < 0 && error_ == 0) Fail("close", rc);
3316+
}
3317+
3318+
void AfterThreadPoolWork(int status) override {
3319+
Environment* env = AsyncWrap::env();
3320+
std::unique_ptr<WriteFileJob> self(this);
3321+
CHECK(status == 0 || status == UV_ECANCELED);
3322+
FS_ASYNC_TRACE_END0(UV_FS_WRITE, this)
3323+
if (status == UV_ECANCELED || !env->can_call_into_js()) return;
3324+
HandleScope handle_scope(env->isolate());
3325+
Context::Scope context_scope(env->context());
3326+
Isolate* isolate = env->isolate();
3327+
Local<Value> argv[1] = {Null(isolate)};
3328+
if (error_ != 0) {
3329+
argv[0] = UVException(isolate,
3330+
error_,
3331+
syscall_,
3332+
nullptr,
3333+
syscall_ == kOpen ? path_.c_str() : nullptr);
3334+
}
3335+
MakeCallback(env->ondone_string(), arraysize(argv), argv);
3336+
}
3337+
3338+
bool IsNotIndicativeOfMemoryLeakAtExit() const override { return true; }
3339+
SET_NO_MEMORY_INFO()
3340+
SET_MEMORY_INFO_NAME(WriteFileJob)
3341+
SET_SELF_SIZE(WriteFileJob)
3342+
3343+
private:
3344+
static constexpr size_t kMaxWriteChunk = 256 * 1024 * 1024;
3345+
static constexpr const char* kOpen = "open";
3346+
3347+
WriteFileJob(Environment* env,
3348+
Local<Object> object,
3349+
std::string&& path,
3350+
int flags,
3351+
int mode,
3352+
Local<ArrayBufferView> view)
3353+
: AsyncWrap(env, object, AsyncWrap::PROVIDER_FSREQCALLBACK),
3354+
ThreadPoolWork(env, "fs.writefile"),
3355+
path_(std::move(path)),
3356+
flags_(flags),
3357+
mode_(mode) {
3358+
// Keep the buffer alive (and its backing store in place) until done.
3359+
buffer_.Reset(env->isolate(), view);
3360+
backing_store_ = view->Buffer()->GetBackingStore();
3361+
data_ = static_cast<char*>(backing_store_->Data()) + view->ByteOffset();
3362+
length_ = view->ByteLength();
3363+
MakeWeak();
3364+
}
3365+
3366+
void Fail(const char* syscall, int error) {
3367+
syscall_ = syscall;
3368+
error_ = error;
3369+
}
3370+
3371+
const std::string path_;
3372+
v8::Global<v8::ArrayBufferView> buffer_;
3373+
std::shared_ptr<v8::BackingStore> backing_store_;
3374+
char* data_ = nullptr;
3375+
size_t length_ = 0;
3376+
const int flags_;
3377+
const int mode_;
3378+
bool scheduled_ = false;
3379+
int error_ = 0;
3380+
const char* syscall_ = nullptr;
3381+
};
3382+
32403383
// Wrapper for readv(2).
32413384
//
32423385
// bytesRead = fs.readv(fd, buffers[, position], callback)
@@ -4553,6 +4696,13 @@ static void CreatePerIsolateProperties(IsolateData* isolate_data,
45534696
SetProtoMethod(isolate, rfj, "run", ReadFileJob::Run);
45544697
SetConstructorFunction(isolate, target, "ReadFileJob", rfj);
45554698

4699+
Local<FunctionTemplate> wfj = NewFunctionTemplate(isolate, WriteFileJob::New);
4700+
wfj->InstanceTemplate()->SetInternalFieldCount(
4701+
WriteFileJob::kInternalFieldCount);
4702+
wfj->Inherit(AsyncWrap::GetConstructorTemplate(isolate_data));
4703+
SetProtoMethod(isolate, wfj, "run", WriteFileJob::Run);
4704+
SetConstructorFunction(isolate, target, "WriteFileJob", wfj);
4705+
45564706
// Create FunctionTemplate for FSReqCallback
45574707
Local<FunctionTemplate> fst = NewFunctionTemplate(isolate, NewFSReqCallback);
45584708
fst->InstanceTemplate()->SetInternalFieldCount(
@@ -4626,6 +4776,8 @@ void RegisterExternalReferences(ExternalReferenceRegistry* registry) {
46264776
registry->Register(Open);
46274777
registry->Register(ReadFileJob::New);
46284778
registry->Register(ReadFileJob::Run);
4779+
registry->Register(WriteFileJob::New);
4780+
registry->Register(WriteFileJob::Run);
46294781
registry->Register(OpenFileHandle);
46304782
registry->Register(Read);
46314783
registry->Register(ReadFileUtf8);

test/parallel/test-fs-promises-file-handle-aggregate-errors.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,9 @@ async function checkAggregateError(op) {
6767
tmpdir.refresh();
6868
await checkAggregateError((filePath) => truncate(filePath));
6969
await checkAggregateError((filePath) => readFile(filePath));
70-
await checkAggregateError((filePath) => writeFile(filePath, '123'));
70+
// More than one write chunk (512 KiB), so that writeFile(path) goes through
71+
// a FileHandle as well.
72+
await checkAggregateError((filePath) => writeFile(filePath, '123'.repeat(200_000)));
7173
if (common.isMacOS) {
7274
await checkAggregateError((filePath) => lchmod(filePath, 0o777));
7375
}

test/parallel/test-fs-promises-file-handle-close-errors.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,9 @@ async function checkCloseError(op) {
6262
tmpdir.refresh();
6363
await checkCloseError((filePath) => truncate(filePath));
6464
await checkCloseError((filePath) => readFile(filePath));
65-
await checkCloseError((filePath) => writeFile(filePath, '123'));
65+
// More than one write chunk (512 KiB), so that writeFile(path) goes through
66+
// a FileHandle as well.
67+
await checkCloseError((filePath) => writeFile(filePath, '123'.repeat(200_000)));
6668
if (common.isMacOS) {
6769
await checkCloseError((filePath) => lchmod(filePath, 0o777));
6870
}

test/parallel/test-fs-promises-file-handle-op-errors.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,9 @@ async function checkOperationError(op) {
5656
tmpdir.refresh();
5757
await checkOperationError((filePath) => truncate(filePath));
5858
await checkOperationError((filePath) => readFile(filePath));
59-
await checkOperationError((filePath) => writeFile(filePath, '123'));
59+
// More than one write chunk (512 KiB), so that writeFile(path) goes through
60+
// a FileHandle as well.
61+
await checkOperationError((filePath) => writeFile(filePath, '123'.repeat(200_000)));
6062
if (common.isMacOS) {
6163
await checkOperationError((filePath) => lchmod(filePath, 0o777));
6264
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
'use strict';
2+
// fs.writeFile() and fs.promises.writeFile() with a path perform
3+
// open + write + close as one thread pool request. This covers what that
4+
// request must keep doing: honor flags and mode, append, report the
5+
// failing syscall, accept every ArrayBufferView, and write buffers larger
6+
// than one write() call in full.
7+
const common = require('../common');
8+
const tmpdir = require('../common/tmpdir');
9+
const assert = require('assert');
10+
const fs = require('fs');
11+
const path = require('path');
12+
13+
tmpdir.refresh();
14+
let counter = 0;
15+
const next = () => tmpdir.resolve(`file-${counter++}`);
16+
17+
async function check(write) {
18+
{
19+
const file = next();
20+
await write(file, 'hello');
21+
await write(file, ' world', { flag: 'a' });
22+
assert.strictEqual(fs.readFileSync(file, 'utf8'), 'hello world');
23+
await assert.rejects(write(file, 'again', { flag: 'wx' }), { code: 'EEXIST', syscall: 'open', path: file });
24+
}
25+
{
26+
const file = path.join(next(), 'missing-dir', 'file');
27+
await assert.rejects(write(file, 'x'), { code: 'ENOENT', syscall: 'open', path: file });
28+
}
29+
{
30+
const file = next();
31+
await write(file, '');
32+
assert.strictEqual(fs.statSync(file).size, 0);
33+
}
34+
if (!common.isWindows) {
35+
const file = next();
36+
const mask = process.umask(0o022);
37+
await write(file, 'x', { mode: 0o640 });
38+
process.umask(mask);
39+
assert.strictEqual(fs.statSync(file).mode & 0o777, 0o640);
40+
}
41+
{
42+
const file = next();
43+
const units = new Uint16Array([0x6968]);
44+
await write(file, units);
45+
await write(file, new DataView(new TextEncoder().encode('!?').buffer, 1, 1), { flag: 'a' });
46+
assert.deepStrictEqual(fs.readFileSync(file),
47+
Buffer.concat([Buffer.from(units.buffer), Buffer.from('?')]));
48+
}
49+
{
50+
const file = next();
51+
const big = Buffer.alloc(3 * 1024 * 1024 + 7, 'z');
52+
await write(file, big);
53+
assert.deepStrictEqual(fs.readFileSync(file), big);
54+
}
55+
}
56+
57+
(async () => {
58+
await check((file, data, options) => new Promise((resolve, reject) => {
59+
fs.writeFile(file, data, options, (err) => (err ? reject(err) : resolve()));
60+
}));
61+
await check(fs.promises.writeFile);
62+
})().then(common.mustCall());

test/parallel/test-trace-events-fs-async.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,10 @@ function chown({ uid, gid }) {
4747

4848
function close() {
4949
const fs = require('fs');
50-
fs.writeFile('fs3.txt', '123', 'utf8', () => {
51-
fs.unlinkSync('fs3.txt');
50+
fs.open('fs3.txt', 'w', (err, fd) => {
51+
fs.close(fd, () => {
52+
fs.unlinkSync('fs3.txt');
53+
});
5254
});
5355
}
5456

@@ -173,7 +175,8 @@ function mktmp() {
173175

174176
function open() {
175177
const fs = require('fs');
176-
fs.writeFile('fs16.txt', '123', 'utf8', () => {
178+
fs.open('fs16.txt', 'w', (err, fd) => {
179+
fs.closeSync(fd);
177180
fs.unlinkSync('fs16.txt');
178181
});
179182
}

0 commit comments

Comments
 (0)