Skip to content

Commit ea5a1a4

Browse files
authored
🐛 修复GM xhr不正确处理异常 onloadend 的问题 (#1412)
* fix: 修复GM xhr不正确处理异常 onloadend 的问题 * 增加单元测试 * 修正 权限错误的 onloadend 丢失问题
1 parent e548e41 commit ea5a1a4

3 files changed

Lines changed: 171 additions & 52 deletions

File tree

example/tests/gm_xhr_test.js

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// ==UserScript==
22
// @name GM_xmlhttpRequest Exhaustive Test Harness v3
33
// @namespace tm-gmxhr-test
4-
// @version 1.2.4
4+
// @version 1.2.5
55
// @description Comprehensive in-page tests for GM_xmlhttpRequest: normal, abnormal, and edge cases with clear pass/fail output.
66
// @author you
77
// @match *://*/*?GM_XHR_TEST_SC
@@ -120,8 +120,7 @@ const enableTool = true;
120120
gap: "8px",
121121
},
122122
},
123-
h("div", { style: { fontWeight: "600" } }, "GM_xmlhttpRequest Test Harness", h("br"), `${GM.info?.version}`),
124-
h("div", { id: "counts", style: { marginLeft: "auto", opacity: 0.8 } }, "…"),
123+
h("div", {}, h("div", { style: { fontWeight: "500" } }, `GM_xmlhttpRequest Test Harness ${GM.info?.script?.version}`), h("div", { style: { display: "flex", flexDirection: "row" } }, h("div", { style: { fontWeight: "400" } }, `${GM.info?.scriptHandler} ${GM.info?.version}`), h("div", { id: "counts", style: { marginLeft: "auto", opacity: 0.8 } }, "…"))),
125124
h("button", { id: "start", style: btn() }, "Run"),
126125
h("button", { id: "clear", style: btn() }, "Clear")
127126
),
@@ -938,6 +937,82 @@ const enableTool = true;
938937
}
939938
},
940939
},
940+
{
941+
name: "GM_xhr abort timeout onloadend events",
942+
async run(fetch) {
943+
const runCase = (details, { abortAfterMs } = {}) => {
944+
return new Promise((resolve, reject) => {
945+
const events = [];
946+
const timeoutMs = Math.max((details.timeout || 0) + (abortAfterMs || 0) + 8000, 12000);
947+
const timer = setTimeout(() => {
948+
reject(new Error(`Expected onloadend; events=${events.join(",")}`));
949+
}, timeoutMs);
950+
const req = GM_xmlhttpRequest({
951+
method: details.method || "GET",
952+
url: details.url,
953+
timeout: details.timeout,
954+
fetch,
955+
onload() {
956+
events.push("onload");
957+
},
958+
onerror() {
959+
events.push("onerror");
960+
},
961+
onabort() {
962+
events.push("onabort");
963+
},
964+
ontimeout() {
965+
events.push("ontimeout");
966+
},
967+
onloadend(response) {
968+
events.push("onloadend");
969+
clearTimeout(timer);
970+
resolve({ events, response });
971+
},
972+
});
973+
if (abortAfterMs != null) {
974+
setTimeout(() => req.abort(), abortAfterMs);
975+
}
976+
});
977+
};
978+
979+
const normal = await runCase({
980+
url: `${HB}/get`,
981+
});
982+
assertDeepEq(normal.events, ["onload", "onloadend"], "normal fires onload then onloadend");
983+
assertEq(normal.response.status, 200, "normal onloadend status 200");
984+
985+
const timeout = await runCase({
986+
url: `${HB}/delay/5`,
987+
timeout: 2000,
988+
});
989+
assertDeepEq(timeout.events, ["ontimeout", "onloadend"], "timeout fires ontimeout then onloadend");
990+
991+
const abort = await runCase(
992+
{
993+
url: `${HB}/delay/10`,
994+
},
995+
{ abortAfterMs: 4000 }
996+
);
997+
assertDeepEq(abort.events, ["onabort", "onloadend"], "abort fires onabort then onloadend");
998+
999+
const nwError1 = await runCase(
1000+
{
1001+
url: `https://nonexistent-domain-abcxyz.test/abc.html`, // allowed domain
1002+
},
1003+
{ abortAfterMs: 500 }
1004+
);
1005+
assertDeepEq(nwError1.events, ["onerror", "onloadend"], "abort fires onerror then onloadend");
1006+
1007+
const nwError2 = await runCase(
1008+
{
1009+
url: `https://nonexistent-domain-abcxyz.reject/abc.html`, // disallowed domain
1010+
},
1011+
{ abortAfterMs: 500 }
1012+
);
1013+
assertDeepEq(nwError2.events, ["onerror", "onloadend"], "abort fires onerror then onloadend");
1014+
},
1015+
},
9411016
{
9421017
name: "onprogress fires while downloading [arraybuffer]",
9431018
async run(fetch) {

src/app/service/content/gm_api/gm_xhr.ts

Lines changed: 58 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,21 @@ export type GMXHRResponseType = {
5656
error?: string;
5757
};
5858

59+
type TXhrCallBackArg = {
60+
//
61+
finalUrl: string;
62+
readyState: ReadyStateCode;
63+
status: number;
64+
statusText: string;
65+
responseHeaders: string;
66+
error?: string;
67+
//
68+
useFetch: boolean;
69+
eventType: string;
70+
ok: boolean;
71+
contentType: string;
72+
};
73+
5974
export type GMXHRResponseTypeWithError = GMXHRResponseType & Required<Pick<GMXHRResponseType, "error">>;
6075

6176
export const toBlobURL = (a: GMApi, blob: Blob): Promise<string> | string => {
@@ -209,7 +224,7 @@ export function GM_xmlhttpRequest(
209224
}
210225
let connect: MessageConnect | null;
211226
const responseTypeOriginal = details.responseType?.toLocaleLowerCase() || "";
212-
let doAbort: any = null;
227+
let doAbort: ((o: TXhrCallBackArg) => void) | null = null;
213228
(async () => {
214229
const [urlResolved, dataResolved] = await Promise.all([urlPromiseLike, dataPromise]);
215230
const u = new URL(urlResolved, window.location.href);
@@ -285,6 +300,7 @@ export function GM_xmlhttpRequest(
285300
}
286301

287302
let refCleanup: (() => void) | null = () => {
303+
// 执行此操作会使连结断开。因此 fetch error, timeout 等出现后不能立即执行,应留待 onloadend 后呼叫
288304
// 清掉函数参考,避免各变数参考无法GC
289305
makeXHRCallbackParam = null;
290306
onMessageHandler = null;
@@ -419,22 +435,7 @@ export function GM_xmlhttpRequest(
419435
return retParamObject;
420436
};
421437

422-
const makeXHRCallbackParam_ = (
423-
res: {
424-
//
425-
finalUrl: string;
426-
readyState: ReadyStateCode;
427-
status: number;
428-
statusText: string;
429-
responseHeaders: string;
430-
error?: string;
431-
//
432-
useFetch: boolean;
433-
eventType: string;
434-
ok: boolean;
435-
contentType: string;
436-
} & Record<string, any>
437-
) => {
438+
const makeXHRCallbackParam_ = (res: TXhrCallBackArg) => {
438439
if ((res.readyState === 4 || reqDone) && res.eventType !== "progress") allowResponse = true;
439440
let resError: Record<string, any> | null = null;
440441
if (
@@ -502,12 +503,33 @@ export function GM_xmlhttpRequest(
502503
return makeResponseRet(retParam, addGetters, res.contentType);
503504
};
504505
let makeXHRCallbackParam: typeof makeXHRCallbackParam_ | null = makeXHRCallbackParam_;
505-
doAbort = (data: any) => {
506+
let loadendCalled = false;
507+
const doLoadEnd = (data: TXhrCallBackArg) => {
508+
if (!loadendCalled) {
509+
loadendCalled = true;
510+
reqDone = true;
511+
responseText = false;
512+
finalResultBuffers = null;
513+
finalResultText = null;
514+
const xhrResponse = makeXHRCallbackParam?.(data) ?? {};
515+
details.onloadend?.(xhrResponse);
516+
if (errorOccur === null) {
517+
retPromiseResolve?.(xhrResponse);
518+
} else {
519+
retPromiseReject?.(errorOccur);
520+
}
521+
refCleanup?.();
522+
}
523+
};
524+
doAbort = (data: TXhrCallBackArg) => {
506525
if (!reqDone) {
507526
errorOccur = "AbortError";
508527
details.onabort?.(makeXHRCallbackParam?.(data) ?? {});
509528
reqDone = true;
510-
refCleanup?.();
529+
// 不要进行 refCleanup !要等待最后的 onloadend
530+
// refCleanup?.();
531+
// doAbort 不是由通讯管控 onloadend. 需要手动处理. 排程在下一个 microTask 避免影响 Abort 流程
532+
Promise.resolve({ ...data, type: "loadend" }).then(doLoadEnd);
511533
}
512534
doAbort = null;
513535
};
@@ -543,8 +565,17 @@ export function GM_xmlhttpRequest(
543565
error: message,
544566
});
545567
reqDone = true;
546-
retPromiseReject?.(message);
547-
refCleanup?.();
568+
// 不要进行 refCleanup !要等待最后的 onloadend
569+
// refCleanup?.();
570+
571+
// 此错误多为 API 非正常执行,估计不会有 loadend 触发。见 Aborted 处理
572+
Promise.resolve({
573+
error: "loadend",
574+
responseHeaders: "",
575+
readyState: 0,
576+
status: 0,
577+
statusText: "",
578+
} as TXhrCallBackArg).then(doLoadEnd);
548579
}
549580
return;
550581
}
@@ -621,18 +652,7 @@ export function GM_xmlhttpRequest(
621652
details.onload?.(makeXHRCallbackParam?.(data) ?? {});
622653
break;
623654
case "onloadend": {
624-
reqDone = true;
625-
responseText = false;
626-
finalResultBuffers = null;
627-
finalResultText = null;
628-
const xhrResponse = makeXHRCallbackParam?.(data) ?? {};
629-
details.onloadend?.(xhrResponse);
630-
if (errorOccur === null) {
631-
retPromiseResolve?.(xhrResponse);
632-
} else {
633-
retPromiseReject?.(errorOccur);
634-
}
635-
refCleanup?.();
655+
doLoadEnd(data);
636656
break;
637657
}
638658
case "onloadstart":
@@ -669,7 +689,8 @@ export function GM_xmlhttpRequest(
669689
errorOccur = "TimeoutError";
670690
details.ontimeout?.(makeXHRCallbackParam?.(data) ?? {});
671691
reqDone = true;
672-
refCleanup?.();
692+
// 不要进行 refCleanup !要等待最后的 onloadend
693+
// refCleanup?.();
673694
}
674695
break;
675696
case "onerror":
@@ -678,7 +699,8 @@ export function GM_xmlhttpRequest(
678699
errorOccur = data.error;
679700
details.onerror?.((makeXHRCallbackParam?.(data) ?? {}) as GMXHRResponseTypeWithError);
680701
reqDone = true;
681-
refCleanup?.();
702+
// 不要进行 refCleanup !要等待最后的 onloadend
703+
// refCleanup?.();
682704
}
683705
break;
684706
case "onabort":
@@ -715,7 +737,7 @@ export function GM_xmlhttpRequest(
715737
readyState: 0,
716738
status: 0,
717739
statusText: "",
718-
}) as GMXHRResponseType;
740+
} as TXhrCallBackArg);
719741
reqDone = true;
720742
}
721743
},

src/app/service/service_worker/gm_api/gm_api.ts

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import LoggerCore from "@App/app/logger/core";
22
import Logger from "@App/app/logger/logger";
33
import { ScriptDAO } from "@App/app/repo/scripts";
44
import { type IGetSender, type Group, GetSenderType } from "@Packages/message/server";
5-
import type { ExtMessageSender, MessageSend, TMessageCommAction } from "@Packages/message/types";
5+
import type { ExtMessageSender, MessageConnect, MessageSend, TMessageCommAction } from "@Packages/message/types";
66
import { connect, sendMessage } from "@Packages/message/client";
77
import type { IMessageQueue } from "@Packages/message/message_queue";
88
import { type ValueService } from "@App/app/service/service_worker/value";
@@ -762,18 +762,38 @@ export default class GMApi {
762762
if (!msgConn) {
763763
throw new Error("GM_xmlhttpRequest ERROR: msgConn is undefined");
764764
}
765-
const throwErrorFn = (error: string) => {
766-
msgConn.sendMessage({
767-
action: "onerror",
768-
data: {
769-
status: 0,
770-
responseHeaders: "",
771-
error: error,
772-
readyState: 4, // ERROR. DONE.
773-
},
774-
});
775-
return new Error(error);
776-
};
765+
// conn 为 nested scope 内 local 存取
766+
let throwErrorFn: ((error: string) => Error) | null = ((conn: MessageConnect | null) => {
767+
let errorOccur: string | null = null;
768+
const doLoadEnd = () => {
769+
conn?.sendMessage({
770+
action: "onloadend",
771+
data: {
772+
status: 0,
773+
responseHeaders: "",
774+
error: errorOccur,
775+
readyState: 4, // ERROR. DONE.
776+
},
777+
});
778+
conn?.disconnect(); // 断开连结
779+
conn = null; // 释放
780+
};
781+
return (error: string) => {
782+
errorOccur = error;
783+
conn?.sendMessage({
784+
action: "onerror",
785+
data: {
786+
status: 0,
787+
responseHeaders: "",
788+
error: errorOccur,
789+
readyState: 4, // ERROR. DONE.
790+
},
791+
});
792+
// throwErrorFn 不是由通讯管控 onloadend. 需要手动处理. 排程在下一个 microTask 避免影响 throw Error 流程
793+
Promise.resolve().then(doLoadEnd);
794+
return new Error(errorOccur);
795+
};
796+
})(msgConn);
777797
const details = request.params[0];
778798
if (!details) {
779799
throw throwErrorFn("param is failed");
@@ -819,6 +839,8 @@ export default class GMApi {
819839
metadata[i18next.t("request_domain")] = url.hostname;
820840
metadata[i18next.t("request_url")] = details.url;
821841

842+
throwErrorFn = null; // 确保 GC 可以释放 conn
843+
822844
return {
823845
permission: "cors",
824846
permissionValue: url.hostname,

0 commit comments

Comments
 (0)