Skip to content

Commit da4f236

Browse files
jstewmonsindresorhus
authored andcommittedJul 25, 2018
Customize timeouts and generally improve the whole thing (#534)
- Refactor timed-out to optimistically defer errors until after the poll phase of the current event loop tick. - Timeouts begin and end when their respective phase of the lifecycle begins and ends. - Timeouts result in a `got.TimeoutError`
1 parent 8cccd8a commit da4f236

8 files changed

+401
-111
lines changed
 

‎readme.md

+8-1
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,14 @@ Type: `number` `Object`
188188

189189
Milliseconds to wait for the server to end the response before aborting request with `ETIMEDOUT` error (a.k.a. `request` property). By default there's no timeout.
190190

191-
This also accepts an object with separate `connect`, `socket`, and `request` fields for connection, socket, and entire request timeouts.
191+
This also accepts an object with separate `lookup`, `connect`, `socket`, `response` and `request` fields to specify granular timeouts for each phase of the request.
192+
193+
- `lookup` starts when a socket is assigned and ends when the hostname has been resolved. Does not apply when using a Unix domain socket.
194+
- `connect` starts when `lookup` completes (or when the socket is assigned if lookup does not apply to the request) and ends when the socket is connected.
195+
- `socket` starts when the socket is connected. See [request.setTimeout](https://nodejs.org/api/http.html#http_request_settimeout_timeout_callback).
196+
- `response` starts when the request has been written to the socket and ends when the response headers are received.
197+
- `send` starts when the socket is connected and ends with the request has been written to the socket.
198+
- `request` starts when the request is initiated and ends when the response's end event fires.
192199

193200
###### retry
194201

‎source/as-promise.js

+6-1
Original file line numberDiff line numberDiff line change
@@ -24,17 +24,22 @@ module.exports = options => {
2424

2525
proxy.emit('request', req);
2626

27+
const uploadComplete = () => {
28+
req.emit('upload-complete');
29+
};
30+
2731
onCancel(() => {
2832
req.abort();
2933
});
3034

3135
if (is.nodeStream(options.body)) {
36+
options.body.once('end', uploadComplete);
3237
options.body.pipe(req);
3338
options.body = undefined;
3439
return;
3540
}
3641

37-
req.end(options.body);
42+
req.end(options.body, uploadComplete);
3843
});
3944

4045
emitter.on('response', async response => {

‎source/as-stream.js

+7-2
Original file line numberDiff line numberDiff line change
@@ -28,23 +28,28 @@ module.exports = options => {
2828

2929
emitter.on('request', req => {
3030
proxy.emit('request', req);
31+
const uploadComplete = () => {
32+
req.emit('upload-complete');
33+
};
3134

3235
if (is.nodeStream(options.body)) {
36+
options.body.once('end', uploadComplete);
3337
options.body.pipe(req);
3438
return;
3539
}
3640

3741
if (options.body) {
38-
req.end(options.body);
42+
req.end(options.body, uploadComplete);
3943
return;
4044
}
4145

4246
if (options.method === 'POST' || options.method === 'PUT' || options.method === 'PATCH') {
47+
input.once('end', uploadComplete);
4348
input.pipe(req);
4449
return;
4550
}
4651

47-
req.end();
52+
req.end(uploadComplete);
4853
});
4954

5055
emitter.on('response', response => {

‎source/errors.js

+9
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ class GotError extends Error {
1919
hostname: opts.hostname,
2020
method: opts.method,
2121
path: opts.path,
22+
socketPath: opts.socketPath,
2223
protocol: opts.protocol,
2324
url: opts.href
2425
});
@@ -89,4 +90,12 @@ module.exports.UnsupportedProtocolError = class extends GotError {
8990
}
9091
};
9192

93+
module.exports.TimeoutError = class extends GotError {
94+
constructor(threshold, event, opts) {
95+
super(`Timeout awaiting '${event}' for ${threshold}ms`, {code: 'ETIMEDOUT'}, opts);
96+
this.name = 'TimeoutError';
97+
this.event = event;
98+
}
99+
};
100+
92101
module.exports.CancelError = PCancelable.CancelError;

‎source/request-as-event-emitter.js

+13-15
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ const timedOut = require('./timed-out');
1010
const getBodySize = require('./get-body-size');
1111
const getResponse = require('./get-response');
1212
const progress = require('./progress');
13-
const {CacheError, UnsupportedProtocolError, MaxRedirectsError, RequestError} = require('./errors');
13+
const {GotError, CacheError, UnsupportedProtocolError, MaxRedirectsError, RequestError} = require('./errors');
1414

1515
const getMethodRedirectCodes = new Set([300, 301, 302, 303, 304, 305, 307, 308]);
1616
const allMethodRedirectCodes = new Set([300, 303, 307, 308]);
@@ -91,13 +91,11 @@ module.exports = (options = {}) => {
9191
return;
9292
}
9393

94-
setImmediate(() => {
95-
try {
96-
getResponse(response, options, emitter, redirects);
97-
} catch (error) {
98-
emitter.emit('error', error);
99-
}
100-
});
94+
try {
95+
getResponse(response, options, emitter, redirects);
96+
} catch (error) {
97+
emitter.emit('error', error);
98+
}
10199
});
102100

103101
cacheReq.on('error', error => {
@@ -119,23 +117,23 @@ module.exports = (options = {}) => {
119117
return;
120118
}
121119

122-
const err = new RequestError(error, options);
123-
emitter.emit('retry', err, retried => {
120+
if (!(error instanceof GotError)) {
121+
error = new RequestError(error, options);
122+
}
123+
emitter.emit('retry', error, retried => {
124124
if (!retried) {
125-
emitter.emit('error', err);
125+
emitter.emit('error', error);
126126
}
127127
});
128128
});
129129

130130
progress.upload(req, emitter, uploadBodySize);
131131

132132
if (options.gotTimeout) {
133-
timedOut(req, options.gotTimeout);
133+
timedOut(req, options);
134134
}
135135

136-
setImmediate(() => {
137-
emitter.emit('request', req);
138-
});
136+
emitter.emit('request', req);
139137
});
140138
};
141139

‎source/timed-out.js

+115-63
Original file line numberDiff line numberDiff line change
@@ -1,79 +1,131 @@
11
'use strict';
2+
const net = require('net');
3+
const {TimeoutError} = require('./errors');
24

3-
// Forked from https://github.com/floatdrop/timed-out
5+
const reentry = Symbol('reentry');
46

5-
module.exports = function (req, delays) {
6-
if (req.timeoutTimer) {
7-
return req;
7+
function addTimeout(delay, callback, ...args) {
8+
// Event loop order is timers, poll, immediates.
9+
// The timed event may emit during the current tick poll phase, so
10+
// defer calling the handler until the poll phase completes.
11+
let immediate;
12+
const timeout = setTimeout(
13+
() => {
14+
immediate = setImmediate(callback, delay, ...args);
15+
if (immediate.unref) {
16+
// Added in node v9.7.0
17+
immediate.unref();
18+
}
19+
},
20+
delay
21+
);
22+
timeout.unref();
23+
return () => {
24+
clearTimeout(timeout);
25+
clearImmediate(immediate);
26+
};
27+
}
28+
29+
module.exports = function (req, options) {
30+
if (req[reentry]) {
31+
return;
832
}
33+
req[reentry] = true;
34+
const {gotTimeout: delays, host, hostname} = options;
35+
const timeoutHandler = (delay, event) => {
36+
req.abort();
37+
req.emit('error', new TimeoutError(delay, event, options));
38+
};
39+
const cancelers = [];
40+
const cancelTimeouts = () => {
41+
cancelers.forEach(cancelTimeout => cancelTimeout());
42+
};
943

10-
const host = req._headers ? (' to ' + req._headers.host) : '';
44+
req.on('error', cancelTimeouts);
45+
req.once('response', response => {
46+
response.once('end', cancelTimeouts);
47+
});
1148

12-
function throwESOCKETTIMEDOUT() {
13-
req.abort();
14-
const e = new Error('Socket timed out on request' + host);
15-
e.code = 'ESOCKETTIMEDOUT';
16-
req.emit('error', e);
49+
if (delays.request !== undefined) {
50+
const cancelTimeout = addTimeout(
51+
delays.request,
52+
timeoutHandler,
53+
'request'
54+
);
55+
cancelers.push(cancelTimeout);
1756
}
18-
19-
function throwETIMEDOUT() {
20-
req.abort();
21-
const e = new Error('Connection timed out on request' + host);
22-
e.code = 'ETIMEDOUT';
23-
req.emit('error', e);
57+
if (delays.socket !== undefined) {
58+
req.setTimeout(
59+
delays.socket,
60+
() => {
61+
timeoutHandler(delays.socket, 'socket');
62+
}
63+
);
64+
}
65+
if (delays.lookup !== undefined && !req.socketPath && !net.isIP(hostname || host)) {
66+
req.once('socket', socket => {
67+
if (socket.connecting) {
68+
const cancelTimeout = addTimeout(
69+
delays.lookup,
70+
timeoutHandler,
71+
'lookup'
72+
);
73+
cancelers.push(cancelTimeout);
74+
socket.once('lookup', cancelTimeout);
75+
}
76+
});
2477
}
25-
2678
if (delays.connect !== undefined) {
27-
req.timeoutTimer = setTimeout(throwETIMEDOUT, delays.connect);
79+
req.once('socket', socket => {
80+
if (socket.connecting) {
81+
const timeConnect = () => {
82+
const cancelTimeout = addTimeout(
83+
delays.connect,
84+
timeoutHandler,
85+
'connect'
86+
);
87+
cancelers.push(cancelTimeout);
88+
return cancelTimeout;
89+
};
90+
if (req.socketPath || net.isIP(hostname || host)) {
91+
socket.once('connect', timeConnect());
92+
} else {
93+
socket.once('lookup', () => {
94+
socket.once('connect', timeConnect());
95+
});
96+
}
97+
}
98+
});
2899
}
29-
30-
if (delays.request !== undefined) {
31-
req.requestTimeoutTimer = setTimeout(() => {
32-
clear();
33-
34-
if (req.connection.connecting) {
35-
throwETIMEDOUT();
100+
if (delays.send !== undefined) {
101+
req.once('socket', socket => {
102+
const timeRequest = () => {
103+
const cancelTimeout = addTimeout(
104+
delays.send,
105+
timeoutHandler,
106+
'send'
107+
);
108+
cancelers.push(cancelTimeout);
109+
return cancelTimeout;
110+
};
111+
if (socket.connecting) {
112+
socket.once('connect', () => {
113+
req.once('upload-complete', timeRequest());
114+
});
36115
} else {
37-
throwESOCKETTIMEDOUT();
116+
req.once('upload-complete', timeRequest());
38117
}
39-
}, delays.request);
40-
}
41-
42-
// Clear the connection timeout timer once a socket is assigned to the
43-
// request and is connected.
44-
req.on('socket', socket => {
45-
// Socket may come from Agent pool and may be already connected.
46-
if (!socket.connecting) {
47-
connect();
48-
return;
49-
}
50-
51-
socket.once('connect', connect);
52-
});
53-
54-
function clear() {
55-
if (req.timeoutTimer) {
56-
clearTimeout(req.timeoutTimer);
57-
req.timeoutTimer = null;
58-
}
118+
});
59119
}
60-
61-
function connect() {
62-
clear();
63-
64-
if (delays.socket !== undefined) {
65-
// Abort the request if there is no activity on the socket for more
66-
// than `delays.socket` milliseconds.
67-
req.setTimeout(delays.socket, throwESOCKETTIMEDOUT);
68-
}
69-
70-
req.on('response', res => {
71-
res.on('end', () => {
72-
// The request is finished, cancel request timeout.
73-
clearTimeout(req.requestTimeoutTimer);
74-
});
120+
if (delays.response !== undefined) {
121+
req.once('upload-complete', () => {
122+
const cancelTimeout = addTimeout(
123+
delays.response,
124+
timeoutHandler,
125+
'response'
126+
);
127+
cancelers.push(cancelTimeout);
128+
req.once('response', cancelTimeout);
75129
});
76130
}
77-
78-
return req.on('error', clear);
79131
};

‎test/retry.js

+6-7
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,7 @@ let fifth = 0;
1010
let lastTried413access = Date.now();
1111

1212
const retryAfterOn413 = 2;
13-
const connectTimeout = 500;
14-
const socketTimeout = 100;
13+
const socketTimeout = 200;
1514

1615
test.before('setup', async () => {
1716
s = await createServer();
@@ -69,12 +68,12 @@ test.before('setup', async () => {
6968
});
7069

7170
test('works on timeout error', async t => {
72-
t.is((await got(`${s.url}/knock-twice`, {timeout: {connect: connectTimeout, socket: socketTimeout}})).body, 'who`s there?');
71+
t.is((await got(`${s.url}/knock-twice`, {timeout: {socket: socketTimeout}})).body, 'who`s there?');
7372
});
7473

7574
test('can be disabled with option', async t => {
7675
const err = await t.throws(got(`${s.url}/try-me`, {
77-
timeout: {connect: connectTimeout, socket: socketTimeout},
76+
timeout: {socket: socketTimeout},
7877
retry: 0
7978
}));
8079
t.truthy(err);
@@ -83,7 +82,7 @@ test('can be disabled with option', async t => {
8382

8483
test('function gets iter count', async t => {
8584
await got(`${s.url}/fifth`, {
86-
timeout: {connect: connectTimeout, socket: socketTimeout},
85+
timeout: {socket: socketTimeout},
8786
retry: {
8887
retries: iteration => iteration < 10
8988
}
@@ -93,7 +92,7 @@ test('function gets iter count', async t => {
9392

9493
test('falsy value prevents retries', async t => {
9594
const err = await t.throws(got(`${s.url}/long`, {
96-
timeout: {connect: connectTimeout, socket: socketTimeout},
95+
timeout: {socket: socketTimeout},
9796
retry: {
9897
retries: () => 0
9998
}
@@ -103,7 +102,7 @@ test('falsy value prevents retries', async t => {
103102

104103
test('falsy value prevents retries #2', async t => {
105104
const err = await t.throws(got(`${s.url}/long`, {
106-
timeout: {connect: connectTimeout, socket: socketTimeout},
105+
timeout: {socket: socketTimeout},
107106
retry: {
108107
retries: (iter, err) => {
109108
t.truthy(err);

‎test/timeout.js

+237-22
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import http from 'http';
2+
import net from 'net';
3+
import stream from 'stream';
14
import getStream from 'get-stream';
25
import test from 'ava';
36
import pEvent from 'p-event';
@@ -6,14 +9,48 @@ import got from '../source';
69
import {createServer} from './helpers/server';
710

811
let s;
9-
const reqDelay = 160;
12+
const reqDelay = 250;
13+
const slowDataStream = () => {
14+
const slowStream = new stream.PassThrough();
15+
let count = 0;
16+
const interval = setInterval(() => {
17+
if (count++ < 10) {
18+
return slowStream.push('data\n'.repeat(100));
19+
}
20+
clearInterval(interval);
21+
slowStream.push(null);
22+
}, 100);
23+
return slowStream;
24+
};
25+
const reqTimeout = reqDelay - 10;
26+
const errorMatcher = {
27+
instanceOf: got.TimeoutError,
28+
code: 'ETIMEDOUT'
29+
};
30+
const keepAliveAgent = new http.Agent({
31+
keepAlive: true
32+
});
1033

1134
test.before('setup', async () => {
1235
s = await createServer();
1336

1437
s.on('/', async (req, res) => {
15-
await delay(reqDelay);
16-
res.statusCode = 200;
38+
req.on('data', () => {});
39+
req.on('end', async () => {
40+
await delay(reqDelay);
41+
res.end('OK');
42+
});
43+
});
44+
45+
s.on('/download', async (req, res) => {
46+
res.writeHead(200, {
47+
'transfer-encoding': 'chunked'
48+
});
49+
res.flushHeaders();
50+
slowDataStream().pipe(res);
51+
});
52+
53+
s.on('/prime', (req, res) => {
1754
res.end('OK');
1855
});
1956

@@ -27,69 +64,246 @@ test('timeout option (ETIMEDOUT)', async t => {
2764
retry: 0
2865
}),
2966
{
30-
code: 'ETIMEDOUT'
67+
...errorMatcher,
68+
message: `Timeout awaiting 'request' for 0ms`
3169
}
3270
);
3371
});
3472

35-
test('timeout option (ESOCKETTIMEDOUT)', async t => {
73+
test('timeout option as object (ETIMEDOUT)', async t => {
3674
await t.throws(
3775
got(s.url, {
38-
timeout: reqDelay,
76+
timeout: {socket: reqDelay * 2.5, request: 1},
3977
retry: 0
4078
}),
4179
{
42-
code: 'ESOCKETTIMEDOUT'
80+
...errorMatcher,
81+
message: `Timeout awaiting 'request' for 1ms`
4382
}
4483
);
4584
});
4685

47-
test('timeout option as object (ETIMEDOUT)', async t => {
86+
test('socket timeout', async t => {
4887
await t.throws(
4988
got(s.url, {
50-
timeout: {socket: reqDelay * 2.5, request: 0},
89+
timeout: {socket: reqTimeout},
5190
retry: 0
5291
}),
5392
{
54-
code: 'ETIMEDOUT'
93+
instanceOf: got.TimeoutError,
94+
code: 'ETIMEDOUT',
95+
message: `Timeout awaiting 'socket' for ${reqTimeout}ms`
5596
}
5697
);
5798
});
5899

59-
test('timeout option as object (ESOCKETTIMEDOUT)', async t => {
100+
test('send timeout', async t => {
60101
await t.throws(
61102
got(s.url, {
62-
timeout: {socket: reqDelay * 1.5, request: reqDelay},
103+
timeout: {send: 1},
63104
retry: 0
64105
}),
65106
{
66-
code: 'ESOCKETTIMEDOUT'
107+
...errorMatcher,
108+
message: `Timeout awaiting 'send' for 1ms`
67109
}
68110
);
69111
});
70112

71-
test('socket timeout', async t => {
113+
test('send timeout (keepalive)', async t => {
114+
await got(`${s.url}/prime`, {agent: keepAliveAgent});
115+
await t.throws(
116+
got(s.url, {
117+
agent: keepAliveAgent,
118+
timeout: {send: 1},
119+
retry: 0,
120+
body: slowDataStream()
121+
}).on('request', req => {
122+
req.once('socket', socket => {
123+
t.false(socket.connecting);
124+
socket.once('connect', () => {
125+
t.fail(`'connect' event fired, invalidating test`);
126+
});
127+
});
128+
}),
129+
{
130+
...errorMatcher,
131+
message: `Timeout awaiting 'send' for 1ms`
132+
}
133+
);
134+
});
135+
136+
test('response timeout', async t => {
72137
await t.throws(
73138
got(s.url, {
74-
timeout: {socket: reqDelay / 20},
139+
timeout: {response: 1},
75140
retry: 0
76141
}),
77142
{
78-
code: 'ESOCKETTIMEDOUT'
143+
...errorMatcher,
144+
message: `Timeout awaiting 'response' for 1ms`
79145
}
80146
);
81147
});
82148

83-
test.todo('connection timeout');
149+
test('response timeout unaffected by slow upload', async t => {
150+
await got(s.url, {
151+
timeout: {response: reqDelay * 2},
152+
retry: 0,
153+
body: slowDataStream()
154+
}).on('request', request => {
155+
request.on('error', error => {
156+
t.fail(`unexpected error: ${error}`);
157+
});
158+
});
159+
await delay(reqDelay * 3);
160+
t.pass('no error emitted');
161+
});
162+
163+
test('response timeout unaffected by slow download', async t => {
164+
await got(`${s.url}/download`, {
165+
timeout: {response: 100},
166+
retry: 0
167+
}).on('request', request => {
168+
request.on('error', error => {
169+
t.fail(`unexpected error: ${error}`);
170+
});
171+
});
172+
await delay(reqDelay * 3);
173+
t.pass('no error emitted');
174+
});
175+
176+
test('response timeout (keepalive)', async t => {
177+
await got(`${s.url}/prime`, {agent: keepAliveAgent});
178+
await delay(100);
179+
const request = got(s.url, {
180+
agent: keepAliveAgent,
181+
timeout: {response: 1},
182+
retry: 0
183+
}).on('request', req => {
184+
req.once('socket', socket => {
185+
t.false(socket.connecting);
186+
socket.once('connect', () => {
187+
t.fail(`'connect' event fired, invalidating test`);
188+
});
189+
});
190+
});
191+
await t.throws(request, {
192+
...errorMatcher,
193+
message: `Timeout awaiting 'response' for 1ms`
194+
});
195+
});
196+
197+
test('connect timeout', async t => {
198+
await t.throws(
199+
got({
200+
host: s.host,
201+
port: s.port,
202+
createConnection: options => {
203+
const socket = new net.Socket(options);
204+
socket.connecting = true;
205+
setImmediate(
206+
socket.emit.bind(socket),
207+
'lookup',
208+
null,
209+
'127.0.0.1',
210+
4,
211+
'localhost'
212+
);
213+
return socket;
214+
}
215+
}, {
216+
timeout: {connect: 1},
217+
retry: 0
218+
}),
219+
{
220+
...errorMatcher,
221+
message: `Timeout awaiting 'connect' for 1ms`
222+
}
223+
);
224+
});
225+
226+
test('connect timeout (ip address)', async t => {
227+
await t.throws(
228+
got({
229+
hostname: '127.0.0.1',
230+
port: s.port,
231+
createConnection: options => {
232+
const socket = new net.Socket(options);
233+
socket.connecting = true;
234+
return socket;
235+
}
236+
}, {
237+
timeout: {connect: 1},
238+
retry: 0
239+
}),
240+
{
241+
...errorMatcher,
242+
message: `Timeout awaiting 'connect' for 1ms`
243+
}
244+
);
245+
});
246+
247+
test('lookup timeout', async t => {
248+
await t.throws(
249+
got({
250+
host: s.host,
251+
port: s.port,
252+
lookup: () => { }
253+
}, {
254+
timeout: {lookup: 1},
255+
retry: 0
256+
}),
257+
{
258+
...errorMatcher,
259+
message: `Timeout awaiting 'lookup' for 1ms`
260+
}
261+
);
262+
});
263+
264+
test('lookup timeout no error (ip address)', async t => {
265+
await got({
266+
hostname: '127.0.0.1',
267+
port: s.port
268+
}, {
269+
timeout: {lookup: 100},
270+
retry: 0
271+
}).on('request', request => {
272+
request.on('error', error => {
273+
t.fail(`error emitted: ${error}`);
274+
});
275+
});
276+
await delay(100);
277+
t.pass('no error emitted');
278+
});
279+
280+
test('lookup timeout no error (keepalive)', async t => {
281+
await got(`${s.url}/prime`, {agent: keepAliveAgent});
282+
await got(s.url, {
283+
agent: keepAliveAgent,
284+
timeout: {lookup: 100},
285+
retry: 0
286+
}).on('request', request => {
287+
request.once('connect', () => {
288+
t.fail('connect event fired, invalidating test');
289+
});
290+
request.on('error', error => {
291+
t.fail(`error emitted: ${error}`);
292+
});
293+
});
294+
await delay(100);
295+
t.pass('no error emitted');
296+
});
84297

85298
test('request timeout', async t => {
86299
await t.throws(
87300
got(s.url, {
88-
timeout: {request: reqDelay},
301+
timeout: {request: reqTimeout},
89302
retry: 0
90303
}),
91304
{
92-
code: 'ESOCKETTIMEDOUT'
305+
...errorMatcher,
306+
message: `Timeout awaiting 'request' for ${reqTimeout}ms`
93307
}
94308
);
95309
});
@@ -98,7 +312,7 @@ test('retries on timeout (ESOCKETTIMEDOUT)', async t => {
98312
let tried = false;
99313

100314
await t.throws(got(s.url, {
101-
timeout: reqDelay,
315+
timeout: reqTimeout,
102316
retry: {
103317
retries: () => {
104318
if (tried) {
@@ -110,7 +324,8 @@ test('retries on timeout (ESOCKETTIMEDOUT)', async t => {
110324
}
111325
}
112326
}), {
113-
code: 'ESOCKETTIMEDOUT'
327+
...errorMatcher,
328+
message: `Timeout awaiting 'request' for ${reqTimeout}ms`
114329
});
115330

116331
t.true(tried);
@@ -131,7 +346,7 @@ test('retries on timeout (ETIMEDOUT)', async t => {
131346
return 1;
132347
}
133348
}
134-
}), {code: 'ETIMEDOUT'});
349+
}), {...errorMatcher});
135350

136351
t.true(tried);
137352
});

0 commit comments

Comments
 (0)
Please sign in to comment.