Skip to content
Open
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
27 changes: 27 additions & 0 deletions doc/api/fs.md
Original file line number Diff line number Diff line change
Expand Up @@ -2287,6 +2287,9 @@ All the [caveats][] for `fs.watch()` also apply to `fsPromises.watch()`.
<!-- YAML
added: v10.0.0
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/65754
description: The `atomic` option is now supported.
- version:
- v21.0.0
- v20.10.0
Expand Down Expand Up @@ -2318,6 +2321,12 @@ changes:
* `flush` {boolean} If all data is successfully written to the file, and
`flush` is `true`, `filehandle.sync()` is used to flush the data.
**Default:** `false`.
* `atomic` {boolean} If `true`, the data is written to a temporary file next
to `file`, flushed, and then renamed over `file`. A reader sees either the
old data or the new data, never a half-written file. The permissions of an
existing `file` are kept instead of `mode`. Cannot be used with a file
descriptor, a {FileHandle}, or a `flag` other than `'w'`.
**Default:** `false`.
* `signal` {AbortSignal} allows aborting an in-progress writeFile
* Returns: {Promise} Fulfills with `undefined` upon success.

Expand Down Expand Up @@ -5681,6 +5690,9 @@ details.
<!-- YAML
added: v0.1.29
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/65754
description: The `atomic` option is now supported.
- version:
- v21.0.0
- v20.10.0
Expand Down Expand Up @@ -5746,6 +5758,12 @@ changes:
* `flush` {boolean} If all data is successfully written to the file, and
`flush` is `true`, `fs.fsync()` is used to flush the data.
**Default:** `false`.
* `atomic` {boolean} If `true`, the data is written to a temporary file next
to `file`, flushed, and then renamed over `file`. A reader sees either the
old data or the new data, never a half-written file. The permissions of an
existing `file` are kept instead of `mode`. Cannot be used with a file
descriptor, a {FileHandle}, or a `flag` other than `'w'`.
**Default:** `false`.
* `signal` {AbortSignal} allows aborting an in-progress writeFile
* `callback` {Function}
* `err` {Error|AggregateError}
Expand Down Expand Up @@ -7095,6 +7113,9 @@ this API: [`fs.utimes()`][].
<!-- YAML
added: v0.1.29
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/65754
description: The `atomic` option is now supported.
- version:
- v21.0.0
- v20.10.0
Expand Down Expand Up @@ -7186,6 +7207,12 @@ added:
* `fd` {integer}
* `buffer` {Buffer|TypedArray|DataView}
* `options` {Object}
* `atomic` {boolean} If `true`, the data is written to a temporary file next
to `file`, flushed, and then renamed over `file`. A reader sees either the
old data or the new data, never a half-written file. The permissions of an
existing `file` are kept instead of `mode`. Cannot be used with a file
descriptor, a {FileHandle}, or a `flag` other than `'w'`.
**Default:** `false`.
Comment on lines +7210 to +7215

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This probably belongs to fs.writeFileSync rather than fs.writeSync

* `offset` {integer} **Default:** `0`
* `length` {integer} **Default:** `buffer.byteLength - offset`
* `position` {integer|null} **Default:** `null`
Expand Down
87 changes: 86 additions & 1 deletion lib/fs.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ const {
collectRecursiveReaddirResult,
copyObject,
Dirent,
getAtomicTempPath,
getDirents,
getRecursiveDirents,
getOptions,
Expand All @@ -118,6 +119,7 @@ const {
stringToFlags,
stringToSymlinkType,
toUnixTimestamp,
validateAtomicWrite,
validateBufferArray,
validateCpOptions,
validateOffsetLengthRead,
Expand Down Expand Up @@ -2878,6 +2880,73 @@ function writeAll(fd, isUserFd, buffer, offset, length, signal, flush, callback)
});
}

// Drop the temp file and report the real error. If unlink also fails, ignore it.
function abandonAtomicWrite(tmp, err, callback) {
fs.unlink(tmp, () => callback(err));
}

function writeFileAtomic(path, data, options, callback) {
const tmp = getAtomicTempPath(path);
// Keep the old permissions, or mode would widen them on every write.
fs.stat(path, (statErr, stats) => {
const mode = stats === undefined ? options.mode : stats.mode & 0o777;
// 'wx' so we never reuse a temp file someone left behind.
fs.open(tmp, 'wx', mode, (openErr, fd) => {
if (openErr) {
callback(openErr);
return;
}
// writeAll closes the fd. Always flush, or a crash can leave half a file.
writeAll(fd, false, data, 0, data.byteLength, options.signal, true, (writeErr) => {
if (writeErr) {
abandonAtomicWrite(tmp, writeErr, callback);
return;
}
fs.rename(tmp, path, (renameErr) => {
if (renameErr) {
abandonAtomicWrite(tmp, renameErr, callback);
return;
}
callback(null);
});
});
});
});
}

function writeFileAtomicSync(path, data, options) {
const tmp = getAtomicTempPath(path);
// Keep the old permissions, or mode would widen them on every write.
const stats = fs.statSync(path, { throwIfNoEntry: false });
const mode = stats === undefined ? options.mode : stats.mode & 0o777;
let fd;
try {
fd = fs.openSync(tmp, 'wx', mode);
try {
let offset = 0;
let length = data.byteLength;
while (length > 0) {
const written = fs.writeSync(fd, data, offset, length);
offset += written;
length -= written;
}
fs.fsyncSync(fd);
} finally {
fs.closeSync(fd);
}
fs.renameSync(tmp, path);
} catch (err) {
if (fd !== undefined) {
try {
fs.unlinkSync(tmp);
} catch {
// Best effort. The real error matters more.
}
}
throw err;
}
}

/**
* Asynchronously writes data to the file.
* @param {string | Buffer | URL | number} path
Expand All @@ -2888,6 +2957,7 @@ function writeAll(fd, isUserFd, buffer, offset, length, signal, flush, callback)
* flag?: string;
* signal?: AbortSignal;
* flush?: boolean;
* atomic?: boolean;
* } | string} [options]
* @param {(err?: Error) => any} callback
* @returns {void}
Expand All @@ -2913,12 +2983,19 @@ function writeFile(path, data, options, callback) {
}

const flag = options.flag || 'w';
const atomicPath = validateAtomicWrite(options.atomic ?? false, path, flag);

if (!isArrayBufferView(data)) {
validateStringAfterArrayBufferView(data, 'data');
data = Buffer.from(data, options.encoding || 'utf8');
}

if (atomicPath !== null) {
if (checkAborted(options.signal, callback)) return;
writeFileAtomic(atomicPath, data, options, callback);
return;
}

if (isFd(path)) {
const isUserFd = true;
const signal = options.signal;
Expand Down Expand Up @@ -2949,6 +3026,7 @@ function writeFile(path, data, options, callback) {
* mode?: number;
* flag?: string;
* flush?: boolean;
* atomic?: boolean;
* } | string} [options]
* @returns {void}
*/
Expand All @@ -2970,9 +3048,11 @@ function writeFileSync(path, data, options) {
}

const flag = options.flag || 'w';
const atomicPath = validateAtomicWrite(options.atomic ?? false, path, flag);

// C++ fast path for string data and UTF8 encoding
if (typeof data === 'string' && (options.encoding === 'utf8' || options.encoding === 'utf-8')) {
if (atomicPath === null && typeof data === 'string' &&
(options.encoding === 'utf8' || options.encoding === 'utf-8')) {
if (!isInt32(path)) {
path = getValidatedPath(path);
}
Expand All @@ -2990,6 +3070,11 @@ function writeFileSync(path, data, options) {
data = Buffer.from(data, options.encoding || 'utf8');
}

if (atomicPath !== null) {
writeFileAtomicSync(atomicPath, data, options);
return;
}

const isUserFd = isFd(path); // File descriptor ownership
const fd = isUserFd ? path : fs.openSync(path, flag, options.mode);

Expand Down
37 changes: 37 additions & 0 deletions lib/internal/fs/promises.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ const {
},
collectRecursiveReaddirResult,
copyObject,
getAtomicTempPath,
getDirents,
getRecursiveDirents,
getOptions,
Expand All @@ -76,6 +77,7 @@ const {
stringToSymlinkType,
toUnixTimestamp,
handleErrorFromBinding: handleSyncErrorFromBinding,
validateAtomicWrite,
validateBufferArray,
validateCpOptions,
validateOffsetLengthRead,
Expand Down Expand Up @@ -2085,6 +2087,37 @@ async function mkdtempDisposable(prefix, options) {
};
}

async function writeFileAtomic(path, data, options) {
const tmp = getAtomicTempPath(path);
let mode = options.mode;
try {
// Keep the old permissions, or mode would widen them on every write.
mode = (await stat(path)).mode & 0o777;
} catch {
// No file there yet, so mode is fine.
}

// 'wx' so we never reuse a temp file someone left behind.
const fd = await open(tmp, 'wx', mode);
try {
try {
await writeFileHandle(fd, data, options.signal, options.encoding);
// Always flush, or a crash can leave half a file.
await fd.sync();
} finally {
await fd.close();
}
await rename(tmp, path);
} catch (err) {
try {
await unlink(tmp);
} catch {
// Best effort. The real error matters more.
}
throw err;
}
}

async function writeFile(path, data, options) {
options = getOptions(options, {
encoding: 'utf8',
Expand All @@ -2104,6 +2137,7 @@ async function writeFile(path, data, options) {
}

const flag = options.flag || 'w';
const atomicPath = validateAtomicWrite(options.atomic ?? false, path, flag);

if (!isArrayBufferView(data) && !isCustomIterable(data)) {
validateStringAfterArrayBufferView(data, 'data');
Expand All @@ -2116,6 +2150,9 @@ async function writeFile(path, data, options) {

checkAborted(options.signal);

if (atomicPath !== null)
return writeFileAtomic(atomicPath, data, options);

const fd = await open(path, flag, options.mode);
let writeOp = writeFileHandle(fd, data, options.signal, options.encoding);

Expand Down
29 changes: 29 additions & 0 deletions lib/internal/fs/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -1186,6 +1186,33 @@ const validatePosition = hideStackFrames((position, name, length) => {
}
});

// Keep the temp file next to the target. rename() is only atomic on the
// same filesystem.
let atomicWriteCounter = 0;
function getAtomicTempPath(path) {
const suffix = `.${process.pid}.${atomicWriteCounter++}.tmp`;
if (typeof path === 'string') {
return path + suffix;
}
return Buffer.concat([path, Buffer.from(suffix)]);
}

// Returns the path to write to, or null when the write is not atomic.
const validateAtomicWrite = hideStackFrames((atomic, path, flag) => {
validateBoolean.withoutStackTrace(atomic, 'options.atomic');
if (!atomic) return null;
if (flag !== 'w') {
throw new ERR_INCOMPATIBLE_OPTION_PAIR.HideStackFramesError(
'options.atomic', 'options.flag');
}
// A file descriptor has no name to rename over.
if (typeof path === 'number') {
throw new ERR_INVALID_ARG_TYPE.HideStackFramesError(
'path', ['string', 'Buffer', 'URL'], path);
}
return getValidatedPath(path);
});

// Shared VFS handler state for fs wrapping.
// When handlers is null, no VFS is active (zero overhead).
const vfsState = { __proto__: null, handlers: null };
Expand All @@ -1208,6 +1235,7 @@ module.exports = {
Dirent,
DirentFromStats,
getDirent,
getAtomicTempPath,
getDirents,
getOptions,
getRecursiveDirents,
Expand All @@ -1222,6 +1250,7 @@ module.exports = {
stringToSymlinkType,
Stats: deprecate(Stats, 'fs.Stats constructor is deprecated.', 'DEP0180'),
toUnixTimestamp,
validateAtomicWrite,
validateBufferArray,
validateCpOptions,
validateOffsetLengthRead,
Expand Down
Loading
Loading