Skip to content

Commit aec6249

Browse files
fs: add atomic option to writeFile
Writes go to a temp file next to the target and then get renamed over it, so a reader never sees a half written file. Refs: #49886 Signed-off-by: webdevelopersrinu <webdeveloper.srinu9@gmail.com>
1 parent 6e7818e commit aec6249

5 files changed

Lines changed: 422 additions & 1 deletion

File tree

‎doc/api/fs.md‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2287,6 +2287,9 @@ All the [caveats][] for `fs.watch()` also apply to `fsPromises.watch()`.
22872287
<!-- YAML
22882288
added: v10.0.0
22892289
changes:
2290+
- version: REPLACEME
2291+
pr-url: https://github.com/nodejs/node/pull/REPLACEME
2292+
description: The `atomic` option is now supported.
22902293
- version:
22912294
- v21.0.0
22922295
- v20.10.0
@@ -2318,6 +2321,12 @@ changes:
23182321
* `flush` {boolean} If all data is successfully written to the file, and
23192322
`flush` is `true`, `filehandle.sync()` is used to flush the data.
23202323
**Default:** `false`.
2324+
* `atomic` {boolean} If `true`, the data is written to a temporary file next
2325+
to `file`, flushed, and then renamed over `file`. A reader sees either the
2326+
old data or the new data, never a half-written file. The permissions of an
2327+
existing `file` are kept instead of `mode`. Cannot be used with a file
2328+
descriptor, a {FileHandle}, or a `flag` other than `'w'`.
2329+
**Default:** `false`.
23212330
* `signal` {AbortSignal} allows aborting an in-progress writeFile
23222331
* Returns: {Promise} Fulfills with `undefined` upon success.
23232332
@@ -5681,6 +5690,9 @@ details.
56815690
<!-- YAML
56825691
added: v0.1.29
56835692
changes:
5693+
- version: REPLACEME
5694+
pr-url: https://github.com/nodejs/node/pull/REPLACEME
5695+
description: The `atomic` option is now supported.
56845696
- version:
56855697
- v21.0.0
56865698
- v20.10.0
@@ -5746,6 +5758,12 @@ changes:
57465758
* `flush` {boolean} If all data is successfully written to the file, and
57475759
`flush` is `true`, `fs.fsync()` is used to flush the data.
57485760
**Default:** `false`.
5761+
* `atomic` {boolean} If `true`, the data is written to a temporary file next
5762+
to `file`, flushed, and then renamed over `file`. A reader sees either the
5763+
old data or the new data, never a half-written file. The permissions of an
5764+
existing `file` are kept instead of `mode`. Cannot be used with a file
5765+
descriptor, a {FileHandle}, or a `flag` other than `'w'`.
5766+
**Default:** `false`.
57495767
* `signal` {AbortSignal} allows aborting an in-progress writeFile
57505768
* `callback` {Function}
57515769
* `err` {Error|AggregateError}
@@ -7095,6 +7113,9 @@ this API: [`fs.utimes()`][].
70957113
<!-- YAML
70967114
added: v0.1.29
70977115
changes:
7116+
- version: REPLACEME
7117+
pr-url: https://github.com/nodejs/node/pull/REPLACEME
7118+
description: The `atomic` option is now supported.
70987119
- version:
70997120
- v21.0.0
71007121
- v20.10.0
@@ -7186,6 +7207,12 @@ added:
71867207
* `fd` {integer}
71877208
* `buffer` {Buffer|TypedArray|DataView}
71887209
* `options` {Object}
7210+
* `atomic` {boolean} If `true`, the data is written to a temporary file next
7211+
to `file`, flushed, and then renamed over `file`. A reader sees either the
7212+
old data or the new data, never a half-written file. The permissions of an
7213+
existing `file` are kept instead of `mode`. Cannot be used with a file
7214+
descriptor, a {FileHandle}, or a `flag` other than `'w'`.
7215+
**Default:** `false`.
71897216
* `offset` {integer} **Default:** `0`
71907217
* `length` {integer} **Default:** `buffer.byteLength - offset`
71917218
* `position` {integer|null} **Default:** `null`

‎lib/fs.js‎

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ const {
102102
collectRecursiveReaddirResult,
103103
copyObject,
104104
Dirent,
105+
getAtomicTempPath,
105106
getDirents,
106107
getRecursiveDirents,
107108
getOptions,
@@ -118,6 +119,7 @@ const {
118119
stringToFlags,
119120
stringToSymlinkType,
120121
toUnixTimestamp,
122+
validateAtomicWrite,
121123
validateBufferArray,
122124
validateCpOptions,
123125
validateOffsetLengthRead,
@@ -2878,6 +2880,73 @@ function writeAll(fd, isUserFd, buffer, offset, length, signal, flush, callback)
28782880
});
28792881
}
28802882

2883+
// Drop the temp file and report the real error. If unlink also fails, ignore it.
2884+
function abandonAtomicWrite(tmp, err, callback) {
2885+
fs.unlink(tmp, () => callback(err));
2886+
}
2887+
2888+
function writeFileAtomic(path, data, options, callback) {
2889+
const tmp = getAtomicTempPath(path);
2890+
// Keep the old permissions, or mode would widen them on every write.
2891+
fs.stat(path, (statErr, stats) => {
2892+
const mode = stats === undefined ? options.mode : stats.mode & 0o777;
2893+
// 'wx' so we never reuse a temp file someone left behind.
2894+
fs.open(tmp, 'wx', mode, (openErr, fd) => {
2895+
if (openErr) {
2896+
callback(openErr);
2897+
return;
2898+
}
2899+
// writeAll closes the fd. Always flush, or a crash can leave half a file.
2900+
writeAll(fd, false, data, 0, data.byteLength, options.signal, true, (writeErr) => {
2901+
if (writeErr) {
2902+
abandonAtomicWrite(tmp, writeErr, callback);
2903+
return;
2904+
}
2905+
fs.rename(tmp, path, (renameErr) => {
2906+
if (renameErr) {
2907+
abandonAtomicWrite(tmp, renameErr, callback);
2908+
return;
2909+
}
2910+
callback(null);
2911+
});
2912+
});
2913+
});
2914+
});
2915+
}
2916+
2917+
function writeFileAtomicSync(path, data, options) {
2918+
const tmp = getAtomicTempPath(path);
2919+
// Keep the old permissions, or mode would widen them on every write.
2920+
const stats = fs.statSync(path, { throwIfNoEntry: false });
2921+
const mode = stats === undefined ? options.mode : stats.mode & 0o777;
2922+
let fd;
2923+
try {
2924+
fd = fs.openSync(tmp, 'wx', mode);
2925+
try {
2926+
let offset = 0;
2927+
let length = data.byteLength;
2928+
while (length > 0) {
2929+
const written = fs.writeSync(fd, data, offset, length);
2930+
offset += written;
2931+
length -= written;
2932+
}
2933+
fs.fsyncSync(fd);
2934+
} finally {
2935+
fs.closeSync(fd);
2936+
}
2937+
fs.renameSync(tmp, path);
2938+
} catch (err) {
2939+
if (fd !== undefined) {
2940+
try {
2941+
fs.unlinkSync(tmp);
2942+
} catch {
2943+
// Best effort. The real error matters more.
2944+
}
2945+
}
2946+
throw err;
2947+
}
2948+
}
2949+
28812950
/**
28822951
* Asynchronously writes data to the file.
28832952
* @param {string | Buffer | URL | number} path
@@ -2888,6 +2957,7 @@ function writeAll(fd, isUserFd, buffer, offset, length, signal, flush, callback)
28882957
* flag?: string;
28892958
* signal?: AbortSignal;
28902959
* flush?: boolean;
2960+
* atomic?: boolean;
28912961
* } | string} [options]
28922962
* @param {(err?: Error) => any} callback
28932963
* @returns {void}
@@ -2913,12 +2983,19 @@ function writeFile(path, data, options, callback) {
29132983
}
29142984

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

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

2993+
if (atomicPath !== null) {
2994+
if (checkAborted(options.signal, callback)) return;
2995+
writeFileAtomic(atomicPath, data, options, callback);
2996+
return;
2997+
}
2998+
29222999
if (isFd(path)) {
29233000
const isUserFd = true;
29243001
const signal = options.signal;
@@ -2949,6 +3026,7 @@ function writeFile(path, data, options, callback) {
29493026
* mode?: number;
29503027
* flag?: string;
29513028
* flush?: boolean;
3029+
* atomic?: boolean;
29523030
* } | string} [options]
29533031
* @returns {void}
29543032
*/
@@ -2970,9 +3048,11 @@ function writeFileSync(path, data, options) {
29703048
}
29713049

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

29743053
// C++ fast path for string data and UTF8 encoding
2975-
if (typeof data === 'string' && (options.encoding === 'utf8' || options.encoding === 'utf-8')) {
3054+
if (atomicPath === null && typeof data === 'string' &&
3055+
(options.encoding === 'utf8' || options.encoding === 'utf-8')) {
29763056
if (!isInt32(path)) {
29773057
path = getValidatedPath(path);
29783058
}
@@ -2990,6 +3070,11 @@ function writeFileSync(path, data, options) {
29903070
data = Buffer.from(data, options.encoding || 'utf8');
29913071
}
29923072

3073+
if (atomicPath !== null) {
3074+
writeFileAtomicSync(atomicPath, data, options);
3075+
return;
3076+
}
3077+
29933078
const isUserFd = isFd(path); // File descriptor ownership
29943079
const fd = isUserFd ? path : fs.openSync(path, flag, options.mode);
29953080

‎lib/internal/fs/promises.js‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ const {
6363
},
6464
collectRecursiveReaddirResult,
6565
copyObject,
66+
getAtomicTempPath,
6667
getDirents,
6768
getRecursiveDirents,
6869
getOptions,
@@ -76,6 +77,7 @@ const {
7677
stringToSymlinkType,
7778
toUnixTimestamp,
7879
handleErrorFromBinding: handleSyncErrorFromBinding,
80+
validateAtomicWrite,
7981
validateBufferArray,
8082
validateCpOptions,
8183
validateOffsetLengthRead,
@@ -2085,6 +2087,37 @@ async function mkdtempDisposable(prefix, options) {
20852087
};
20862088
}
20872089

2090+
async function writeFileAtomic(path, data, options) {
2091+
const tmp = getAtomicTempPath(path);
2092+
let mode = options.mode;
2093+
try {
2094+
// Keep the old permissions, or mode would widen them on every write.
2095+
mode = (await stat(path)).mode & 0o777;
2096+
} catch {
2097+
// No file there yet, so mode is fine.
2098+
}
2099+
2100+
// 'wx' so we never reuse a temp file someone left behind.
2101+
const fd = await open(tmp, 'wx', mode);
2102+
try {
2103+
try {
2104+
await writeFileHandle(fd, data, options.signal, options.encoding);
2105+
// Always flush, or a crash can leave half a file.
2106+
await fd.sync();
2107+
} finally {
2108+
await fd.close();
2109+
}
2110+
await rename(tmp, path);
2111+
} catch (err) {
2112+
try {
2113+
await unlink(tmp);
2114+
} catch {
2115+
// Best effort. The real error matters more.
2116+
}
2117+
throw err;
2118+
}
2119+
}
2120+
20882121
async function writeFile(path, data, options) {
20892122
options = getOptions(options, {
20902123
encoding: 'utf8',
@@ -2104,6 +2137,7 @@ async function writeFile(path, data, options) {
21042137
}
21052138

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

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

21172151
checkAborted(options.signal);
21182152

2153+
if (atomicPath !== null)
2154+
return writeFileAtomic(atomicPath, data, options);
2155+
21192156
const fd = await open(path, flag, options.mode);
21202157
let writeOp = writeFileHandle(fd, data, options.signal, options.encoding);
21212158

‎lib/internal/fs/utils.js‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1186,6 +1186,33 @@ const validatePosition = hideStackFrames((position, name, length) => {
11861186
}
11871187
});
11881188

1189+
// Keep the temp file next to the target. rename() is only atomic on the
1190+
// same filesystem.
1191+
let atomicWriteCounter = 0;
1192+
function getAtomicTempPath(path) {
1193+
const suffix = `.${process.pid}.${atomicWriteCounter++}.tmp`;
1194+
if (typeof path === 'string') {
1195+
return path + suffix;
1196+
}
1197+
return Buffer.concat([path, Buffer.from(suffix)]);
1198+
}
1199+
1200+
// Returns the path to write to, or null when the write is not atomic.
1201+
const validateAtomicWrite = hideStackFrames((atomic, path, flag) => {
1202+
validateBoolean.withoutStackTrace(atomic, 'options.atomic');
1203+
if (!atomic) return null;
1204+
if (flag !== 'w') {
1205+
throw new ERR_INCOMPATIBLE_OPTION_PAIR.HideStackFramesError(
1206+
'options.atomic', 'options.flag');
1207+
}
1208+
// A file descriptor has no name to rename over.
1209+
if (typeof path === 'number') {
1210+
throw new ERR_INVALID_ARG_TYPE.HideStackFramesError(
1211+
'path', ['string', 'Buffer', 'URL'], path);
1212+
}
1213+
return getValidatedPath(path);
1214+
});
1215+
11891216
// Shared VFS handler state for fs wrapping.
11901217
// When handlers is null, no VFS is active (zero overhead).
11911218
const vfsState = { __proto__: null, handlers: null };
@@ -1208,6 +1235,7 @@ module.exports = {
12081235
Dirent,
12091236
DirentFromStats,
12101237
getDirent,
1238+
getAtomicTempPath,
12111239
getDirents,
12121240
getOptions,
12131241
getRecursiveDirents,
@@ -1222,6 +1250,7 @@ module.exports = {
12221250
stringToSymlinkType,
12231251
Stats: deprecate(Stats, 'fs.Stats constructor is deprecated.', 'DEP0180'),
12241252
toUnixTimestamp,
1253+
validateAtomicWrite,
12251254
validateBufferArray,
12261255
validateCpOptions,
12271256
validateOffsetLengthRead,

0 commit comments

Comments
 (0)