Skip to content

Commit

Permalink
fs: support abortsignal in writeFile
Browse files Browse the repository at this point in the history
PR-URL: #35993
Backport-PR-URL: #38386
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
  • Loading branch information
benjamingr authored and targos committed Apr 30, 2021
1 parent 5f88b64 commit 219cd00
Show file tree
Hide file tree
Showing 5 changed files with 132 additions and 7 deletions.
61 changes: 60 additions & 1 deletion doc/api/fs.md
Expand Up @@ -4375,6 +4375,10 @@ details.
<!-- YAML
added: v0.1.29
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/35993
description: The options argument may include an AbortSignal to abort an
ongoing writeFile request.
- version: v14.12.0
pr-url: https://github.com/nodejs/node/pull/34993
description: The `data` parameter will stringify an object with an
Expand Down Expand Up @@ -4409,6 +4413,7 @@ changes:
* `encoding` {string|null} **Default:** `'utf8'`
* `mode` {integer} **Default:** `0o666`
* `flag` {string} See [support of file system `flags`][]. **Default:** `'w'`.
* `signal` {AbortSignal} allows aborting an in-progress writeFile
* `callback` {Function}
* `err` {Error}

Expand Down Expand Up @@ -4440,6 +4445,28 @@ It is unsafe to use `fs.writeFile()` multiple times on the same file without
waiting for the callback. For this scenario, [`fs.createWriteStream()`][] is
recommended.

Similarly to `fs.readFile` - `fs.writeFile` is a convenience method that
performs multiple `write` calls internally to write the buffer passed to it.
For performance sensitive code consider using [`fs.createWriteStream()`][].

It is possible to use an {AbortSignal} to cancel an `fs.writeFile()`.
Cancelation is "best effort", and some amount of data is likely still
to be written.

```js
const controller = new AbortController();
const { signal } = controller;
const data = new Uint8Array(Buffer.from('Hello Node.js'));
fs.writeFile('message.txt', data, { signal }, (err) => {
// When a request is aborted - the callback is called with an AbortError
});
// When the request should be aborted
controller.abort();
```

Aborting an ongoing request does not abort individual operating
system requests but rather the internal buffering `fs.writeFile` performs.

### Using `fs.writeFile()` with file descriptors

When `file` is a file descriptor, the behavior is almost identical to directly
Expand Down Expand Up @@ -5684,6 +5711,10 @@ The `atime` and `mtime` arguments follow these rules:
<!-- YAML
added: v10.0.0
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/35993
description: The options argument may include an AbortSignal to abort an
ongoing writeFile request.
- version: v14.12.0
pr-url: https://github.com/nodejs/node/pull/34993
description: The `data` parameter will stringify an object with an
Expand All @@ -5700,6 +5731,7 @@ changes:
* `encoding` {string|null} **Default:** `'utf8'`
* `mode` {integer} **Default:** `0o666`
* `flag` {string} See [support of file system `flags`][]. **Default:** `'w'`.
* `signal` {AbortSignal} allows aborting an in-progress writeFile
* Returns: {Promise}

Asynchronously writes data to a file, replacing the file if it already exists.
Expand All @@ -5713,7 +5745,34 @@ If `options` is a string, then it specifies the encoding.
Any specified `FileHandle` has to support writing.

It is unsafe to use `fsPromises.writeFile()` multiple times on the same file
without waiting for the `Promise` to be resolved (or rejected).
without waiting for the `Promise` to be fulfilled (or rejected).

Similarly to `fsPromises.readFile` - `fsPromises.writeFile` is a convenience
method that performs multiple `write` calls internally to write the buffer
passed to it. For performance sensitive code consider using
[`fs.createWriteStream()`][].

It is possible to use an {AbortSignal} to cancel an `fsPromises.writeFile()`.
Cancelation is "best effort", and some amount of data is likely still
to be written.

```js
const controller = new AbortController();
const { signal } = controller;
const data = new Uint8Array(Buffer.from('Hello Node.js'));
(async () => {
try {
await fs.writeFile('message.txt', data, { signal });
} catch (err) {
// When a request is aborted - err is an AbortError
}
})();
// When the request should be aborted
controller.abort();
```

Aborting an ongoing request does not abort individual operating
system requests but rather the internal buffering `fs.writeFile` performs.

## FS constants

Expand Down
26 changes: 22 additions & 4 deletions lib/fs.js
Expand Up @@ -71,6 +71,7 @@ const {
ERR_INVALID_CALLBACK,
ERR_FEATURE_UNAVAILABLE_ON_PLATFORM
},
hideStackFrames,
uvErrmapGet,
uvException
} = require('internal/errors');
Expand Down Expand Up @@ -134,6 +135,13 @@ let ReadStream;
let WriteStream;
let rimraf;
let rimrafSync;
let DOMException;

const lazyDOMException = hideStackFrames((message, name) => {
if (DOMException === undefined)
DOMException = internalBinding('messaging').DOMException;
return new DOMException(message, name);
});

// These have to be separate because of how graceful-fs happens to do it's
// monkeypatching.
Expand Down Expand Up @@ -1425,7 +1433,11 @@ function lutimesSync(path, atime, mtime) {
handleErrorFromBinding(ctx);
}

function writeAll(fd, isUserFd, buffer, offset, length, callback) {
function writeAll(fd, isUserFd, buffer, offset, length, signal, callback) {
if (signal?.aborted) {
callback(lazyDOMException('The operation was aborted', 'AbortError'));
return;
}
// write(fd, buffer, offset, length, position, callback)
fs.write(fd, buffer, offset, length, null, (writeErr, written) => {
if (writeErr) {
Expand All @@ -1445,7 +1457,7 @@ function writeAll(fd, isUserFd, buffer, offset, length, callback) {
} else {
offset += written;
length -= written;
writeAll(fd, isUserFd, buffer, offset, length, callback);
writeAll(fd, isUserFd, buffer, offset, length, signal, callback);
}
});
}
Expand All @@ -1462,16 +1474,22 @@ function writeFile(path, data, options, callback) {

if (isFd(path)) {
const isUserFd = true;
writeAll(path, isUserFd, data, 0, data.byteLength, callback);
const signal = options.signal;
writeAll(path, isUserFd, data, 0, data.byteLength, signal, callback);
return;
}

if (options.signal?.aborted) {
callback(lazyDOMException('The operation was aborted', 'AbortError'));
return;
}
fs.open(path, flag, options.mode, (openErr, fd) => {
if (openErr) {
callback(openErr);
} else {
const isUserFd = false;
writeAll(fd, isUserFd, data, 0, data.byteLength, callback);
const signal = options.signal;
writeAll(fd, isUserFd, data, 0, data.byteLength, signal, callback);
}
});
}
Expand Down
10 changes: 8 additions & 2 deletions lib/internal/fs/promises.js
Expand Up @@ -250,12 +250,15 @@ async function fsCall(fn, handle, ...args) {
}
}

async function writeFileHandle(filehandle, data) {
async function writeFileHandle(filehandle, data, signal) {
// `data` could be any kind of typed array.
data = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
let remaining = data.length;
if (remaining === 0) return;
do {
if (signal?.aborted) {
throw new lazyDOMException('The operation was aborted', 'AbortError');
}
const { bytesWritten } =
await write(filehandle, data, 0,
MathMin(kWriteFileMaxChunkSize, data.length));
Expand Down Expand Up @@ -644,9 +647,12 @@ async function writeFile(path, data, options) {
}

if (path instanceof FileHandle)
return writeFileHandle(path, data);
return writeFileHandle(path, data, options.signal);

const fd = await open(path, flag, options.mode);
if (options.signal?.aborted) {
throw new lazyDOMException('The operation was aborted', 'AbortError');
}
return PromisePrototypeFinally(writeFileHandle(fd, data), fd.close);
}

Expand Down
12 changes: 12 additions & 0 deletions test/parallel/test-fs-promises-writefile.js
@@ -1,3 +1,4 @@
// Flags: --experimental-abortcontroller
'use strict';

const common = require('../common');
Expand All @@ -11,6 +12,7 @@ const tmpDir = tmpdir.path;
tmpdir.refresh();

const dest = path.resolve(tmpDir, 'tmp.txt');
const otherDest = path.resolve(tmpDir, 'tmp-2.txt');
const buffer = Buffer.from('abc'.repeat(1000));
const buffer2 = Buffer.from('xyz'.repeat(1000));

Expand All @@ -20,6 +22,15 @@ async function doWrite() {
assert.deepStrictEqual(data, buffer);
}

async function doWriteWithCancel() {
const controller = new AbortController();
const { signal } = controller;
process.nextTick(() => controller.abort());
assert.rejects(fsPromises.writeFile(otherDest, buffer, { signal }), {
name: 'AbortError'
});
}

async function doAppend() {
await fsPromises.appendFile(dest, buffer2);
const data = fs.readFileSync(dest);
Expand All @@ -41,6 +52,7 @@ async function doReadWithEncoding() {
}

doWrite()
.then(doWriteWithCancel)
.then(doAppend)
.then(doRead)
.then(doReadWithEncoding)
Expand Down
30 changes: 30 additions & 0 deletions test/parallel/test-fs-write-file.js
Expand Up @@ -19,6 +19,7 @@
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
// USE OR OTHER DEALINGS IN THE SOFTWARE.

// Flags: --experimental-abortcontroller
'use strict';
const common = require('../common');
const assert = require('assert');
Expand Down Expand Up @@ -66,3 +67,32 @@ fs.open(filename4, 'w+', common.mustSucceed((fd) => {
}));
}));
}));


{
// Test that writeFile is cancellable with an AbortSignal.
// Before the operation has started
const controller = new AbortController();
const signal = controller.signal;
const filename3 = join(tmpdir.path, 'test3.txt');

fs.writeFile(filename3, s, { signal }, common.mustCall((err) => {
assert.strictEqual(err.name, 'AbortError');
}));

controller.abort();
}

{
// Test that writeFile is cancellable with an AbortSignal.
// After the operation has started
const controller = new AbortController();
const signal = controller.signal;
const filename4 = join(tmpdir.path, 'test4.txt');

fs.writeFile(filename4, s, { signal }, common.mustCall((err) => {
assert.strictEqual(err.name, 'AbortError');
}));

process.nextTick(() => controller.abort());
}

0 comments on commit 219cd00

Please sign in to comment.