stream: support abort signal

PR-URL: https://github.com/nodejs/node/pull/36061
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Robert Nagy <ronagy@icloud.com>
pull/36432/head
Benjamin Gruenbaum 2020-11-09 23:25:30 +02:00 committed by Node.js GitHub Bot
parent 5122456883
commit 5bd1eecfa9
10 changed files with 183 additions and 13 deletions

View File

@ -45,8 +45,8 @@ There are four fundamental stream types within Node.js:
is written and read (for example, [`zlib.createDeflate()`][]).
Additionally, this module includes the utility functions
[`stream.pipeline()`][], [`stream.finished()`][] and
[`stream.Readable.from()`][].
[`stream.pipeline()`][], [`stream.finished()`][], [`stream.Readable.from()`][]
and [`stream.addAbortSignal()`][].
### Streams Promises API
<!-- YAML
@ -1799,6 +1799,55 @@ Calling `Readable.from(string)` or `Readable.from(buffer)` will not have
the strings or buffers be iterated to match the other streams semantics
for performance reasons.
### `stream.addAbortSignal(signal, stream)`
<!-- YAML
added: REPLACEME
-->
* `signal` {AbortSignal} A signal representing possible cancellation
* `stream` {Stream} a stream to attach a signal to
Attaches an AbortSignal to a readable or writeable stream. This lets code
control stream destruction using an `AbortController`.
Calling `abort` on the `AbortController` corresponding to the passed
`AbortSignal` will behave the same way as calling `.destroy(new AbortError())`
on the stream.
```js
const fs = require('fs');
const controller = new AbortController();
const read = addAbortSignal(
controller.signal,
fs.createReadStream(('object.json'))
);
// Later, abort the operation closing the stream
controller.abort();
```
Or using an `AbortSignal` with a readable stream as an async iterable:
```js
const controller = new AbortController();
setTimeout(() => controller.abort(), 10_000); // set a timeout
const stream = addAbortSignal(
controller.signal,
fs.createReadStream(('object.json'))
);
(async () => {
try {
for await (const chunk of stream) {
await process(chunk);
}
} catch (e) {
if (e.name === 'AbortError') {
// The operation was cancelled
} else {
throw e;
}
}
})();
```
## API for stream implementers
<!--type=misc-->
@ -3123,6 +3172,7 @@ contain multi-byte characters.
[`stream.finished()`]: #stream_stream_finished_stream_options_callback
[`stream.pipe()`]: #stream_readable_pipe_destination_options
[`stream.pipeline()`]: #stream_stream_pipeline_source_transforms_destination_callback
[`stream.addAbortSignal()`]: #stream_stream_addabortsignal_signal_stream
[`stream.uncork()`]: #stream_writable_uncork
[`stream.unpipe()`]: #stream_readable_unpipe_destination
[`stream.wrap()`]: #stream_readable_wrap_stream

View File

@ -51,7 +51,7 @@ const { Buffer } = require('buffer');
const { defaultTriggerAsyncIdScope } = require('internal/async_hooks');
const { URL, urlToOptions, searchParamsSymbol } = require('internal/url');
const { kOutHeaders, kNeedDrain } = require('internal/http');
const { AbortError, connResetException, codes } = require('internal/errors');
const { connResetException, codes } = require('internal/errors');
const {
ERR_HTTP_HEADERS_SENT,
ERR_INVALID_ARG_TYPE,
@ -61,7 +61,6 @@ const {
} = codes;
const {
validateInteger,
validateAbortSignal,
} = require('internal/validators');
const { getTimerDuration } = require('internal/timers');
const {
@ -69,6 +68,8 @@ const {
DTRACE_HTTP_CLIENT_RESPONSE
} = require('internal/dtrace');
const { addAbortSignal } = require('stream');
const INVALID_PATH_REGEX = /[^\u0021-\u00ff]/;
const kError = Symbol('kError');
@ -174,12 +175,7 @@ function ClientRequest(input, options, cb) {
const signal = options.signal;
if (signal) {
validateAbortSignal(signal, 'options.signal');
const listener = (e) => this.destroy(new AbortError());
signal.addEventListener('abort', listener);
this.once('close', () => {
signal.removeEventListener('abort', listener);
});
addAbortSignal(signal, this);
}
let method = options.method;
const methodIsString = (typeof method === 'string');

View File

@ -0,0 +1,41 @@
'use strict';
const {
AbortError,
codes,
} = require('internal/errors');
const eos = require('internal/streams/end-of-stream');
const { ERR_INVALID_ARG_TYPE } = codes;
// This method is inlined here for readable-stream
// https://github.com/nodejs/node/pull/36061#discussion_r533718029
const validateAbortSignal = (signal, name) => {
if (signal !== undefined &&
(signal === null ||
typeof signal !== 'object' ||
!('aborted' in signal))) {
throw new ERR_INVALID_ARG_TYPE(name, 'AbortSignal', signal);
}
};
function isStream(obj) {
return !!(obj && typeof obj.pipe === 'function');
}
module.exports = function addAbortSignal(signal, stream) {
validateAbortSignal(signal, 'signal');
if (!isStream(stream)) {
throw new ERR_INVALID_ARG_TYPE('stream', 'stream.Stream', stream);
}
const onAbort = () => {
stream.destroy(new AbortError());
};
if (signal.aborted) {
onAbort();
} else {
signal.addEventListener('abort', onAbort);
eos(stream, () => signal.removeEventListener('abort', onAbort));
}
return stream;
};

View File

@ -50,6 +50,7 @@ const {
getHighWaterMark,
getDefaultHighWaterMark
} = require('internal/streams/state');
const {
ERR_INVALID_ARG_TYPE,
ERR_STREAM_PUSH_AFTER_EOF,

View File

@ -43,6 +43,7 @@ Stream.Duplex = require('internal/streams/duplex');
Stream.Transform = require('internal/streams/transform');
Stream.PassThrough = require('internal/streams/passthrough');
Stream.pipeline = pipeline;
Stream.addAbortSignal = require('internal/streams/add-abort-signal');
Stream.finished = eos;
function lazyLoadPromises() {

View File

@ -245,6 +245,7 @@
'lib/internal/worker/js_transferable.js',
'lib/internal/watchdog.js',
'lib/internal/streams/lazy_transform.js',
'lib/internal/streams/add-abort-signal.js',
'lib/internal/streams/buffer_list.js',
'lib/internal/streams/duplexpair.js',
'lib/internal/streams/from.js',

View File

@ -78,6 +78,7 @@ const expectedModules = new Set([
'NativeModule internal/process/warning',
'NativeModule internal/querystring',
'NativeModule internal/source_map/source_map_cache',
'NativeModule internal/streams/add-abort-signal',
'NativeModule internal/streams/buffer_list',
'NativeModule internal/streams/destroy',
'NativeModule internal/streams/duplex',

View File

@ -8,7 +8,8 @@ const {
Transform,
pipeline,
PassThrough,
Duplex
Duplex,
addAbortSignal,
} = require('stream');
const assert = require('assert');
const http = require('http');
@ -1261,3 +1262,32 @@ const net = require('net');
() => common.mustNotCall(),
);
}
{
const ac = new AbortController();
const r = Readable.from(async function* () {
for (let i = 0; i < 10; i++) {
await Promise.resolve();
yield String(i);
if (i === 5) {
ac.abort();
}
}
}());
let res = '';
const w = new Writable({
write(chunk, encoding, callback) {
res += chunk;
callback();
}
});
const cb = common.mustCall((err) => {
assert.strictEqual(err.name, 'AbortError');
assert.strictEqual(res, '012345');
assert.strictEqual(w.destroyed, true);
assert.strictEqual(r.destroyed, true);
assert.strictEqual(pipelined.destroyed, true);
});
const pipelined = addAbortSignal(ac.signal, pipeline([r, w], cb));
}

View File

@ -1,7 +1,7 @@
'use strict';
const common = require('../common');
const { Readable } = require('stream');
const { Readable, addAbortSignal } = require('stream');
const assert = require('assert');
{
@ -268,3 +268,38 @@ const assert = require('assert');
}));
read.resume();
}
{
const controller = new AbortController();
const read = addAbortSignal(controller.signal, new Readable({
read() {
this.push('asd');
},
}));
read.on('error', common.mustCall((e) => {
assert.strictEqual(e.name, 'AbortError');
}));
controller.abort();
read.on('data', common.mustNotCall());
}
{
const controller = new AbortController();
const read = addAbortSignal(controller.signal, new Readable({
objectMode: true,
read() {
return false;
}
}));
read.push('asd');
read.on('error', common.mustCall((e) => {
assert.strictEqual(e.name, 'AbortError');
}));
assert.rejects((async () => {
/* eslint-disable-next-line no-unused-vars */
for await (const chunk of read) {}
})(), /AbortError/);
setTimeout(() => controller.abort(), 0);
}

View File

@ -1,7 +1,7 @@
'use strict';
const common = require('../common');
const { Writable } = require('stream');
const { Writable, addAbortSignal } = require('stream');
const assert = require('assert');
{
@ -417,3 +417,17 @@ const assert = require('assert');
}));
write.write('asd');
}
{
const ac = new AbortController();
const write = addAbortSignal(ac.signal, new Writable({
write(chunk, enc, cb) { cb(); }
}));
write.on('error', common.mustCall((e) => {
assert.strictEqual(e.name, 'AbortError');
assert.strictEqual(write.destroyed, true);
}));
write.write('asd');
ac.abort();
}