Skip to content

Commit 759a018

Browse files
Gabriel Schulhofrvagg
Gabriel Schulhof
authored andcommittedFeb 28, 2019
n-api: add API for asynchronous functions
Bundle a `uv_async_t`, a `uv_idle_t`, a `uv_mutex_t`, a `uv_cond_t`, and a `v8::Persistent<v8::Function>` to make it possible to call into JS from another thread. The API accepts a void data pointer and a callback which will be invoked on the loop thread and which will receive the `napi_value` representing the JavaScript function to call so as to perform the call into JS. The callback is run inside a `node::CallbackScope`. A `std::queue<void*>` is used to store calls from the secondary threads, and an idle loop is started by the `uv_async_t` callback on the loop thread to drain the queue, calling into JS with each item. Items can be added to the queue blockingly or non-blockingly. The thread-safe function can be referenced or unreferenced, with the same semantics as libuv handles. Re: nodejs/help#1035 Re: #20964 Fixes: #13512 Backport-PR-URL: #25002 PR-URL: #17887 Reviewed-By: Matteo Collina <matteo.collina@gmail.com> Reviewed-By: Michael Dawson <michael_dawson@ca.ibm.com>
1 parent fbafe8d commit 759a018

File tree

8 files changed

+1330
-4
lines changed

8 files changed

+1330
-4
lines changed
 

‎doc/api/errors.md

+25
Original file line numberDiff line numberDiff line change
@@ -1107,6 +1107,31 @@ multiple of the element size.
11071107
While calling `napi_create_typedarray()`, `(length * size_of_element) +
11081108
byte_offset` was larger than the length of given `buffer`.
11091109

1110+
<a id="ERR_NAPI_TSFN_CALL_JS"></a>
1111+
### ERR_NAPI_TSFN_CALL_JS
1112+
1113+
An error occurred while invoking the JavaScript portion of the thread-safe
1114+
function.
1115+
1116+
<a id="ERR_NAPI_TSFN_GET_UNDEFINED"></a>
1117+
### ERR_NAPI_TSFN_GET_UNDEFINED
1118+
1119+
An error occurred while attempting to retrieve the JavaScript `undefined`
1120+
value.
1121+
1122+
<a id="ERR_NAPI_TSFN_START_IDLE_LOOP"></a>
1123+
### ERR_NAPI_TSFN_START_IDLE_LOOP
1124+
1125+
On the main thread, values are removed from the queue associated with the
1126+
thread-safe function in an idle loop. This error indicates that an error
1127+
has occurred when attemping to start the loop.
1128+
1129+
<a id="ERR_NAPI_TSFN_STOP_IDLE_LOOP"></a>
1130+
### ERR_NAPI_TSFN_STOP_IDLE_LOOP
1131+
1132+
Once no more items are left in the queue, the idle loop must be suspended. This
1133+
error indicates that the idle loop has failed to stop.
1134+
11101135
<a id="ERR_NO_ICU"></a>
11111136
### ERR_NO_ICU
11121137

‎doc/api/n-api.md

+371-1
Large diffs are not rendered by default.

‎src/node_api.cc

+441-2
Large diffs are not rendered by default.

‎src/node_api.h

+38
Original file line numberDiff line numberDiff line change
@@ -595,6 +595,44 @@ NAPI_EXTERN napi_status napi_run_script(napi_env env,
595595
NAPI_EXTERN napi_status napi_get_uv_event_loop(napi_env env,
596596
struct uv_loop_s** loop);
597597

598+
#ifdef NAPI_EXPERIMENTAL
599+
// Calling into JS from other threads
600+
NAPI_EXTERN napi_status
601+
napi_create_threadsafe_function(napi_env env,
602+
napi_value func,
603+
napi_value async_resource,
604+
napi_value async_resource_name,
605+
size_t max_queue_size,
606+
size_t initial_thread_count,
607+
void* thread_finalize_data,
608+
napi_finalize thread_finalize_cb,
609+
void* context,
610+
napi_threadsafe_function_call_js call_js_cb,
611+
napi_threadsafe_function* result);
612+
613+
NAPI_EXTERN napi_status
614+
napi_get_threadsafe_function_context(napi_threadsafe_function func,
615+
void** result);
616+
617+
NAPI_EXTERN napi_status
618+
napi_call_threadsafe_function(napi_threadsafe_function func,
619+
void* data,
620+
napi_threadsafe_function_call_mode is_blocking);
621+
622+
NAPI_EXTERN napi_status
623+
napi_acquire_threadsafe_function(napi_threadsafe_function func);
624+
625+
NAPI_EXTERN napi_status
626+
napi_release_threadsafe_function(napi_threadsafe_function func,
627+
napi_threadsafe_function_release_mode mode);
628+
629+
NAPI_EXTERN napi_status
630+
napi_unref_threadsafe_function(napi_env env, napi_threadsafe_function func);
631+
632+
NAPI_EXTERN napi_status
633+
napi_ref_threadsafe_function(napi_env env, napi_threadsafe_function func);
634+
635+
#endif // NAPI_EXPERIMENTAL
598636
EXTERN_C_END
599637

600638
#endif // SRC_NODE_API_H_

‎src/node_api_types.h

+27-1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ typedef struct napi_callback_info__ *napi_callback_info;
2020
typedef struct napi_async_context__ *napi_async_context;
2121
typedef struct napi_async_work__ *napi_async_work;
2222
typedef struct napi_deferred__ *napi_deferred;
23+
#ifdef NAPI_EXPERIMENTAL
24+
typedef struct napi_threadsafe_function__* napi_threadsafe_function;
25+
#endif // NAPI_EXPERIMENTAL
2326

2427
typedef enum {
2528
napi_default = 0,
@@ -72,9 +75,25 @@ typedef enum {
7275
napi_cancelled,
7376
napi_escape_called_twice,
7477
napi_handle_scope_mismatch,
75-
napi_callback_scope_mismatch
78+
napi_callback_scope_mismatch,
79+
#ifdef NAPI_EXPERIMENTAL
80+
napi_queue_full,
81+
napi_closing,
82+
#endif // NAPI_EXPERIMENTAL
7683
} napi_status;
7784

85+
#ifdef NAPI_EXPERIMENTAL
86+
typedef enum {
87+
napi_tsfn_release,
88+
napi_tsfn_abort
89+
} napi_threadsafe_function_release_mode;
90+
91+
typedef enum {
92+
napi_tsfn_nonblocking,
93+
napi_tsfn_blocking
94+
} napi_threadsafe_function_call_mode;
95+
#endif // NAPI_EXPERIMENTAL
96+
7897
typedef napi_value (*napi_callback)(napi_env env,
7998
napi_callback_info info);
8099
typedef void (*napi_finalize)(napi_env env,
@@ -86,6 +105,13 @@ typedef void (*napi_async_complete_callback)(napi_env env,
86105
napi_status status,
87106
void* data);
88107

108+
#ifdef NAPI_EXPERIMENTAL
109+
typedef void (*napi_threadsafe_function_call_js)(napi_env env,
110+
napi_value js_callback,
111+
void* context,
112+
void* data);
113+
#endif // NAPI_EXPERIMENTAL
114+
89115
typedef struct {
90116
// One of utf8name or name should be NULL.
91117
const char* utf8name;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
// For the purpose of this test we use libuv's threading library. When deciding
2+
// on a threading library for a new project it bears remembering that in the
3+
// future libuv may introduce API changes which may render it non-ABI-stable,
4+
// which, in turn, may affect the ABI stability of the project despite its use
5+
// of N-API.
6+
#include <uv.h>
7+
#define NAPI_EXPERIMENTAL
8+
#include <node_api.h>
9+
#include "../common.h"
10+
11+
#define ARRAY_LENGTH 10
12+
13+
static uv_thread_t uv_threads[2];
14+
static napi_threadsafe_function ts_fn;
15+
16+
typedef struct {
17+
napi_threadsafe_function_call_mode block_on_full;
18+
napi_threadsafe_function_release_mode abort;
19+
bool start_secondary;
20+
napi_ref js_finalize_cb;
21+
} ts_fn_hint;
22+
23+
static ts_fn_hint ts_info;
24+
25+
// Thread data to transmit to JS
26+
static int ints[ARRAY_LENGTH];
27+
28+
static void secondary_thread(void* data) {
29+
napi_threadsafe_function ts_fn = data;
30+
31+
if (napi_release_threadsafe_function(ts_fn, napi_tsfn_release) != napi_ok) {
32+
napi_fatal_error("secondary_thread", NAPI_AUTO_LENGTH,
33+
"napi_release_threadsafe_function failed", NAPI_AUTO_LENGTH);
34+
}
35+
}
36+
37+
// Source thread producing the data
38+
static void data_source_thread(void* data) {
39+
napi_threadsafe_function ts_fn = data;
40+
int index;
41+
void* hint;
42+
ts_fn_hint *ts_fn_info;
43+
napi_status status;
44+
bool queue_was_full = false;
45+
bool queue_was_closing = false;
46+
47+
if (napi_get_threadsafe_function_context(ts_fn, &hint) != napi_ok) {
48+
napi_fatal_error("data_source_thread", NAPI_AUTO_LENGTH,
49+
"napi_get_threadsafe_function_context failed", NAPI_AUTO_LENGTH);
50+
}
51+
52+
ts_fn_info = (ts_fn_hint *)hint;
53+
54+
if (ts_fn_info != &ts_info) {
55+
napi_fatal_error("data_source_thread", NAPI_AUTO_LENGTH,
56+
"thread-safe function hint is not as expected", NAPI_AUTO_LENGTH);
57+
}
58+
59+
if (ts_fn_info->start_secondary) {
60+
if (napi_acquire_threadsafe_function(ts_fn) != napi_ok) {
61+
napi_fatal_error("data_source_thread", NAPI_AUTO_LENGTH,
62+
"napi_acquire_threadsafe_function failed", NAPI_AUTO_LENGTH);
63+
}
64+
65+
if (uv_thread_create(&uv_threads[1], secondary_thread, ts_fn) != 0) {
66+
napi_fatal_error("data_source_thread", NAPI_AUTO_LENGTH,
67+
"failed to start secondary thread", NAPI_AUTO_LENGTH);
68+
}
69+
}
70+
71+
for (index = ARRAY_LENGTH - 1; index > -1 && !queue_was_closing; index--) {
72+
status = napi_call_threadsafe_function(ts_fn, &ints[index],
73+
ts_fn_info->block_on_full);
74+
switch (status) {
75+
case napi_queue_full:
76+
queue_was_full = true;
77+
index++;
78+
// fall through
79+
80+
case napi_ok:
81+
continue;
82+
83+
case napi_closing:
84+
queue_was_closing = true;
85+
break;
86+
87+
default:
88+
napi_fatal_error("data_source_thread", NAPI_AUTO_LENGTH,
89+
"napi_call_threadsafe_function failed", NAPI_AUTO_LENGTH);
90+
}
91+
}
92+
93+
// Assert that the enqueuing of a value was refused at least once, if this is
94+
// a non-blocking test run.
95+
if (!ts_fn_info->block_on_full && !queue_was_full) {
96+
napi_fatal_error("data_source_thread", NAPI_AUTO_LENGTH,
97+
"queue was never full", NAPI_AUTO_LENGTH);
98+
}
99+
100+
// Assert that the queue was marked as closing at least once, if this is an
101+
// aborting test run.
102+
if (ts_fn_info->abort == napi_tsfn_abort && !queue_was_closing) {
103+
napi_fatal_error("data_source_thread", NAPI_AUTO_LENGTH,
104+
"queue was never closing", NAPI_AUTO_LENGTH);
105+
}
106+
107+
if (!queue_was_closing &&
108+
napi_release_threadsafe_function(ts_fn, napi_tsfn_release) != napi_ok) {
109+
napi_fatal_error("data_source_thread", NAPI_AUTO_LENGTH,
110+
"napi_release_threadsafe_function failed", NAPI_AUTO_LENGTH);
111+
}
112+
}
113+
114+
// Getting the data into JS
115+
static void call_js(napi_env env, napi_value cb, void* hint, void* data) {
116+
if (!(env == NULL || cb == NULL)) {
117+
napi_value argv, undefined;
118+
NAPI_CALL_RETURN_VOID(env, napi_create_int32(env, *(int*)data, &argv));
119+
NAPI_CALL_RETURN_VOID(env, napi_get_undefined(env, &undefined));
120+
NAPI_CALL_RETURN_VOID(env, napi_call_function(env, undefined, cb, 1, &argv,
121+
NULL));
122+
}
123+
}
124+
125+
// Cleanup
126+
static napi_value StopThread(napi_env env, napi_callback_info info) {
127+
size_t argc = 2;
128+
napi_value argv[2];
129+
NAPI_CALL(env, napi_get_cb_info(env, info, &argc, argv, NULL, NULL));
130+
napi_valuetype value_type;
131+
NAPI_CALL(env, napi_typeof(env, argv[0], &value_type));
132+
NAPI_ASSERT(env, value_type == napi_function,
133+
"StopThread argument is a function");
134+
NAPI_ASSERT(env, (ts_fn != NULL), "Existing threadsafe function");
135+
NAPI_CALL(env,
136+
napi_create_reference(env, argv[0], 1, &(ts_info.js_finalize_cb)));
137+
bool abort;
138+
NAPI_CALL(env, napi_get_value_bool(env, argv[1], &abort));
139+
NAPI_CALL(env,
140+
napi_release_threadsafe_function(ts_fn,
141+
abort ? napi_tsfn_abort : napi_tsfn_release));
142+
ts_fn = NULL;
143+
return NULL;
144+
}
145+
146+
// Join the thread and inform JS that we're done.
147+
static void join_the_threads(napi_env env, void *data, void *hint) {
148+
uv_thread_t *the_threads = data;
149+
ts_fn_hint *the_hint = hint;
150+
napi_value js_cb, undefined;
151+
152+
uv_thread_join(&the_threads[0]);
153+
if (the_hint->start_secondary) {
154+
uv_thread_join(&the_threads[1]);
155+
}
156+
157+
NAPI_CALL_RETURN_VOID(env,
158+
napi_get_reference_value(env, the_hint->js_finalize_cb, &js_cb));
159+
NAPI_CALL_RETURN_VOID(env, napi_get_undefined(env, &undefined));
160+
NAPI_CALL_RETURN_VOID(env,
161+
napi_call_function(env, undefined, js_cb, 0, NULL, NULL));
162+
NAPI_CALL_RETURN_VOID(env, napi_delete_reference(env,
163+
the_hint->js_finalize_cb));
164+
}
165+
166+
static napi_value StartThreadInternal(napi_env env,
167+
napi_callback_info info,
168+
napi_threadsafe_function_call_js cb,
169+
bool block_on_full) {
170+
size_t argc = 3;
171+
napi_value argv[3];
172+
173+
ts_info.block_on_full =
174+
(block_on_full ? napi_tsfn_blocking : napi_tsfn_nonblocking);
175+
176+
NAPI_ASSERT(env, (ts_fn == NULL), "Existing thread-safe function");
177+
NAPI_CALL(env, napi_get_cb_info(env, info, &argc, argv, NULL, NULL));
178+
napi_value async_name;
179+
NAPI_CALL(env, napi_create_string_utf8(env, "N-API Thread-safe Function Test",
180+
NAPI_AUTO_LENGTH, &async_name));
181+
NAPI_CALL(env, napi_create_threadsafe_function(env, argv[0], NULL, async_name,
182+
2, 2, uv_threads, join_the_threads, &ts_info, cb, &ts_fn));
183+
bool abort;
184+
NAPI_CALL(env, napi_get_value_bool(env, argv[1], &abort));
185+
ts_info.abort = abort ? napi_tsfn_abort : napi_tsfn_release;
186+
NAPI_CALL(env, napi_get_value_bool(env, argv[2], &(ts_info.start_secondary)));
187+
188+
NAPI_ASSERT(env,
189+
(uv_thread_create(&uv_threads[0], data_source_thread, ts_fn) == 0),
190+
"Thread creation");
191+
192+
return NULL;
193+
}
194+
195+
static napi_value Unref(napi_env env, napi_callback_info info) {
196+
NAPI_ASSERT(env, ts_fn != NULL, "No existing thread-safe function");
197+
NAPI_CALL(env, napi_unref_threadsafe_function(env, ts_fn));
198+
return NULL;
199+
}
200+
201+
static napi_value Release(napi_env env, napi_callback_info info) {
202+
NAPI_ASSERT(env, ts_fn != NULL, "No existing thread-safe function");
203+
NAPI_CALL(env, napi_release_threadsafe_function(ts_fn, napi_tsfn_release));
204+
return NULL;
205+
}
206+
207+
// Startup
208+
static napi_value StartThread(napi_env env, napi_callback_info info) {
209+
return StartThreadInternal(env, info, call_js, true);
210+
}
211+
212+
static napi_value StartThreadNonblocking(napi_env env,
213+
napi_callback_info info) {
214+
return StartThreadInternal(env, info, call_js, false);
215+
}
216+
217+
static napi_value StartThreadNoNative(napi_env env, napi_callback_info info) {
218+
return StartThreadInternal(env, info, NULL, true);
219+
}
220+
221+
// Module init
222+
static napi_value Init(napi_env env, napi_value exports) {
223+
size_t index;
224+
for (index = 0; index < ARRAY_LENGTH; index++) {
225+
ints[index] = index;
226+
}
227+
napi_value js_array_length;
228+
napi_create_uint32(env, ARRAY_LENGTH, &js_array_length);
229+
230+
napi_property_descriptor properties[] = {
231+
{
232+
"ARRAY_LENGTH",
233+
NULL,
234+
NULL,
235+
NULL,
236+
NULL,
237+
js_array_length,
238+
napi_enumerable,
239+
NULL
240+
},
241+
DECLARE_NAPI_PROPERTY("StartThread", StartThread),
242+
DECLARE_NAPI_PROPERTY("StartThreadNoNative", StartThreadNoNative),
243+
DECLARE_NAPI_PROPERTY("StartThreadNonblocking", StartThreadNonblocking),
244+
DECLARE_NAPI_PROPERTY("StopThread", StopThread),
245+
DECLARE_NAPI_PROPERTY("Unref", Unref),
246+
DECLARE_NAPI_PROPERTY("Release", Release),
247+
};
248+
249+
NAPI_CALL(env, napi_define_properties(env, exports,
250+
sizeof(properties)/sizeof(properties[0]), properties));
251+
252+
return exports;
253+
}
254+
NAPI_MODULE(NODE_GYP_MODULE_NAME, Init)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
'targets': [
3+
{
4+
'target_name': 'binding',
5+
'sources': ['binding.c']
6+
}
7+
]
8+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
'use strict';
2+
3+
const common = require('../../common');
4+
const assert = require('assert');
5+
const binding = require(`./build/${common.buildType}/binding`);
6+
const { fork } = require('child_process');
7+
const expectedArray = (function(arrayLength) {
8+
const result = [];
9+
for (let index = 0; index < arrayLength; index++) {
10+
result.push(arrayLength - 1 - index);
11+
}
12+
return result;
13+
})(binding.ARRAY_LENGTH);
14+
15+
common.crashOnUnhandledRejection();
16+
17+
// Handle the rapid teardown test case as the child process. We unref the
18+
// thread-safe function after we have received two values. This causes the
19+
// process to exit and the environment cleanup handler to be invoked.
20+
if (process.argv[2] === 'child') {
21+
let callCount = 0;
22+
binding.StartThread((value) => {
23+
callCount++;
24+
console.log(value);
25+
if (callCount === 2) {
26+
binding.Unref();
27+
}
28+
}, false /* abort */, true /* launchSecondary */);
29+
30+
// Release the thread-safe function from the main thread so that it may be
31+
// torn down via the environment cleanup handler.
32+
binding.Release();
33+
return;
34+
}
35+
36+
function testWithJSMarshaller({
37+
threadStarter,
38+
quitAfter,
39+
abort,
40+
launchSecondary }) {
41+
return new Promise((resolve) => {
42+
const array = [];
43+
binding[threadStarter](function testCallback(value) {
44+
array.push(value);
45+
if (array.length === quitAfter) {
46+
setImmediate(() => {
47+
binding.StopThread(common.mustCall(() => {
48+
resolve(array);
49+
}), !!abort);
50+
});
51+
}
52+
}, !!abort, !!launchSecondary);
53+
if (threadStarter === 'StartThreadNonblocking') {
54+
// Let's make this thread really busy for a short while to ensure that
55+
// the queue fills and the thread receives a napi_queue_full.
56+
const start = Date.now();
57+
while (Date.now() - start < 200);
58+
}
59+
});
60+
}
61+
62+
new Promise(function testWithoutJSMarshaller(resolve) {
63+
let callCount = 0;
64+
binding.StartThreadNoNative(function testCallback() {
65+
callCount++;
66+
67+
// The default call-into-JS implementation passes no arguments.
68+
assert.strictEqual(arguments.length, 0);
69+
if (callCount === binding.ARRAY_LENGTH) {
70+
setImmediate(() => {
71+
binding.StopThread(common.mustCall(() => {
72+
resolve();
73+
}), false);
74+
});
75+
}
76+
}, false /* abort */, false /* launchSecondary */);
77+
})
78+
79+
// Start the thread in blocking mode, and assert that all values are passed.
80+
// Quit after it's done.
81+
.then(() => testWithJSMarshaller({
82+
threadStarter: 'StartThread',
83+
quitAfter: binding.ARRAY_LENGTH
84+
}))
85+
.then((result) => assert.deepStrictEqual(result, expectedArray))
86+
87+
// Start the thread in non-blocking mode, and assert that all values are passed.
88+
// Quit after it's done.
89+
.then(() => testWithJSMarshaller({
90+
threadStarter: 'StartThreadNonblocking',
91+
quitAfter: binding.ARRAY_LENGTH
92+
}))
93+
.then((result) => assert.deepStrictEqual(result, expectedArray))
94+
95+
// Start the thread in blocking mode, and assert that all values are passed.
96+
// Quit early, but let the thread finish.
97+
.then(() => testWithJSMarshaller({
98+
threadStarter: 'StartThread',
99+
quitAfter: 1
100+
}))
101+
.then((result) => assert.deepStrictEqual(result, expectedArray))
102+
103+
// Start the thread in non-blocking mode, and assert that all values are passed.
104+
// Quit early, but let the thread finish.
105+
.then(() => testWithJSMarshaller({
106+
threadStarter: 'StartThreadNonblocking',
107+
quitAfter: 1
108+
}))
109+
.then((result) => assert.deepStrictEqual(result, expectedArray))
110+
111+
// Start the thread in blocking mode, and assert that all values are passed.
112+
// Quit early, but let the thread finish. Launch a secondary thread to test the
113+
// reference counter incrementing functionality.
114+
.then(() => testWithJSMarshaller({
115+
threadStarter: 'StartThread',
116+
quitAfter: 1,
117+
launchSecondary: true
118+
}))
119+
.then((result) => assert.deepStrictEqual(result, expectedArray))
120+
121+
// Start the thread in non-blocking mode, and assert that all values are passed.
122+
// Quit early, but let the thread finish. Launch a secondary thread to test the
123+
// reference counter incrementing functionality.
124+
.then(() => testWithJSMarshaller({
125+
threadStarter: 'StartThreadNonblocking',
126+
quitAfter: 1,
127+
launchSecondary: true
128+
}))
129+
.then((result) => assert.deepStrictEqual(result, expectedArray))
130+
131+
// Start the thread in blocking mode, and assert that it could not finish.
132+
// Quit early and aborting.
133+
.then(() => testWithJSMarshaller({
134+
threadStarter: 'StartThread',
135+
quitAfter: 1,
136+
abort: true
137+
}))
138+
.then((result) => assert.strictEqual(result.indexOf(0), -1))
139+
140+
// Start the thread in non-blocking mode, and assert that it could not finish.
141+
// Quit early and aborting.
142+
.then(() => testWithJSMarshaller({
143+
threadStarter: 'StartThreadNonblocking',
144+
quitAfter: 1,
145+
abort: true
146+
}))
147+
.then((result) => assert.strictEqual(result.indexOf(0), -1))
148+
149+
// Start a child process to test rapid teardown
150+
.then(() => {
151+
return new Promise((resolve, reject) => {
152+
let output = '';
153+
const child = fork(__filename, ['child'], {
154+
stdio: [process.stdin, 'pipe', process.stderr, 'ipc']
155+
});
156+
child.on('close', (code) => {
157+
if (code === 0) {
158+
resolve(output.match(/\S+/g));
159+
} else {
160+
reject(new Error('Child process died with code ' + code));
161+
}
162+
});
163+
child.stdout.on('data', (data) => (output += data.toString()));
164+
});
165+
})
166+
.then((result) => assert.strictEqual(result.indexOf(0), -1));

0 commit comments

Comments
 (0)
Please sign in to comment.