Skip to content

Commit f86c085

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. The job holds the buffer's backing store, so the memory stays valid if the buffer is detached or collected before the write finishes; a resizable ArrayBuffer could still have its pages decommitted by a shrink, so its contents are copied when the job is created. Signed-off-by: Shelley Vohr <shelley.vohr@gmail.com>
1 parent 927dfad commit f86c085

8 files changed

Lines changed: 320 additions & 6 deletions

lib/fs.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ const {
7979
const {
8080
FSReqCallback,
8181
ReadFileJob,
82+
WriteFileJob,
8283
} = binding;
8384
const { toPathIfFileURL } = require('internal/url');
8485
const {
@@ -2929,6 +2930,23 @@ function writeFile(path, data, options, callback) {
29292930
if (checkAborted(options.signal, callback))
29302931
return;
29312932

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

lib/internal/fs/promises.js

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

21172117
checkAborted(options.signal);
21182118

2119+
if (!flush && !isCustomIterable(data) && data.byteLength <= kWriteFileMaxChunkSize) {
2120+
path = getValidatedPath(path);
2121+
await writeFileInOneRoundTrip(path, stringToFlags(flag, 'options.flag'),
2122+
parseFileMode(options.mode, 'mode', 0o666), data);
2123+
checkAborted(options.signal); // An abort during the write still wins.
2124+
return;
2125+
}
2126+
21192127
const fd = await open(path, flag, options.mode);
21202128
let writeOp = writeFileHandle(fd, data, options.signal, options.encoding);
21212129

@@ -2126,6 +2134,33 @@ async function writeFile(path, data, options) {
21262134
return handleFdClose(writeOp, fd.close);
21272135
}
21282136

2137+
/**
2138+
* Open + write + close as one thread pool round trip.
2139+
* @param {string|Buffer} path Validated path
2140+
* @param {number} flagsNumber
2141+
* @param {number} mode
2142+
* @param {ArrayBufferView} data
2143+
* @returns {Promise<void>}
2144+
*/
2145+
function writeFileInOneRoundTrip(path, flagsNumber, mode, data) {
2146+
return new Promise((resolve, reject) => {
2147+
const job = new binding.WriteFileJob(path, flagsNumber, mode, data);
2148+
job.ondone = (err) => {
2149+
if (err != null) {
2150+
ErrorCaptureStackTrace(err, writeFileInOneRoundTrip);
2151+
reject(err);
2152+
} else {
2153+
resolve();
2154+
}
2155+
};
2156+
const accessError = job.run(path);
2157+
if (accessError !== undefined) {
2158+
ErrorCaptureStackTrace(accessError, writeFileInOneRoundTrip);
2159+
reject(accessError);
2160+
}
2161+
});
2162+
}
2163+
21292164
function isCustomIterable(obj) {
21302165
return isIterable(obj) && !isArrayBufferView(obj) && typeof obj !== 'string';
21312166
}

src/node_file.cc

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ namespace fs {
6565

6666
using v8::Array;
6767
using v8::ArrayBuffer;
68+
using v8::ArrayBufferView;
6869
using v8::BigInt;
6970
using v8::Context;
7071
using v8::EscapableHandleScope;
@@ -3703,6 +3704,7 @@ class ReadFileJob final : public AsyncWrap, public ThreadPoolWork {
37033704
SET_SELF_SIZE(ReadFileJob)
37043705

37053706
private:
3707+
friend class WriteFileJob;
37063708
static constexpr size_t kUnknownSizeChunk = 64 * 1024;
37073709
static constexpr size_t kMaxReadChunk = 256 * 1024 * 1024;
37083710

@@ -3786,6 +3788,162 @@ class ReadFileJob final : public AsyncWrap, public ThreadPoolWork {
37863788
int fd_ = -1;
37873789
};
37883790

3791+
// Writes a whole buffer to a file in ONE thread pool round trip -- open +
3792+
// write (until everything is written) + close -- for fs.writeFile() and
3793+
// fs.promises.writeFile() with a path, which otherwise pay one round trip per
3794+
// step.
3795+
//
3796+
// JS: const job = new WriteFileJob(path, flags, mode, buffer);
3797+
// job.ondone = (err) => {...}; job.run(path);
3798+
// `err` carries the syscall that failed ('open', 'write' or 'close'); the file
3799+
// descriptor opened here is always closed.
3800+
class WriteFileJob final : public AsyncWrap, public ThreadPoolWork {
3801+
public:
3802+
static void New(const FunctionCallbackInfo<Value>& args) {
3803+
CHECK(args.IsConstructCall());
3804+
Environment* env = Environment::GetCurrent(args);
3805+
CHECK_GE(args.Length(), 4);
3806+
BufferValue path(env->isolate(), args[0]);
3807+
CHECK_NOT_NULL(*path);
3808+
ToNamespacedPath(env, &path);
3809+
CHECK(args[1]->IsInt32());
3810+
CHECK(args[2]->IsInt32());
3811+
CHECK(args[3]->IsArrayBufferView());
3812+
new WriteFileJob(env,
3813+
args.This(),
3814+
path.ToString(),
3815+
args[1].As<Int32>()->Value(),
3816+
args[2].As<Int32>()->Value(),
3817+
args[3].As<ArrayBufferView>());
3818+
}
3819+
3820+
// Returns undefined when the job was scheduled, or the ERR_ACCESS_DENIED
3821+
// error the asynchronous open() would have delivered (nothing is scheduled).
3822+
static void Run(const FunctionCallbackInfo<Value>& args) {
3823+
WriteFileJob* job;
3824+
ASSIGN_OR_RETURN_UNWRAP(&job, args.This());
3825+
Environment* env = job->AsyncWrap::env();
3826+
CHECK(!job->scheduled_);
3827+
BufferValue path(env->isolate(), args[0]);
3828+
CHECK_NOT_NULL(*path);
3829+
ToNamespacedPath(env, &path);
3830+
Local<Value> access_error;
3831+
if (ReadFileJob::OpenPermissionError(env, path, job->flags_)
3832+
.ToLocal(&access_error)) {
3833+
args.GetReturnValue().Set(access_error);
3834+
return;
3835+
}
3836+
job->scheduled_ = true;
3837+
job->ClearWeak();
3838+
FS_ASYNC_TRACE_BEGIN0(UV_FS_WRITE, job)
3839+
job->ScheduleWork();
3840+
}
3841+
3842+
void DoThreadPoolWork() override {
3843+
uv_fs_t req;
3844+
int fd = uv_fs_open(nullptr, &req, path_.c_str(), flags_, mode_, nullptr);
3845+
uv_fs_req_cleanup(&req);
3846+
if (fd < 0) return Fail("open", fd);
3847+
3848+
size_t written = 0;
3849+
while (written < length_) {
3850+
uv_buf_t buf = uv_buf_init(data_ + written,
3851+
static_cast<unsigned int>(std::min<size_t>(
3852+
length_ - written, kMaxWriteChunk)));
3853+
int r = uv_fs_write(nullptr, &req, fd, &buf, 1, -1, nullptr);
3854+
uv_fs_req_cleanup(&req);
3855+
if (r < 0) {
3856+
Fail("write", r);
3857+
break;
3858+
}
3859+
written += static_cast<size_t>(r);
3860+
}
3861+
3862+
int rc = uv_fs_close(nullptr, &req, fd, nullptr);
3863+
uv_fs_req_cleanup(&req);
3864+
if (rc < 0 && error_ == 0) Fail("close", rc);
3865+
}
3866+
3867+
void AfterThreadPoolWork(int status) override {
3868+
Environment* env = AsyncWrap::env();
3869+
std::unique_ptr<WriteFileJob> self(this);
3870+
CHECK(status == 0 || status == UV_ECANCELED);
3871+
FS_ASYNC_TRACE_END0(UV_FS_WRITE, this)
3872+
if (status == UV_ECANCELED || !env->can_call_into_js()) return;
3873+
HandleScope handle_scope(env->isolate());
3874+
Context::Scope context_scope(env->context());
3875+
Isolate* isolate = env->isolate();
3876+
Local<Value> argv[1] = {Null(isolate)};
3877+
if (error_ != 0) {
3878+
argv[0] = UVException(isolate,
3879+
error_,
3880+
syscall_,
3881+
nullptr,
3882+
syscall_ == kOpen ? path_.c_str() : nullptr);
3883+
}
3884+
MakeCallback(env->ondone_string(), arraysize(argv), argv);
3885+
}
3886+
3887+
bool IsNotIndicativeOfMemoryLeakAtExit() const override { return true; }
3888+
void MemoryInfo(MemoryTracker* tracker) const override {
3889+
tracker->TrackField("buffer", buffer_);
3890+
if (copy_) tracker->TrackFieldWithSize("copy", length_);
3891+
}
3892+
SET_MEMORY_INFO_NAME(WriteFileJob)
3893+
SET_SELF_SIZE(WriteFileJob)
3894+
3895+
private:
3896+
static constexpr size_t kMaxWriteChunk = 256 * 1024 * 1024;
3897+
static constexpr const char* kOpen = "open";
3898+
3899+
WriteFileJob(Environment* env,
3900+
Local<Object> object,
3901+
std::string&& path,
3902+
int flags,
3903+
int mode,
3904+
Local<ArrayBufferView> view)
3905+
: AsyncWrap(env, object, AsyncWrap::PROVIDER_FSREQCALLBACK),
3906+
ThreadPoolWork(env, "fs.writefile"),
3907+
path_(std::move(path)),
3908+
flags_(flags),
3909+
mode_(mode) {
3910+
// Holding the backing store keeps the memory valid even if the buffer is
3911+
// detached or collected meanwhile; a resizable buffer can still have its
3912+
// pages decommitted by a shrink, so its contents are copied instead.
3913+
length_ = view->ByteLength();
3914+
backing_store_ = view->Buffer()->GetBackingStore();
3915+
if (backing_store_->IsResizableByUserJavaScript()) {
3916+
copy_.reset(new char[length_]);
3917+
memcpy(copy_.get(),
3918+
static_cast<char*>(backing_store_->Data()) + view->ByteOffset(),
3919+
length_);
3920+
data_ = copy_.get();
3921+
backing_store_.reset();
3922+
} else {
3923+
buffer_.Reset(env->isolate(), view);
3924+
data_ = static_cast<char*>(backing_store_->Data()) + view->ByteOffset();
3925+
}
3926+
MakeWeak();
3927+
}
3928+
3929+
void Fail(const char* syscall, int error) {
3930+
syscall_ = syscall;
3931+
error_ = error;
3932+
}
3933+
3934+
const std::string path_;
3935+
v8::Global<v8::ArrayBufferView> buffer_;
3936+
std::shared_ptr<v8::BackingStore> backing_store_;
3937+
std::unique_ptr<char[]> copy_;
3938+
char* data_ = nullptr;
3939+
size_t length_ = 0;
3940+
const int flags_;
3941+
const int mode_;
3942+
bool scheduled_ = false;
3943+
int error_ = 0;
3944+
const char* syscall_ = nullptr;
3945+
};
3946+
37893947
// Wrapper for readv(2).
37903948
//
37913949
// bytesRead = fs.readv(fd, buffers[, position], callback)
@@ -5103,6 +5261,13 @@ static void CreatePerIsolateProperties(IsolateData* isolate_data,
51035261
SetProtoMethod(isolate, rfj, "run", ReadFileJob::Run);
51045262
SetConstructorFunction(isolate, target, "ReadFileJob", rfj);
51055263

5264+
Local<FunctionTemplate> wfj = NewFunctionTemplate(isolate, WriteFileJob::New);
5265+
wfj->InstanceTemplate()->SetInternalFieldCount(
5266+
WriteFileJob::kInternalFieldCount);
5267+
wfj->Inherit(AsyncWrap::GetConstructorTemplate(isolate_data));
5268+
SetProtoMethod(isolate, wfj, "run", WriteFileJob::Run);
5269+
SetConstructorFunction(isolate, target, "WriteFileJob", wfj);
5270+
51065271
// Create FunctionTemplate for FSReqCallback
51075272
Local<FunctionTemplate> fst = NewFunctionTemplate(isolate, NewFSReqCallback);
51085273
fst->InstanceTemplate()->SetInternalFieldCount(
@@ -5177,6 +5342,8 @@ void RegisterExternalReferences(ExternalReferenceRegistry* registry) {
51775342
registry->Register(Open);
51785343
registry->Register(ReadFileJob::New);
51795344
registry->Register(ReadFileJob::Run);
5345+
registry->Register(WriteFileJob::New);
5346+
registry->Register(WriteFileJob::Run);
51805347
registry->Register(OpenFileHandle);
51815348
registry->Register(Read);
51825349
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
}

0 commit comments

Comments
 (0)