mirror of
https://github.com/zebrajr/node.git
synced 2025-12-06 00:20:08 +01:00
inspector: add http2 tracking support
This allows tracking HTTP/2 calls through the Network tab of Chrome DevTools for Node.js. Signed-off-by: Darshan Sen <raisinten@gmail.com> PR-URL: https://github.com/nodejs/node/pull/59611 Refs: https://github.com/nodejs/node/issues/53946 Reviewed-By: Ryuhei Shima <shimaryuhei@gmail.com> Reviewed-By: Chengzhong Wu <legendecas@gmail.com> Reviewed-By: Kohei Ueno <kohei.ueno119@gmail.com>
This commit is contained in:
parent
89067de391
commit
c86c488e18
190
lib/internal/inspector/network_http2.js
Normal file
190
lib/internal/inspector/network_http2.js
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
'use strict';
|
||||
|
||||
const {
|
||||
ArrayIsArray,
|
||||
DateNow,
|
||||
ObjectEntries,
|
||||
String,
|
||||
Symbol,
|
||||
} = primordials;
|
||||
|
||||
const {
|
||||
kInspectorRequestId,
|
||||
kResourceType,
|
||||
getMonotonicTime,
|
||||
getNextRequestId,
|
||||
sniffMimeType,
|
||||
} = require('internal/inspector/network');
|
||||
const dc = require('diagnostics_channel');
|
||||
const { Network } = require('inspector');
|
||||
const {
|
||||
HTTP2_HEADER_AUTHORITY,
|
||||
HTTP2_HEADER_CONTENT_TYPE,
|
||||
HTTP2_HEADER_COOKIE,
|
||||
HTTP2_HEADER_METHOD,
|
||||
HTTP2_HEADER_PATH,
|
||||
HTTP2_HEADER_SCHEME,
|
||||
HTTP2_HEADER_SET_COOKIE,
|
||||
HTTP2_HEADER_STATUS,
|
||||
NGHTTP2_NO_ERROR,
|
||||
} = internalBinding('http2').constants;
|
||||
|
||||
const kRequestUrl = Symbol('kRequestUrl');
|
||||
|
||||
// Convert a Headers object (Map<string, number | string | string[]>) to a plain object (Map<string, string>)
|
||||
function convertHeaderObject(headers = {}) {
|
||||
let scheme;
|
||||
let authority;
|
||||
let path;
|
||||
let method;
|
||||
let statusCode;
|
||||
let charset;
|
||||
let mimeType;
|
||||
const dict = {};
|
||||
|
||||
for (const { 0: key, 1: value } of ObjectEntries(headers)) {
|
||||
const lowerCasedKey = key.toLowerCase();
|
||||
|
||||
if (lowerCasedKey === HTTP2_HEADER_SCHEME) {
|
||||
scheme = value;
|
||||
} else if (lowerCasedKey === HTTP2_HEADER_AUTHORITY) {
|
||||
authority = value;
|
||||
} else if (lowerCasedKey === HTTP2_HEADER_PATH) {
|
||||
path = value;
|
||||
} else if (lowerCasedKey === HTTP2_HEADER_METHOD) {
|
||||
method = value;
|
||||
} else if (lowerCasedKey === HTTP2_HEADER_STATUS) {
|
||||
statusCode = value;
|
||||
} else if (lowerCasedKey === HTTP2_HEADER_CONTENT_TYPE) {
|
||||
const result = sniffMimeType(value);
|
||||
charset = result.charset;
|
||||
mimeType = result.mimeType;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
dict[key] = value;
|
||||
} else if (ArrayIsArray(value)) {
|
||||
if (lowerCasedKey === HTTP2_HEADER_COOKIE) dict[key] = value.join('; ');
|
||||
// ChromeDevTools frontend treats 'set-cookie' as a special case
|
||||
// https://github.com/ChromeDevTools/devtools-frontend/blob/4275917f84266ef40613db3c1784a25f902ea74e/front_end/core/sdk/NetworkRequest.ts#L1368
|
||||
else if (lowerCasedKey === HTTP2_HEADER_SET_COOKIE) dict[key] = value.join('\n');
|
||||
else dict[key] = value.join(', ');
|
||||
} else {
|
||||
dict[key] = String(value);
|
||||
}
|
||||
}
|
||||
|
||||
const url = `${scheme}://${authority}${path}`;
|
||||
|
||||
return [dict, url, method, statusCode, charset, mimeType];
|
||||
}
|
||||
|
||||
/**
|
||||
* When a client stream is created, emit Network.requestWillBeSent event.
|
||||
* https://chromedevtools.github.io/devtools-protocol/1-3/Network/#event-requestWillBeSent
|
||||
* @param {{ stream: import('http2').ClientHttp2Stream, headers: object }} event
|
||||
*/
|
||||
function onClientStreamCreated({ stream, headers }) {
|
||||
stream[kInspectorRequestId] = getNextRequestId();
|
||||
|
||||
const { 0: convertedHeaderObject, 1: url, 2: method, 4: charset } = convertHeaderObject(headers);
|
||||
stream[kRequestUrl] = url;
|
||||
|
||||
Network.requestWillBeSent({
|
||||
requestId: stream[kInspectorRequestId],
|
||||
timestamp: getMonotonicTime(),
|
||||
wallTime: DateNow(),
|
||||
charset,
|
||||
request: {
|
||||
url,
|
||||
method,
|
||||
headers: convertedHeaderObject,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* When a client stream errors, emit Network.loadingFailed event.
|
||||
* https://chromedevtools.github.io/devtools-protocol/1-3/Network/#event-loadingFailed
|
||||
* @param {{ stream: import('http2').ClientHttp2Stream, error: any }} event
|
||||
*/
|
||||
function onClientStreamError({ stream, error }) {
|
||||
if (typeof stream[kInspectorRequestId] !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
Network.loadingFailed({
|
||||
requestId: stream[kInspectorRequestId],
|
||||
timestamp: getMonotonicTime(),
|
||||
type: kResourceType.Other,
|
||||
errorText: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* When response headers are received, emit Network.responseReceived event.
|
||||
* https://chromedevtools.github.io/devtools-protocol/1-3/Network/#event-responseReceived
|
||||
* @param {{ stream: import('http2').ClientHttp2Stream, headers: object }} event
|
||||
*/
|
||||
function onClientStreamFinish({ stream, headers }) {
|
||||
if (typeof stream[kInspectorRequestId] !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
const { 0: convertedHeaderObject, 3: statusCode, 4: charset, 5: mimeType } = convertHeaderObject(headers);
|
||||
|
||||
Network.responseReceived({
|
||||
requestId: stream[kInspectorRequestId],
|
||||
timestamp: getMonotonicTime(),
|
||||
type: kResourceType.Other,
|
||||
response: {
|
||||
url: stream[kRequestUrl],
|
||||
status: statusCode,
|
||||
statusText: '',
|
||||
headers: convertedHeaderObject,
|
||||
mimeType,
|
||||
charset,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* When user code completes consuming the response body, emit Network.loadingFinished event.
|
||||
* https://chromedevtools.github.io/devtools-protocol/1-3/Network/#event-loadingFinished
|
||||
* @param {{ stream: import('http2').ClientHttp2Stream }} event
|
||||
*/
|
||||
function onClientStreamClose({ stream }) {
|
||||
if (typeof stream[kInspectorRequestId] !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (stream.rstCode !== NGHTTP2_NO_ERROR) {
|
||||
// This is an error case, so only Network.loadingFailed should be emitted
|
||||
// which is already done by onClientStreamError().
|
||||
return;
|
||||
}
|
||||
|
||||
Network.loadingFinished({
|
||||
requestId: stream[kInspectorRequestId],
|
||||
timestamp: getMonotonicTime(),
|
||||
});
|
||||
}
|
||||
|
||||
function enable() {
|
||||
dc.subscribe('http2.client.stream.created', onClientStreamCreated);
|
||||
dc.subscribe('http2.client.stream.error', onClientStreamError);
|
||||
dc.subscribe('http2.client.stream.finish', onClientStreamFinish);
|
||||
dc.subscribe('http2.client.stream.close', onClientStreamClose);
|
||||
}
|
||||
|
||||
function disable() {
|
||||
dc.unsubscribe('http2.client.stream.created', onClientStreamCreated);
|
||||
dc.unsubscribe('http2.client.stream.error', onClientStreamError);
|
||||
dc.unsubscribe('http2.client.stream.finish', onClientStreamFinish);
|
||||
dc.unsubscribe('http2.client.stream.close', onClientStreamClose);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
enable,
|
||||
disable,
|
||||
};
|
||||
|
|
@ -2,11 +2,13 @@
|
|||
|
||||
function enable() {
|
||||
require('internal/inspector/network_http').enable();
|
||||
require('internal/inspector/network_http2').enable();
|
||||
require('internal/inspector/network_undici').enable();
|
||||
}
|
||||
|
||||
function disable() {
|
||||
require('internal/inspector/network_http').disable();
|
||||
require('internal/inspector/network_http2').disable();
|
||||
require('internal/inspector/network_undici').disable();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -122,8 +122,8 @@ BuiltinLoader::BuiltinCategories BuiltinLoader::GetBuiltinCategories() const {
|
|||
#if !HAVE_INSPECTOR
|
||||
"inspector", "inspector/promises", "internal/util/inspector",
|
||||
"internal/inspector/network", "internal/inspector/network_http",
|
||||
"internal/inspector/network_undici", "internal/inspector_async_hook",
|
||||
"internal/inspector_network_tracking",
|
||||
"internal/inspector/network_http2", "internal/inspector/network_undici",
|
||||
"internal/inspector_async_hook", "internal/inspector_network_tracking",
|
||||
#endif // !HAVE_INSPECTOR
|
||||
|
||||
#if !NODE_USE_V8_PLATFORM || !defined(NODE_HAVE_I18N_SUPPORT)
|
||||
|
|
|
|||
302
test/parallel/test-inspector-network-http2.js
Normal file
302
test/parallel/test-inspector-network-http2.js
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
// Flags: --inspect=0 --experimental-network-inspection
|
||||
'use strict';
|
||||
const common = require('../common');
|
||||
if (!common.hasCrypto)
|
||||
common.skip('missing crypto');
|
||||
common.skipIfInspectorDisabled();
|
||||
|
||||
const assert = require('node:assert');
|
||||
const fixtures = require('../common/fixtures');
|
||||
const { on, once } = require('node:events');
|
||||
const http2 = require('node:http2');
|
||||
const inspector = require('node:inspector/promises');
|
||||
|
||||
const session = new inspector.Session();
|
||||
session.connect();
|
||||
|
||||
const requestHeaders = {
|
||||
'x-header1': ['value1', 'value2'],
|
||||
[http2.constants.HTTP2_HEADER_ACCEPT_LANGUAGE]: 'en-US',
|
||||
[http2.constants.HTTP2_HEADER_AGE]: 1000,
|
||||
[http2.constants.HTTP2_HEADER_COOKIE]: ['k1=v1', 'k2=v2'],
|
||||
[http2.constants.HTTP2_HEADER_METHOD]: 'GET',
|
||||
[http2.constants.HTTP2_HEADER_PATH]: '/hello-world',
|
||||
};
|
||||
|
||||
const requestErrorHeaders = {
|
||||
'x-header1': ['value1', 'value2'],
|
||||
[http2.constants.HTTP2_HEADER_ACCEPT_LANGUAGE]: 'en-US',
|
||||
[http2.constants.HTTP2_HEADER_AGE]: 1000,
|
||||
[http2.constants.HTTP2_HEADER_COOKIE]: ['k1=v1', 'k2=v2'],
|
||||
[http2.constants.HTTP2_HEADER_METHOD]: 'GET',
|
||||
[http2.constants.HTTP2_HEADER_PATH]: '/trigger-error',
|
||||
};
|
||||
|
||||
const responseHeaders = {
|
||||
'x-header2': ['value1', 'value2'],
|
||||
[http2.constants.HTTP2_HEADER_CONTENT_TYPE]: 'text/plain; charset=utf-8',
|
||||
[http2.constants.HTTP2_HEADER_ETAG]: 12345,
|
||||
[http2.constants.HTTP2_HEADER_SERVER]: 'node',
|
||||
[http2.constants.HTTP2_HEADER_SET_COOKIE]: ['key1=value1', 'key2=value2'],
|
||||
[http2.constants.HTTP2_HEADER_STATUS]: 200,
|
||||
};
|
||||
|
||||
const pushRequestHeaders = {
|
||||
'x-header3': ['value1', 'value2'],
|
||||
'x-push': 'true',
|
||||
[http2.constants.HTTP2_HEADER_PATH]: '/style.css',
|
||||
};
|
||||
|
||||
const pushResponseHeaders = {
|
||||
'x-header4': ['value1', 'value2'],
|
||||
'x-push': 'true',
|
||||
[http2.constants.HTTP2_HEADER_CONTENT_TYPE]: 'text/css',
|
||||
[http2.constants.HTTP2_HEADER_STATUS]: 200,
|
||||
};
|
||||
|
||||
const kTimeout = 1000;
|
||||
const kDelta = 200;
|
||||
|
||||
const handleStream = (stream, headers) => {
|
||||
const path = headers[http2.constants.HTTP2_HEADER_PATH];
|
||||
switch (path) {
|
||||
case '/hello-world':
|
||||
stream.pushStream(pushRequestHeaders, common.mustSucceed((pushStream) => {
|
||||
pushStream.respond(pushResponseHeaders);
|
||||
pushStream.end('body { color: red; }\n');
|
||||
}));
|
||||
|
||||
stream.respond(responseHeaders);
|
||||
|
||||
setTimeout(() => {
|
||||
stream.end('hello world\n');
|
||||
}, kTimeout);
|
||||
break;
|
||||
case '/trigger-error':
|
||||
stream.close(http2.constants.NGHTTP2_STREAM_CLOSED);
|
||||
stream.on('error', common.expectsError({
|
||||
code: 'ERR_HTTP2_STREAM_ERROR',
|
||||
name: 'Error',
|
||||
message: 'Stream closed with error code NGHTTP2_STREAM_CLOSED'
|
||||
}));
|
||||
break;
|
||||
default:
|
||||
assert(false, `Unexpected path: ${path}`);
|
||||
}
|
||||
};
|
||||
|
||||
const http2Server = http2.createServer();
|
||||
|
||||
const http2SecureServer = http2.createSecureServer({
|
||||
key: fixtures.readKey('agent1-key.pem'),
|
||||
cert: fixtures.readKey('agent1-cert.pem'),
|
||||
});
|
||||
|
||||
http2Server.on('stream', handleStream);
|
||||
http2SecureServer.on('stream', handleStream);
|
||||
|
||||
const terminate = () => {
|
||||
session.disconnect();
|
||||
http2Server.close();
|
||||
http2SecureServer.close();
|
||||
inspector.close();
|
||||
};
|
||||
|
||||
function findFrameInInitiator(scriptName, initiator) {
|
||||
const frame = initiator.stack.callFrames.find((it) => {
|
||||
return it.url === scriptName;
|
||||
});
|
||||
return frame;
|
||||
}
|
||||
|
||||
function verifyRequestWillBeSent({ method, params }, expectedUrl) {
|
||||
assert.strictEqual(method, 'Network.requestWillBeSent');
|
||||
|
||||
assert.ok(params.requestId.startsWith('node-network-event-'));
|
||||
assert.strictEqual(params.request.url, expectedUrl);
|
||||
assert.strictEqual(params.request.method, 'GET');
|
||||
assert.strictEqual(typeof params.request.headers, 'object');
|
||||
|
||||
if (expectedUrl.endsWith('/hello-world')) {
|
||||
assert.strictEqual(params.request.headers['accept-language'], 'en-US');
|
||||
assert.strictEqual(params.request.headers.cookie, 'k1=v1; k2=v2');
|
||||
assert.strictEqual(params.request.headers.age, '1000');
|
||||
assert.strictEqual(params.request.headers['x-header1'], 'value1, value2');
|
||||
assert.ok(findFrameInInitiator(__filename, params.initiator));
|
||||
} else if (expectedUrl.endsWith('/style.css')) {
|
||||
assert.strictEqual(params.request.headers['x-header3'], 'value1, value2');
|
||||
assert.strictEqual(params.request.headers['x-push'], 'true');
|
||||
assert.ok(!findFrameInInitiator(__filename, params.initiator));
|
||||
}
|
||||
|
||||
assert.strictEqual(typeof params.timestamp, 'number');
|
||||
assert.strictEqual(typeof params.wallTime, 'number');
|
||||
|
||||
assert.strictEqual(typeof params.initiator, 'object');
|
||||
assert.strictEqual(params.initiator.type, 'script');
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
function verifyResponseReceived({ method, params }, expectedUrl) {
|
||||
assert.strictEqual(method, 'Network.responseReceived');
|
||||
|
||||
assert.ok(params.requestId.startsWith('node-network-event-'));
|
||||
assert.strictEqual(typeof params.timestamp, 'number');
|
||||
assert.strictEqual(params.type, 'Other');
|
||||
assert.strictEqual(params.response.status, 200);
|
||||
assert.strictEqual(params.response.statusText, '');
|
||||
assert.strictEqual(params.response.url, expectedUrl);
|
||||
assert.strictEqual(typeof params.response.headers, 'object');
|
||||
if (expectedUrl.endsWith('/hello-world')) {
|
||||
assert.strictEqual(params.response.headers.server, 'node');
|
||||
assert.strictEqual(params.response.headers.etag, '12345');
|
||||
assert.strictEqual(params.response.headers['set-cookie'], 'key1=value1\nkey2=value2');
|
||||
assert.strictEqual(params.response.headers['x-header2'], 'value1, value2');
|
||||
assert.strictEqual(params.response.mimeType, 'text/plain');
|
||||
assert.strictEqual(params.response.charset, 'utf-8');
|
||||
} else if (expectedUrl.endsWith('/style.css')) {
|
||||
assert.strictEqual(params.response.headers['x-header4'], 'value1, value2');
|
||||
assert.strictEqual(params.response.headers['x-push'], 'true');
|
||||
assert.strictEqual(params.response.mimeType, 'text/css');
|
||||
assert.strictEqual(params.response.charset, '');
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
function verifyLoadingFinished({ method, params }) {
|
||||
assert.strictEqual(method, 'Network.loadingFinished');
|
||||
|
||||
assert.ok(params.requestId.startsWith('node-network-event-'));
|
||||
assert.strictEqual(typeof params.timestamp, 'number');
|
||||
return params;
|
||||
}
|
||||
|
||||
function verifyLoadingFailed({ method, params }) {
|
||||
assert.strictEqual(method, 'Network.loadingFailed');
|
||||
|
||||
assert.ok(params.requestId.startsWith('node-network-event-'));
|
||||
assert.strictEqual(typeof params.timestamp, 'number');
|
||||
assert.strictEqual(params.type, 'Other');
|
||||
assert.strictEqual(typeof params.errorText, 'string');
|
||||
}
|
||||
|
||||
async function testHttp2(secure = false) {
|
||||
const port = (secure ? http2SecureServer : http2Server).address().port;
|
||||
const origin = (secure ? 'https' : 'http') + `://localhost:${port}`;
|
||||
const url = `${origin}/hello-world`;
|
||||
const pushedUrl = `${origin}/style.css`;
|
||||
|
||||
const requestWillBeSent = on(session, 'Network.requestWillBeSent');
|
||||
const responseReceived = on(session, 'Network.responseReceived');
|
||||
const loadingFinished = on(session, 'Network.loadingFinished');
|
||||
|
||||
session.on('Network.loadingFailed', common.mustNotCall());
|
||||
|
||||
const client = http2.connect(origin, {
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
const request = client.request(requestHeaders);
|
||||
|
||||
// Dump the responses.
|
||||
request.on('data', () => {});
|
||||
client.on('stream', (pushStream) => {
|
||||
pushStream.on('data', () => {});
|
||||
});
|
||||
request.on('end', () => {
|
||||
client.close();
|
||||
});
|
||||
request.end();
|
||||
|
||||
const [
|
||||
{ value: [ mainRequest ] },
|
||||
{ value: [ pushRequest ] },
|
||||
] = await Promise.all([requestWillBeSent.next(), requestWillBeSent.next()]);
|
||||
verifyRequestWillBeSent(mainRequest, url);
|
||||
verifyRequestWillBeSent(pushRequest, pushedUrl);
|
||||
|
||||
const [
|
||||
{ value: [ mainResponse ] },
|
||||
{ value: [ pushResponse ] },
|
||||
] = await Promise.all([responseReceived.next(), responseReceived.next()]);
|
||||
verifyResponseReceived(mainResponse, url);
|
||||
verifyResponseReceived(pushResponse, pushedUrl);
|
||||
|
||||
const [
|
||||
{ value: [ event1 ] },
|
||||
{ value: [ event2 ] },
|
||||
] = await Promise.all([loadingFinished.next(), loadingFinished.next()]);
|
||||
verifyLoadingFinished(event1);
|
||||
verifyLoadingFinished(event2);
|
||||
|
||||
const mainFinished = [event1, event2]
|
||||
.find((event) => event.params.requestId === mainResponse.params.requestId);
|
||||
const pushFinished = [event1, event2]
|
||||
.find((event) => event.params.requestId === pushResponse.params.requestId);
|
||||
|
||||
assert.ok(mainFinished.params.timestamp >= mainResponse.params.timestamp);
|
||||
assert.ok(pushFinished.params.timestamp >= pushResponse.params.timestamp);
|
||||
|
||||
const delta =
|
||||
(mainFinished.params.timestamp - mainResponse.params.timestamp) * 1000;
|
||||
assert.ok(delta > kDelta);
|
||||
}
|
||||
|
||||
async function testHttp2Error(secure = false) {
|
||||
const port = (secure ? http2SecureServer : http2Server).address().port;
|
||||
const origin = (secure ? 'https' : 'http') + `://localhost:${port}`;
|
||||
const errorUrl = `${origin}/trigger-error`;
|
||||
|
||||
const requestWillBeSent = once(session, 'Network.requestWillBeSent');
|
||||
session.on('Network.responseReceived', common.mustNotCall());
|
||||
session.on('Network.loadingFinished', common.mustNotCall());
|
||||
const loadingFailed = once(session, 'Network.loadingFailed');
|
||||
|
||||
const client = http2.connect(origin, {
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
const request = client.request(requestErrorHeaders);
|
||||
|
||||
request.on('close', common.mustCall(() => {
|
||||
assert.strictEqual(request.rstCode, http2.constants.NGHTTP2_STREAM_CLOSED);
|
||||
client.close();
|
||||
}));
|
||||
request.on('error', common.expectsError({
|
||||
code: 'ERR_HTTP2_STREAM_ERROR',
|
||||
name: 'Error',
|
||||
message: 'Stream closed with error code NGHTTP2_STREAM_CLOSED'
|
||||
}));
|
||||
request.end();
|
||||
|
||||
const [ requestEvent ] = await requestWillBeSent;
|
||||
verifyRequestWillBeSent(requestEvent, errorUrl);
|
||||
|
||||
const [ failed ] = await loadingFailed;
|
||||
verifyLoadingFailed(failed);
|
||||
}
|
||||
|
||||
const testNetworkInspection = async () => {
|
||||
await testHttp2();
|
||||
session.removeAllListeners();
|
||||
await testHttp2(true);
|
||||
session.removeAllListeners();
|
||||
await testHttp2Error();
|
||||
session.removeAllListeners();
|
||||
await testHttp2Error(true);
|
||||
session.removeAllListeners();
|
||||
};
|
||||
|
||||
http2Server.listen(0, async () => {
|
||||
http2SecureServer.listen(0, async () => {
|
||||
try {
|
||||
await session.post('Network.enable');
|
||||
await testNetworkInspection();
|
||||
await session.post('Network.disable');
|
||||
} catch (e) {
|
||||
assert.fail(e);
|
||||
} finally {
|
||||
terminate();
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user