From 61591bae30ab5baba830ac4c4b1c6d0186c8b989 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 31 Jan 2026 15:14:18 +0900 Subject: [PATCH 1/4] feat: add `GM.runExclusive` API for cross-context exclusive execution --- example/gm_run_exclusive.js | 158 ++++++++++++++++++ src/app/service/content/gm_api/gm_api.ts | 72 ++++++++ .../service/service_worker/gm_api/gm_api.ts | 44 ++++- src/template/scriptcat.d.tpl | 3 + src/types/scriptcat.d.ts | 3 + 5 files changed, 279 insertions(+), 1 deletion(-) create mode 100644 example/gm_run_exclusive.js diff --git a/example/gm_run_exclusive.js b/example/gm_run_exclusive.js new file mode 100644 index 000000000..b939a8646 --- /dev/null +++ b/example/gm_run_exclusive.js @@ -0,0 +1,158 @@ +// ==UserScript== +// @name GM.runExclusive Demo +// @namespace https://docs.scriptcat.org/ +// @version 0.1.1 +// @match https://example.com/*?runExclusive* +// @grant GM.runExclusive +// @grant GM.setValue +// @grant GM.getValue +// @run-at document-start +// @allFrames +// ==/UserScript== + +(async function () { + 'use strict'; + + const delayMatch = location.href.match(/runExclusive(\d+)/); + const timeDelay = delayMatch ? +delayMatch[1] : 0; + const isWorker = !!timeDelay; + + /* ---------- Shared UI helpers ---------- */ + const panel = document.createElement('div'); + Object.assign(panel.style, { + position: 'fixed', + top: '10px', + right: '10px', + background: '#1e1e1e', + color: '#e0e0e0', + padding: '14px', + borderRadius: '8px', + fontFamily: 'monospace', + zIndex: 99999, + maxWidth: '420px' + }); + document.documentElement.appendChild(panel); + + const logContainer = document.createElement('div'); + panel.appendChild(logContainer); + + const getTimeWithMilliseconds = date => `${date.toLocaleTimeString('it-US')}.${date.getMilliseconds()}`; + + const log = (msg, color = '#ccc') => { + const line = document.createElement('div'); + line.textContent = `[${getTimeWithMilliseconds(new Date())}] ${msg}`; + line.style.color = color; + logContainer.appendChild(line); + }; + + /* ====================================================== + MAIN PAGE (Controller) + ====================================================== */ + if (!isWorker) { + panel.innerHTML = ` +

GM.runExclusive Demo

+

Pick worker durations (ms):

+ + + +
+
+ `; + + const iframeContainer = panel.querySelector('#iframeContainer'); + + panel.querySelector('#reset').onclick = async () => { + await GM.setValue('mValue01', 0); + await GM.setValue('order', 0); + iframeContainer.innerHTML = ''; + log('Shared counters reset', '#ff0'); + }; + + panel.querySelector('#run').onclick = async () => { + iframeContainer.innerHTML = ''; + await GM.setValue('mValue01', 0); + await GM.setValue('order', 0); + + const delays = panel + .querySelector('#durations') + .value.split(',') + .map(v => +v.trim()) + .filter(Boolean); + + log(`Launching workers: ${delays.join(', ')}`, '#0f0'); + + delays.forEach(delay => { + const iframe = document.createElement('iframe'); + iframe.src = `${location.pathname}?runExclusive${delay}`; + iframe.style.width = '100%'; + iframe.style.height = '220px'; + iframe.style.border = '1px solid #444'; + iframe.style.marginTop = '8px'; + iframeContainer.appendChild(iframe); + }); + }; + + window.addEventListener('message', (e) => { + if (e.data?.type !== 'close-worker') return; + const iframes = iframeContainer.querySelectorAll('iframe'); + for (const iframe of iframes) { + if (iframe.src.includes(`runExclusive${e.data.delay}`)) { + iframe.remove(); + log(`Closed worker ${e.data.delay}ms`, '#ff9800'); + return; + } + } + }); + + return; + } + + /* ====================================================== + WORKER IFRAME + ====================================================== */ + + const closeBtn = document.createElement('button'); + closeBtn.textContent = 'Close worker'; + Object.assign(closeBtn.style, { + marginTop: '8px', + padding: '4px 8px', + cursor: 'pointer' + }); + closeBtn.onclick = () => { + window.parent.postMessage({ type: 'close-worker', delay: timeDelay }, '*'); + }; + panel.appendChild(closeBtn); + + log(`Worker ${timeDelay}ms loaded`, '#fff'); + log('Waiting for exclusive lock…', '#0af'); + + const startWait = performance.now(); + + try { + const result = await GM.runExclusive('demo-lock-key', async () => { + const waited = Math.round(performance.now() - startWait); + + const order = (await GM.getValue('order')) + 1; + await GM.setValue('order', order); + + log(`Lock acquired (#${order}, waited ${waited}ms)`, '#0f0'); + + const val = await GM.getValue('mValue01'); + await GM.setValue('mValue01', val + timeDelay); + + log(`Working ${timeDelay}ms…`, '#ff0'); + await new Promise(r => setTimeout(r, timeDelay)); + + const final = await GM.getValue('mValue01'); + log(`Done. Shared value = ${final}`, '#f55'); + + return { order, waited, final }; + }, 5000); + log(`Result: ${JSON.stringify(result)}`, '#fff'); + } catch (e) { + log(`Error: ${JSON.stringify(e?.message || e)}`, '#f55'); + } + + +})(); diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 9a2463f4b..51d0ba00d 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -1383,6 +1383,78 @@ export default class GMApi extends GM_Base { CAT_scriptLoaded() { return this.loadScriptPromise; } + + @GMContext.API({ alias: "GM_runExclusive" }) + ["GM.runExclusive"](lockKey: string, cb: () => T | PromiseLike, timeout: number = -1): Promise { + lockKey = `${lockKey}`; // 转化为字串 + if (!lockKey || !this.scriptRes) { + throw new Error("GM.runExclusive: Invalid Calling"); + } + const key = `${getStorageName(this.scriptRes).replace(/:/g, ":_")}::${lockKey.replace(/:/g, ":_")}`; + return new Promise((resolve, reject) => { + let killConn: (() => any) | null | undefined = undefined; + let error: any; + let result: any; + let done = false; + const onDisconnected = () => { + killConn = null; // before resolve, set killConn to null + if (error) { + reject(error); + } else if (!done) { + reject(new Error("GM.runExclusive: Incomplete Action")); + } else { + resolve(result); + } + result = null; // GC + error = null; // GC + }; + const onStart = async (con: MessageConnect) => { + if (killConn === null || done) { + // already resolved + con.disconnect(); + return; + } + try { + result = await cb(); + } catch (e) { + error = e; + } + done = true; + con.sendMessage({ + action: "done", + data: error ? false : typeof result, + }); + con.disconnect(); + onDisconnected(); // in case .disconnect() not working + }; + this.connect("runExclusive", [key]).then((con) => { + if (killConn === null || done) { + // already resolved + con.disconnect(); + return; + } + killConn = () => { + con.disconnect(); + }; + con.onDisconnect(onDisconnected); + con.onMessage((msg) => { + switch (msg.action) { + case "start": + onStart(con); + break; + } + }); + }); + if (timeout > 0) { + setTimeout(() => { + if (killConn === null || done) return; + error = new Error("GM.runExclusive: Timeout Error"); + killConn?.(); + onDisconnected(); // in case .disconnect() not working + }, timeout); + } + }); + } } // 从 GM_Base 对象中解构出 createGMBase 函数并导出(可供其他模块使用) diff --git a/src/app/service/service_worker/gm_api/gm_api.ts b/src/app/service/service_worker/gm_api/gm_api.ts index 1e5135605..fb8c51efd 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -2,7 +2,7 @@ import LoggerCore from "@App/app/logger/core"; import Logger from "@App/app/logger/logger"; import { ScriptDAO } from "@App/app/repo/scripts"; import { type IGetSender, type Group, GetSenderType } from "@Packages/message/server"; -import type { ExtMessageSender, MessageSend, TMessageCommAction } from "@Packages/message/types"; +import type { ExtMessageSender, MessageConnect, MessageSend, TMessageCommAction } from "@Packages/message/types"; import { connect, sendMessage } from "@Packages/message/client"; import type { IMessageQueue } from "@Packages/message/message_queue"; import { type ValueService } from "@App/app/service/service_worker/value"; @@ -11,6 +11,7 @@ import PermissionVerify, { PermissionVerifyApiGet } from "../permission_verify"; import { cacheInstance } from "@App/app/cache"; import { type RuntimeService } from "../runtime"; import { getIcon, isFirefox, getCurrentTab, openInCurrentTab, cleanFileName, makeBlobURL } from "@App/pkg/utils/utils"; +import { deferred, type Deferred } from "@App/pkg/utils/utils"; import { type SystemConfig } from "@App/pkg/config/config"; import i18next, { i18nName } from "@App/locales/locales"; import FileSystemFactory from "@Packages/filesystem/factory"; @@ -44,6 +45,7 @@ import { headerModifierMap, headersReceivedMap } from "./gm_xhr"; import { BgGMXhr } from "@App/pkg/utils/xhr/bg_gm_xhr"; import { mightPrepareSetClipboard, setClipboard } from "../clipboard"; import { nativePageWindowOpen } from "../../offscreen/gm_api"; +import { stackAsyncTask } from "@App/pkg/utils/async_queue"; let generatedUniqueMarkerIDs = ""; let generatedUniqueMarkerIDWhen = ""; @@ -1305,6 +1307,46 @@ export default class GMApi { } } + @PermissionVerify.API({ link: ["GM.runExclusive", "GM_runExclusive"] }) + runExclusive(request: GMApiRequest<[string]>, sender: IGetSender) { + if (!request.params || request.params.length < 1) { + throw new Error("param is failed"); + } + const lockKey = request.params[0]; + if (!sender.isType(GetSenderType.CONNECT)) { + throw new Error("GM_download ERROR: sender is not MessageConnect"); + } + let msgConn: MessageConnect | undefined | null = sender.getConnect(); + if (!msgConn) { + throw new Error("GM_download ERROR: msgConn is undefined"); + } + let isConnDisconnected = false; + let d: Deferred | null = deferred(); + let done: boolean = false; + const onDisconnected = () => { + if (isConnDisconnected) return; + isConnDisconnected = true; + d!.resolve(done); + msgConn = null; // release for GC + d = null; // release for GC + }; + msgConn.onDisconnect(onDisconnected); + msgConn.onMessage((msg) => { + if (msg.action === "done") { + done = true; + msgConn?.disconnect(); + onDisconnected(); // in case .disconnect() not working + } + }); + stackAsyncTask(`${lockKey}`, async () => { + if (isConnDisconnected) return; + msgConn!.sendMessage({ + action: "start", + }); + return d!.promise; + }); + } + handlerNotification() { const send = async ( event: NotificationMessageOption["event"], diff --git a/src/template/scriptcat.d.tpl b/src/template/scriptcat.d.tpl index e89cb2b05..425bd4954 100644 --- a/src/template/scriptcat.d.tpl +++ b/src/template/scriptcat.d.tpl @@ -304,6 +304,9 @@ declare const GM: { /** Cookie 操作 */ cookie(action: GMTypes.CookieAction, details: GMTypes.CookieDetails): Promise; + + /** cross-context exclusive execution */ + runExclusive(key: string, callback: () => T | PromiseLike, timeout?: number): Promise; }; /** diff --git a/src/types/scriptcat.d.ts b/src/types/scriptcat.d.ts index d03005163..2ea00d095 100644 --- a/src/types/scriptcat.d.ts +++ b/src/types/scriptcat.d.ts @@ -304,6 +304,9 @@ declare const GM: { /** Cookie 操作 */ cookie(action: GMTypes.CookieAction, details: GMTypes.CookieDetails): Promise; + + /** cross-context exclusive execution */ + runExclusive(key: string, callback: () => T | PromiseLike, timeout?: number): Promise; }; /** From c18036ea05ce0c7d6d704e5983848f3e06375336 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 31 Jan 2026 18:07:41 +0900 Subject: [PATCH 2/4] Update gm_api.ts --- src/app/service/content/gm_api/gm_api.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 51d0ba00d..dfafbf27d 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -1395,12 +1395,12 @@ export default class GMApi extends GM_Base { let killConn: (() => any) | null | undefined = undefined; let error: any; let result: any; - let done = false; + let state = 0; // 0 = not started; 1 = started; 2 = done const onDisconnected = () => { killConn = null; // before resolve, set killConn to null if (error) { reject(error); - } else if (!done) { + } else if (state !== 2) { reject(new Error("GM.runExclusive: Incomplete Action")); } else { resolve(result); @@ -1409,17 +1409,18 @@ export default class GMApi extends GM_Base { error = null; // GC }; const onStart = async (con: MessageConnect) => { - if (killConn === null || done) { - // already resolved + if (killConn === null || state > 0) { + // already resolved (unexpected or by timeout) con.disconnect(); return; } + state = 1; try { result = await cb(); } catch (e) { error = e; } - done = true; + state = 2; con.sendMessage({ action: "done", data: error ? false : typeof result, @@ -1428,8 +1429,8 @@ export default class GMApi extends GM_Base { onDisconnected(); // in case .disconnect() not working }; this.connect("runExclusive", [key]).then((con) => { - if (killConn === null || done) { - // already resolved + if (killConn === null || state > 0) { + // already resolved (unexpected or by timeout) con.disconnect(); return; } @@ -1447,7 +1448,7 @@ export default class GMApi extends GM_Base { }); if (timeout > 0) { setTimeout(() => { - if (killConn === null || done) return; + if (killConn === null || state > 0) return; // 执行开始了就不进行 timeout 操作 error = new Error("GM.runExclusive: Timeout Error"); killConn?.(); onDisconnected(); // in case .disconnect() not working From 2821a7bebbbc8eb8a978ea3898660416ad830ecd Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 31 Jan 2026 18:34:01 +0900 Subject: [PATCH 3/4] Update gm_run_exclusive.js --- example/gm_run_exclusive.js | 63 ++++++++++++++++++++++++++----------- 1 file changed, 45 insertions(+), 18 deletions(-) diff --git a/example/gm_run_exclusive.js b/example/gm_run_exclusive.js index b939a8646..3ffdf9456 100644 --- a/example/gm_run_exclusive.js +++ b/example/gm_run_exclusive.js @@ -1,7 +1,7 @@ // ==UserScript== // @name GM.runExclusive Demo // @namespace https://docs.scriptcat.org/ -// @version 0.1.1 +// @version 0.1.2 // @match https://example.com/*?runExclusive* // @grant GM.runExclusive // @grant GM.setValue @@ -13,23 +13,40 @@ (async function () { 'use strict'; - const delayMatch = location.href.match(/runExclusive(\d+)/); + const delayMatch = location.href.match(/runExclusive(\d+)_(\d*)/); const timeDelay = delayMatch ? +delayMatch[1] : 0; + const timeoutValue = (delayMatch ? +delayMatch[2] : 0) || -1; const isWorker = !!timeDelay; + const sheet = new CSSStyleSheet(); + sheet.replaceSync(` + #exclusive-test-panel { + all: unset; + } + #exclusive-test-panel div, #exclusive-test-panel p, #exclusive-test-panel span { + opacity: 1.0; + line-height: 1; + font-size: 10pt; + } + `); + document.adoptedStyleSheets = document.adoptedStyleSheets.concat(sheet); + /* ---------- Shared UI helpers ---------- */ const panel = document.createElement('div'); + panel.id = "exclusive-test-panel"; Object.assign(panel.style, { + opacity: "1.0", position: 'fixed', + boxSizing: 'border-box', top: '10px', right: '10px', - background: '#1e1e1e', + background: '#3e3e3e', color: '#e0e0e0', padding: '14px', borderRadius: '8px', fontFamily: 'monospace', zIndex: 99999, - maxWidth: '420px' + width: '420px' }); document.documentElement.appendChild(panel); @@ -40,7 +57,7 @@ const log = (msg, color = '#ccc') => { const line = document.createElement('div'); - line.textContent = `[${getTimeWithMilliseconds(new Date())}] ${msg}`; + line.textContent = msg.startsWith(" ") ? msg : `[${getTimeWithMilliseconds(new Date())}] ${msg}`; line.style.color = color; logContainer.appendChild(line); }; @@ -49,14 +66,18 @@ MAIN PAGE (Controller) ====================================================== */ if (!isWorker) { + panel.style.width = "480px"; panel.innerHTML = `

GM.runExclusive Demo

Pick worker durations (ms):

- - - -
+
+ + + + +
`; @@ -79,14 +100,16 @@ .value.split(',') .map(v => +v.trim()) .filter(Boolean); + + let timeoutQ = +panel.querySelector("#timeout").value.trim() || ""; log(`Launching workers: ${delays.join(', ')}`, '#0f0'); delays.forEach(delay => { const iframe = document.createElement('iframe'); - iframe.src = `${location.pathname}?runExclusive${delay}`; + iframe.src = `${location.pathname}?runExclusive${delay}_${timeoutQ}`; iframe.style.width = '100%'; - iframe.style.height = '220px'; + iframe.style.height = '160px'; iframe.style.border = '1px solid #444'; iframe.style.marginTop = '8px'; iframeContainer.appendChild(iframe); @@ -97,7 +120,7 @@ if (e.data?.type !== 'close-worker') return; const iframes = iframeContainer.querySelectorAll('iframe'); for (const iframe of iframes) { - if (iframe.src.includes(`runExclusive${e.data.delay}`)) { + if (iframe.src.includes(`runExclusive${e.data.delay}_`)) { iframe.remove(); log(`Closed worker ${e.data.delay}ms`, '#ff9800'); return; @@ -113,18 +136,22 @@ ====================================================== */ const closeBtn = document.createElement('button'); - closeBtn.textContent = 'Close worker'; + closeBtn.textContent = 'Close'; Object.assign(closeBtn.style, { - marginTop: '8px', + margin: '8px', padding: '4px 8px', - cursor: 'pointer' + cursor: 'pointer', + position: 'absolute', + top: '0px', + right: '0px', + boxSizing: 'border-box' }); closeBtn.onclick = () => { window.parent.postMessage({ type: 'close-worker', delay: timeDelay }, '*'); }; panel.appendChild(closeBtn); - log(`Worker ${timeDelay}ms loaded`, '#fff'); + log(` [Worker] duration=${timeDelay}ms${timeoutValue > 0 ? " timeout=" + timeoutValue + "ms" : ""}`, '#fff'); log('Waiting for exclusive lock…', '#0af'); const startWait = performance.now(); @@ -148,7 +175,7 @@ log(`Done. Shared value = ${final}`, '#f55'); return { order, waited, final }; - }, 5000); + }, timeoutValue); log(`Result: ${JSON.stringify(result)}`, '#fff'); } catch (e) { log(`Error: ${JSON.stringify(e?.message || e)}`, '#f55'); From 6c400aa1d38f97ebaafa5519a03ee88b52c9d79a Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Mon, 2 Feb 2026 22:11:36 +0900 Subject: [PATCH 4/4] =?UTF-8?q?=E5=9C=A8=20content=20api=20=E5=8A=A0?= =?UTF-8?q?=E5=85=A5=20stackAsyncTask=20=E9=81=BF=E5=85=8D=E8=BF=87?= =?UTF-8?q?=E5=BA=A6=E5=BC=80=E5=90=AF=E8=BF=9E=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/gm_api/gm_api.ts | 127 +++++++++++++---------- 1 file changed, 70 insertions(+), 57 deletions(-) diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index dfafbf27d..acee3d78d 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -23,6 +23,7 @@ import { decodeRValue, encodeRValue, type REncoded } from "@App/pkg/utils/messag import { type TGMKeyValue } from "@App/app/repo/value"; import type { ContextType } from "./gm_xhr"; import { convObjectToURL, GM_xmlhttpRequest, toBlobURL, urlToDocumentInContentPage } from "./gm_xhr"; +import { stackAsyncTask } from "@App/pkg/utils/async_queue"; // 内部函数呼叫定义 export interface IGM_Base { @@ -1391,69 +1392,81 @@ export default class GMApi extends GM_Base { throw new Error("GM.runExclusive: Invalid Calling"); } const key = `${getStorageName(this.scriptRes).replace(/:/g, ":_")}::${lockKey.replace(/:/g, ":_")}`; - return new Promise((resolve, reject) => { - let killConn: (() => any) | null | undefined = undefined; - let error: any; - let result: any; - let state = 0; // 0 = not started; 1 = started; 2 = done - const onDisconnected = () => { - killConn = null; // before resolve, set killConn to null - if (error) { - reject(error); - } else if (state !== 2) { - reject(new Error("GM.runExclusive: Incomplete Action")); - } else { - resolve(result); - } - result = null; // GC - error = null; // GC - }; - const onStart = async (con: MessageConnect) => { - if (killConn === null || state > 0) { - // already resolved (unexpected or by timeout) + + const taskAsync = () => + new Promise((resolve, reject) => { + let killConn: (() => any) | null | undefined = undefined; + let error: any; + let result: any; + let state = 0; // 0 = not started; 1 = started; 2 = done + const onDisconnected = () => { + killConn = null; // before resolve, set killConn to null + if (error) { + reject(error); + } else if (state !== 2) { + reject(new Error("GM.runExclusive: Incomplete Action")); + } else { + resolve(result); + } + result = null; // GC + error = null; // GC + }; + const onStart = async (con: MessageConnect) => { + if (killConn === null || state > 0) { + // already resolved (unexpected or by timeout) + con.disconnect(); + return; + } + state = 1; + try { + result = await cb(); + } catch (e) { + error = e; + } + state = 2; + con.sendMessage({ + action: "done", + data: error ? false : typeof result, + }); con.disconnect(); - return; + onDisconnected(); // in case .disconnect() not working + }; + this.connect("runExclusive", [key]).then((con) => { + if (killConn === null || state > 0) { + // already resolved (unexpected or by timeout) + con.disconnect(); + return; + } + killConn = () => { + con.disconnect(); + }; + con.onDisconnect(onDisconnected); + con.onMessage((msg) => { + switch (msg.action) { + case "start": + onStart(con); + break; + } + }); + }); + if (timeout > 0) { + setTimeout(() => { + if (killConn === null || state > 0) return; // 执行开始了就不进行 timeout 操作 + error = new Error("GM.runExclusive: Timeout Error"); + killConn?.(); + onDisconnected(); // in case .disconnect() not working + }, timeout); } - state = 1; + }); + + return new Promise((resolve, reject) => { + stackAsyncTask(`runExclusive::${key}`, async () => { try { - result = await cb(); + resolve(await taskAsync()); } catch (e) { - error = e; - } - state = 2; - con.sendMessage({ - action: "done", - data: error ? false : typeof result, - }); - con.disconnect(); - onDisconnected(); // in case .disconnect() not working - }; - this.connect("runExclusive", [key]).then((con) => { - if (killConn === null || state > 0) { - // already resolved (unexpected or by timeout) - con.disconnect(); - return; + reject(e); } - killConn = () => { - con.disconnect(); - }; - con.onDisconnect(onDisconnected); - con.onMessage((msg) => { - switch (msg.action) { - case "start": - onStart(con); - break; - } - }); }); - if (timeout > 0) { - setTimeout(() => { - if (killConn === null || state > 0) return; // 执行开始了就不进行 timeout 操作 - error = new Error("GM.runExclusive: Timeout Error"); - killConn?.(); - onDisconnected(); // in case .disconnect() not working - }, timeout); - } }); } }