From efc7ed23b2ae733a4f10db78be482254c22368f1 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 31 Jan 2026 20:20:54 +0900 Subject: [PATCH 1/2] =?UTF-8?q?VSCodeConnect=20=E4=BB=A3=E7=A0=81=E9=87=8D?= =?UTF-8?q?=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VSCodeConnect 代码重构 --- src/app/service/offscreen/client.ts | 4 +- src/app/service/offscreen/vscode-connect.ts | 451 ++++++++++++++++---- src/app/service/service_worker/client.ts | 4 +- 3 files changed, 381 insertions(+), 78 deletions(-) diff --git a/src/app/service/offscreen/client.ts b/src/app/service/offscreen/client.ts index a79421c08..d168f6bcb 100644 --- a/src/app/service/offscreen/client.ts +++ b/src/app/service/offscreen/client.ts @@ -2,7 +2,7 @@ import { type WindowMessage } from "@Packages/message/window_message"; import type { SCRIPT_RUN_STATUS, ScriptRunResource } from "@App/app/repo/scripts"; import { Client, sendMessage } from "@Packages/message/client"; import type { MessageSend } from "@Packages/message/types"; -import { type VSCodeConnect } from "./vscode-connect"; +import { type VSCodeConnectParam } from "./vscode-connect"; export function preparationSandbox(windowMessage: WindowMessage) { return sendMessage(windowMessage, "offscreen/preparationSandbox"); @@ -42,7 +42,7 @@ export class VscodeConnectClient extends Client { super(msgSender, "offscreen/vscodeConnect"); } - connect(params: Parameters[0]): ReturnType { + connect(params: VSCodeConnectParam): Promise { return this.do("connect", params); } } diff --git a/src/app/service/offscreen/vscode-connect.ts b/src/app/service/offscreen/vscode-connect.ts index dcb3a31b9..d96361130 100644 --- a/src/app/service/offscreen/vscode-connect.ts +++ b/src/app/service/offscreen/vscode-connect.ts @@ -5,99 +5,402 @@ import type { MessageSend } from "@Packages/message/types"; import { ScriptClient } from "../service_worker/client"; import { v5 as uuidv5 } from "uuid"; -// 在offscreen下与scriptcat-vscode建立websocket连接 -// 需要在vscode中安装scriptcat-vscode插件 +/** + * VSCode ↔ ScriptCat 热重载 / 即时安装桥接核心类 + * + * ───────────────────────────────────────────── + * 📌 功能说明 + * ───────────────────────────────────────────── + * 本类负责与 VS Code 扩展「scriptcat-vscode」建立 WebSocket 连接,实现: + * 1. 在 VS Code 中储存 `.user.js` 时,即时将脚本内容推送至 ScriptCat + * 2. 依据脚本 URI 使用 UUID v5 生成稳定脚本 ID,进行安装或更新 + * 3. 提供断线自动重连、30 秒连接超时、VS Code 重启后自动恢复等机制 + * + * + * ───────────────────────────────────────────── + * 🧭 使用流程(使用者视角) + * ───────────────────────────────────────────── + * + * 1️⃣ 安装必要工具 + * - 浏览器安装 ScriptCat:https://scriptcat.org + * - VS Code 安装 scriptcat-vscode 扩展 + * - Marketplace 搜寻「ScriptCat」 + * - 或 GitHub:https://github.com/scriptscat/scriptcat-vscode + * + * 2️⃣ 启用 VS Code 自动连接 + * - 打开 ScriptCat + * - Tools > Development Debugging + * - 启用并点击: + * Auto Connect VSCode Service > Connect + * + * 3️⃣ 设定要同步的 `.user.js` + * - 打开或新增任意 `.user.js` 文件 + * - 可透过 VS Code 指令指定同步模式: + * + * a. 单一脚本模式 + * - Ctrl + Shift + P + * - scriptcat.target + * - 指定脚本路径 + * + * b. 自动识别模式 + * - Ctrl + Shift + P + * - scriptcat.autoTarget + * - 自动同步当前开启的 `.user.js` + * + * 4️⃣ 连接开发模式 + * - 在 ScriptCat 设定页或侧边栏点击「连接开发模式」 + * - scriptcat-vscode 会发送 connect 讯息, + * 并附带 WebSocket 位址(如 ws://localhost:xxxx/...) + * + * + * ───────────────────────────────────────────── + * 🔌 WebSocket 通讯流程 + * ───────────────────────────────────────────── + * - 收到 "connect" 事件后建立 WebSocket 连接 + * - 连接成功后发送握手讯息: + * { action: "hello" } + * + * + * ───────────────────────────────────────────── + * 🔄 脚本同步与安装机制 + * ───────────────────────────────────────────── + * - 每次储存 `.user.js` 时,VS Code 端发送: + * { + * action: "onchange", + * data: { + * script: "完整脚本内容", + * uri: "file:///..." + * } + * } + * + * - 本类在收到 onchange 后: + * - 使用 uuidv5(uri) 生成稳定脚本 ID + * - 呼叫 scriptClient.installByCode() + * 执行脚本安装或更新 + * + * + * ───────────────────────────────────────────── + * 📡 与 scriptcat-vscode 的讯息契约 + * ───────────────────────────────────────────── + * + * ▶ VS Code → Service Worker(本类) + * - "connect": { url: string, reconnect: boolean } + * → 触发 connect() + * + * ▶ WebSocket → 本类 + * - { action: "hello" } + * → 握手讯息(本端主动发送,收到回应则为 ack) + * + * - { action: "onchange", data: { script, uri, ... } } + * → 安装或更新脚本 + * + * + * ───────────────────────────────────────────── + * 🧠 重要设计决策 + * ───────────────────────────────────────────── + * - 使用 UUID v5(URI + URL namespace) + * → 确保同一档案路径对应固定脚本 ID + * - 30 秒连接超时与多次重连 + * → 应对 VS Code 重启或网路短暂中断 + * - ManagedWebSocket 负责事件清理与安全关闭 + * → 避免记忆体泄漏 + * - reconnect 行为由 VS Code 端控制(通常预设开启) + * + * + * ───────────────────────────────────────────── + * 🛠 常见问题排查 + * ───────────────────────────────────────────── + * - 无法连线?确认 ScriptCat 是否启动 WebSocket(常见为 localhost:25389) + * - 脚本未更新?确认 uri 正确,且 script 为完整用户脚本 + * - 重复安装?检查 stableId 是否稳定(可 console.log) + * - VS Code 重启未重连?确认扩展设定启用自动连接 + * + * + * @see https://github.com/scriptscat/scriptcat-vscode + * @see https://github.com/scriptscat/scriptcat + */ + +/* + ## Features / 功能特性 + * Initial connect 初始连接 + * Hello handshake Hello 握手 + * 30s connection timeout 30 秒连接超时 + * Auto-install on `onchange` onchange 时自动安装 + * Stable UUID from URI 基于 URI 的稳定 UUID + * Reconnect on connect failure 连接失败时自动重连 + * Reconnect on timeout 超时后自动重连 + * Reconnect after successful open 连接成功后仍支援自动重连 + * Handles VSCode restart 支援 VSCode 重启 + * Handles network drop 支援网路断线恢复 +*/ + +/** + * VSCode ↔ ScriptCat 连接管理器 + * + * ⚠️ 维护者注意: + * 本类是一个「强状态 + 并发敏感」的 WebSocket 管理器。 + * 修改 epoch / timeout / cleanup 逻辑前,请完整理解事件顺序。 + */ + +// ──────────────────────────────────────────────── +// 配置与类型定义 +// ──────────────────────────────────────────────── + +const CONFIG = { + CONNECT_TIMEOUT: 30_000, + INITIAL_RECONNECT_DELAY: 1000, + MAX_RECONNECT_DELAY: 10_000, + HANDSHAKE_MSG: JSON.stringify({ action: "hello" }), +}; + +export interface VSCodeConnectParam { + url: string; + reconnect: boolean; +} + +enum VSCodeAction { + Hello = "hello", + OnChange = "onchange", +} + +interface VSCodeMessage { + action: VSCodeAction; + data?: { + script?: string; + uri?: string; + [key: string]: unknown; + }; +} + +/** + * VSCode ↔ ScriptCat 连接管理器 + */ export class VSCodeConnect { - logger: Logger = LoggerCore.logger().with({ service: "VSCodeConnect" }); + private readonly logger = LoggerCore.logger().with({ + service: "VSCodeConnect", + }); - reconnect: boolean = false; + private ws: WebSocket | null = null; + private connectTimerId: ReturnType | null = null; + private reconnectTimerId: ReturnType | null = null; - wsConnect: WebSocket | undefined; + /** + * * epoch 用于确保非同步回调的时效性 + * * 每次发起新连接时都会递增,旧的 epoch 回调会被忽略 + */ + private epoch = 0; + private lastParams: VSCodeConnectParam | null = null; + private reconnectDelay = CONFIG.INITIAL_RECONNECT_DELAY; - connectVSCodeTimer: any; + private readonly scriptClient: ScriptClient; + private readonly messageGroup: Group; - scriptClient: ScriptClient; + constructor(messageGroup: Group, messageSender: MessageSend) { + this.messageGroup = messageGroup; + this.scriptClient = new ScriptClient(messageSender); + } - constructor( - private group: Group, - private msgSender: MessageSend - ) { - this.scriptClient = new ScriptClient(this.msgSender); + /** + * 初始化消息监听 + */ + public init(): void { + this.messageGroup.on("connect", (param: VSCodeConnectParam) => { + void this.connect(param); + }); } - connect({ url, reconnect }: { url: string; reconnect: boolean }) { - // 如果已经连接,断开重连 - if (this.wsConnect) { - this.wsConnect.close(); - } - // 清理老的定时器 - if (this.connectVSCodeTimer) { - clearInterval(this.connectVSCodeTimer); - this.connectVSCodeTimer = undefined; + /** + * 建立或替换 WebSocket 连接 + */ + private async connect(params: VSCodeConnectParam): Promise { + const currentEpoch = (this.epoch = this.epoch === Number.MAX_SAFE_INTEGER ? 1 : this.epoch + 1); + this.lastParams = { ...params }; + + this.cleanup(); + + if (!params.url?.trim()) { + this.logger.warn("Invalid VSCode connection URL provided"); + return; } - const handler = () => { - if (!this.wsConnect) { - return this.connectVSCode({ url }); + + try { + await this.openSocket(params.url, currentEpoch); + this.logger.info("VSCode WebSocket connected", { url: params.url }); + // 连接成功后重置重连间隔 + this.reconnectDelay = CONFIG.INITIAL_RECONNECT_DELAY; + } catch (err) { + if (currentEpoch !== this.epoch) return; + + this.logger.error("VSCode connection attempt failed", Logger.E(err)); + + if (params.reconnect) { + this.queueReconnect(); } - return Promise.resolve(); - }; - if (reconnect) { - this.connectVSCodeTimer = setInterval(() => { - handler(); - }, 30 * 1000); } - return handler(); } - // 连接到vscode - connectVSCode({ url }: { url: string }) { - return new Promise((resolve, reject) => { - // 如果已经连接,断开重连 - if (this.wsConnect) { - this.wsConnect.close(); - } - try { - this.wsConnect = new WebSocket(url); - } catch (e: any) { - this.logger.debug("connect vscode faild", Logger.E(e)); - reject(e); - return; - } - let ok = false; - this.wsConnect.addEventListener("open", () => { - this.wsConnect!.send('{"action":"hello"}'); - ok = true; - resolve(); - }); - this.wsConnect.addEventListener("message", async (ev) => { - const data = JSON.parse(ev.data); - switch (data.action) { - case "onchange": { - // 调用安装脚本接口 - const code = data.data.script; - this.scriptClient.installByCode(uuidv5(data.data.uri, uuidv5.URL), code, "vscode"); - break; - } - default: + // ──────────────────────────────────────────────── + // 核心连接逻辑 + // ──────────────────────────────────────────────── + + private openSocket(url: string, currentEpoch: number): Promise { + return new Promise((resolve, reject) => { + let isSettled = false; + + const finish = (error?: Error) => { + if (isSettled) return; + isSettled = true; + if (this.connectTimerId) { + clearTimeout(this.connectTimerId); + this.connectTimerId = null; } - }); - this.wsConnect.addEventListener("error", (e) => { - this.wsConnect = undefined; - this.logger.debug("connect vscode faild", Logger.E(e)); - if (!ok) { - reject(new Error("connect fail")); + if (error) { + reject(error); + } else { + resolve(); } - }); + }; - this.wsConnect.addEventListener("close", () => { - this.wsConnect = undefined; - this.logger.debug("vscode connection closed"); - }); + try { + const socket = new WebSocket(url); + this.ws = socket; + + // 连接超时处理 + this.connectTimerId = setTimeout(() => { + if (currentEpoch !== this.epoch) return; + this.logger.debug("Connection timeout reached"); + this.cleanup(); + finish(new Error("Socket connection timeout")); + }, CONFIG.CONNECT_TIMEOUT); + + socket.onopen = () => { + if (currentEpoch !== this.epoch) { + socket.close(); + return; + } + socket.send(CONFIG.HANDSHAKE_MSG); + finish(); + }; + + socket.onmessage = (ev) => { + if (currentEpoch === this.epoch) { + this.handleSocketMessage(ev); + } + }; + + socket.onerror = (_ev) => { + if (currentEpoch !== this.epoch) return; + this.logger.debug("WebSocket error", { epoch: currentEpoch }); + finish(new Error("WebSocket error")); + this.queueReconnect(); + }; + + socket.onclose = () => { + if (currentEpoch !== this.epoch) return; + this.logger.debug("WebSocket closed", { epoch: currentEpoch }); + finish(new Error("WebSocket closed")); + this.queueReconnect(); + }; + } catch (err) { + finish(err instanceof Error ? err : new Error(String(err))); + } }); } - init() { - this.group.on("connect", this.connect.bind(this)); + // ──────────────────────────────────────────────── + // 业务逻辑处理 + // ──────────────────────────────────────────────── + + private handleSocketMessage(ev: MessageEvent): void { + if (typeof ev.data !== "string") return; + + try { + const msg = JSON.parse(ev.data) as VSCodeMessage; + + switch (msg.action) { + case VSCodeAction.Hello: + this.logger.debug("Handshake acknowledged by VSCode"); + break; + + case VSCodeAction.OnChange: + this.processScriptUpdate(msg.data); + break; + + default: + this.logger.warn("Received unsupported action", { action: msg.action }); + } + } catch (err) { + this.logger.error("Failed to parse or handle message", Logger.E(err)); + } + } + + private async processScriptUpdate(data: VSCodeMessage["data"]): Promise { + const { script, uri } = data ?? {}; + + if (!script || !uri) { + this.logger.warn("Received incomplete script update payload"); + return; + } + + try { + // 使用 URI 作为 Seed 生成固定 ID + const stableId = uuidv5(uri, uuidv5.URL); + await this.scriptClient.installByCode(stableId, script, "vscode"); + + this.logger.info("Script synced successfully", { + uri, + uuid: stableId, + }); + } catch (err) { + this.logger.error("Failed to install script from VSCode", Logger.E(err)); + } + } + + // ──────────────────────────────────────────────── + // 生命周期管理 + // ──────────────────────────────────────────────── + + private queueReconnect(): void { + if (!this.lastParams?.reconnect || this.reconnectTimerId) return; + + this.logger.debug(`Scheduling reconnect in ${this.reconnectDelay}ms`); + + this.reconnectTimerId = setTimeout(() => { + this.reconnectTimerId = null; + + // 指数退避策略 + this.reconnectDelay = Math.min(this.reconnectDelay * 1.5, CONFIG.MAX_RECONNECT_DELAY); + + if (this.lastParams) { + void this.connect(this.lastParams); + } + }, this.reconnectDelay); + } + + /** + * 彻底清理资源,确保没有悬挂的 Socket 或 Timer + */ + private cleanup(): void { + if (this.connectTimerId) { + clearTimeout(this.connectTimerId); + this.connectTimerId = null; + } + if (this.reconnectTimerId) { + clearTimeout(this.reconnectTimerId); + this.reconnectTimerId = null; + } + if (this.ws) { + // 移除所有回调防止内存泄漏 + this.ws.onopen = null; + this.ws.onclose = null; + this.ws.onerror = null; + this.ws.onmessage = null; + + if (this.ws.readyState === WebSocket.CONNECTING || this.ws.readyState === WebSocket.OPEN) { + this.ws.close(); + } + this.ws = null; + } } } diff --git a/src/app/service/service_worker/client.ts b/src/app/service/service_worker/client.ts index da64bf805..87d328f51 100644 --- a/src/app/service/service_worker/client.ts +++ b/src/app/service/service_worker/client.ts @@ -12,7 +12,7 @@ import { v4 as uuidv4 } from "uuid"; import { cacheInstance } from "@App/app/cache"; import { CACHE_KEY_IMPORT_FILE } from "@App/app/cache_key"; import { type ResourceBackup } from "@App/pkg/backup/struct"; -import { type VSCodeConnect } from "../offscreen/vscode-connect"; +import { type VSCodeConnectParam } from "../offscreen/vscode-connect"; import { type ScriptInfo } from "@App/pkg/utils/scriptInstall"; import type { ScriptService, TCheckScriptUpdateOption, TOpenBatchUpdatePageOption } from "./script"; import { encodeRValue, type TKeyValuePair } from "@App/pkg/utils/message_value"; @@ -400,7 +400,7 @@ export class SystemClient extends Client { super(msgSender, "serviceWorker/system"); } - connectVSCode(params: Parameters[0]): ReturnType { + connectVSCode(params: VSCodeConnectParam): Promise { return this.do("connectVSCode", params); } } From b49813405552790a89e9b583d84da286f2cb7e01 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 12 Feb 2026 21:46:08 +0900 Subject: [PATCH 2/2] =?UTF-8?q?AI=20=E5=86=8D=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/offscreen/vscode-connect.ts | 329 ++++++++++---------- 1 file changed, 159 insertions(+), 170 deletions(-) diff --git a/src/app/service/offscreen/vscode-connect.ts b/src/app/service/offscreen/vscode-connect.ts index d96361130..0d627df8f 100644 --- a/src/app/service/offscreen/vscode-connect.ts +++ b/src/app/service/offscreen/vscode-connect.ts @@ -143,263 +143,252 @@ import { v5 as uuidv5 } from "uuid"; */ // ──────────────────────────────────────────────── -// 配置与类型定义 +// 类型定义 // ──────────────────────────────────────────────── const CONFIG = { CONNECT_TIMEOUT: 30_000, - INITIAL_RECONNECT_DELAY: 1000, + BASE_RECONNECT_DELAY: 1000, MAX_RECONNECT_DELAY: 10_000, - HANDSHAKE_MSG: JSON.stringify({ action: "hello" }), -}; + NAMESPACE: uuidv5.URL, // 缓存 UUID Namespace +} as const; export interface VSCodeConnectParam { url: string; reconnect: boolean; } -enum VSCodeAction { - Hello = "hello", - OnChange = "onchange", -} - interface VSCodeMessage { - action: VSCodeAction; + action: "hello" | "onchange"; data?: { script?: string; uri?: string; - [key: string]: unknown; }; } /** - * VSCode ↔ ScriptCat 连接管理器 + * VSCode ↔ ScriptCat 连接管理器 (Refactored) + * 核心目标:稳定、易读、无内存泄漏 */ export class VSCodeConnect { - private readonly logger = LoggerCore.logger().with({ - service: "VSCodeConnect", - }); - - private ws: WebSocket | null = null; - private connectTimerId: ReturnType | null = null; - private reconnectTimerId: ReturnType | null = null; - - /** - * * epoch 用于确保非同步回调的时效性 - * * 每次发起新连接时都会递增,旧的 epoch 回调会被忽略 - */ - private epoch = 0; - private lastParams: VSCodeConnectParam | null = null; - private reconnectDelay = CONFIG.INITIAL_RECONNECT_DELAY; - + private readonly logger = LoggerCore.logger().with({ service: "VSCodeConnect" }); private readonly scriptClient: ScriptClient; - private readonly messageGroup: Group; - constructor(messageGroup: Group, messageSender: MessageSend) { - this.messageGroup = messageGroup; + // 状态管理 + private ws: WebSocket | null = null; + private epoch = 0; // 用于废弃旧连接的回调 + private currentParams: VSCodeConnectParam | null = null; + + // 重连策略状态 + private reconnectDelay: number = CONFIG.BASE_RECONNECT_DELAY; + private reconnectTimer: ReturnType | null = null; + private connectTimeoutTimer: ReturnType | null = null; + + constructor( + private readonly messageGroup: Group, + messageSender: MessageSend + ) { this.scriptClient = new ScriptClient(messageSender); } - /** - * 初始化消息监听 - */ public init(): void { - this.messageGroup.on("connect", (param: VSCodeConnectParam) => { - void this.connect(param); + this.messageGroup.on("connect", (params: VSCodeConnectParam) => { + // this.logger.info("Received connect request", params); + // 重置重连延迟 + this.reconnectDelay = CONFIG.BASE_RECONNECT_DELAY; + this.startSession(params); }); } /** - * 建立或替换 WebSocket 连接 + * 启动一个新的连接会话 + * 每次调用都会递增 epoch,自动使旧的连接和定时器失效 */ - private async connect(params: VSCodeConnectParam): Promise { - const currentEpoch = (this.epoch = this.epoch === Number.MAX_SAFE_INTEGER ? 1 : this.epoch + 1); - this.lastParams = { ...params }; + private startSession(params: VSCodeConnectParam): void { + this.dispose(); // 彻底清理旧资源 + this.currentParams = params; - this.cleanup(); + // 开启新一轮连接 + this.epoch++; + this.connect(this.epoch); + } - if (!params.url?.trim()) { - this.logger.warn("Invalid VSCode connection URL provided"); - return; - } + private isReconnecting = false; // 状态锁:防止重复触发重连 + /** + * 执行实际连接逻辑 + */ + private connect(sessionEpoch: number): void { + const url = this.currentParams?.url; + if (!url) return; try { - await this.openSocket(params.url, currentEpoch); - this.logger.info("VSCode WebSocket connected", { url: params.url }); - // 连接成功后重置重连间隔 - this.reconnectDelay = CONFIG.INITIAL_RECONNECT_DELAY; - } catch (err) { - if (currentEpoch !== this.epoch) return; - - this.logger.error("VSCode connection attempt failed", Logger.E(err)); - - if (params.reconnect) { - this.queueReconnect(); - } + this.logger.debug(`Attempting connection (Epoch: ${sessionEpoch})`, { url }); + this.isReconnecting = false; // 开始新连接时重置锁 + this.ws = new WebSocket(url); + + // 设置连接超时看门狗 + this.connectTimeoutTimer = setTimeout(() => { + if (sessionEpoch === this.epoch) { + this.logger.warn("Connection timeout"); + this.ws?.close(); + } + }, CONFIG.CONNECT_TIMEOUT); + + // 绑定事件 + this.ws.onopen = () => this.handleOpen(sessionEpoch); + this.ws.onmessage = (ev) => this.handleMessage(ev, sessionEpoch); + this.ws.onclose = () => this.handleClose(sessionEpoch); + this.ws.onerror = (ev) => this.handleError(ev, sessionEpoch); + } catch (e) { + this.logger.error("WebSocket creation failed", Logger.E(e)); + this.handleError(e, sessionEpoch); } } // ──────────────────────────────────────────────── - // 核心连接逻辑 + // 事件处理 (Event Handlers) // ──────────────────────────────────────────────── - private openSocket(url: string, currentEpoch: number): Promise { - return new Promise((resolve, reject) => { - let isSettled = false; + private handleOpen(sessionEpoch: number): void { + if (sessionEpoch !== this.epoch) return; - const finish = (error?: Error) => { - if (isSettled) return; - isSettled = true; - if (this.connectTimerId) { - clearTimeout(this.connectTimerId); - this.connectTimerId = null; - } + this.logger.info("WebSocket connected"); - if (error) { - reject(error); - } else { - resolve(); - } - }; - - try { - const socket = new WebSocket(url); - this.ws = socket; - - // 连接超时处理 - this.connectTimerId = setTimeout(() => { - if (currentEpoch !== this.epoch) return; - this.logger.debug("Connection timeout reached"); - this.cleanup(); - finish(new Error("Socket connection timeout")); - }, CONFIG.CONNECT_TIMEOUT); - - socket.onopen = () => { - if (currentEpoch !== this.epoch) { - socket.close(); - return; - } - socket.send(CONFIG.HANDSHAKE_MSG); - finish(); - }; - - socket.onmessage = (ev) => { - if (currentEpoch === this.epoch) { - this.handleSocketMessage(ev); - } - }; - - socket.onerror = (_ev) => { - if (currentEpoch !== this.epoch) return; - this.logger.debug("WebSocket error", { epoch: currentEpoch }); - finish(new Error("WebSocket error")); - this.queueReconnect(); - }; - - socket.onclose = () => { - if (currentEpoch !== this.epoch) return; - this.logger.debug("WebSocket closed", { epoch: currentEpoch }); - finish(new Error("WebSocket closed")); - this.queueReconnect(); - }; - } catch (err) { - finish(err instanceof Error ? err : new Error(String(err))); - } - }); - } + // 清除超时检测 + if (this.connectTimeoutTimer) { + clearTimeout(this.connectTimeoutTimer); + this.connectTimeoutTimer = null; + } - // ──────────────────────────────────────────────── - // 业务逻辑处理 - // ──────────────────────────────────────────────── + // 重置重连指数退避 + this.reconnectDelay = CONFIG.BASE_RECONNECT_DELAY; + + // 发送握手 + this.send({ action: "hello" }); + } - private handleSocketMessage(ev: MessageEvent): void { - if (typeof ev.data !== "string") return; + private handleMessage(ev: MessageEvent, sessionEpoch: number): void { + if (sessionEpoch !== this.epoch) return; try { - const msg = JSON.parse(ev.data) as VSCodeMessage; + const msg = JSON.parse(ev.data as string) as VSCodeMessage; switch (msg.action) { - case VSCodeAction.Hello: - this.logger.debug("Handshake acknowledged by VSCode"); + case "hello": + this.logger.debug("Handshake confirmed"); break; - - case VSCodeAction.OnChange: - this.processScriptUpdate(msg.data); + case "onchange": + void this.handleScriptUpdate(msg.data); break; - default: - this.logger.warn("Received unsupported action", { action: msg.action }); + this.logger.warn("Unknown action received", { action: msg.action }); } - } catch (err) { - this.logger.error("Failed to parse or handle message", Logger.E(err)); + } catch (e) { + this.logger.warn("Failed to parse message", Logger.E(e)); + } + } + + private handleClose(sessionEpoch: number): void { + if (sessionEpoch !== this.epoch) return; + + // 💡 关闭时不仅置空 ws,也要清理超时计时器 + if (this.connectTimeoutTimer) { + clearTimeout(this.connectTimeoutTimer); + this.connectTimeoutTimer = null; } + + this.ws = null; + this.logger.debug("WebSocket connection closed"); + + // 无论是由 onerror 还是 onclose 触发,scheduleReconnect 内部的锁 (isReconnecting) + // 都会确保同一 Epoch 下只开启一个重连计时器,此处作为保底调用。 + this.scheduleReconnect(); } - private async processScriptUpdate(data: VSCodeMessage["data"]): Promise { - const { script, uri } = data ?? {}; + private handleError(ev: Event | Error | unknown, sessionEpoch: number): void { + if (sessionEpoch !== this.epoch) return; + this.logger.error("WebSocket error", { + event: ev instanceof Event ? ev.type : undefined, + error: ev instanceof Error ? ev.message : String(ev), + }); + // 发生错误时立即尝试介入重连,无需等待 onclose 事件。 + // 内部锁会拦截后续 handleClose 发起的重复请求。 + this.scheduleReconnect(); + } + // ──────────────────────────────────────────────── + // 业务逻辑 + // ──────────────────────────────────────────────── + + private async handleScriptUpdate(data: VSCodeMessage["data"]): Promise { + const { script, uri } = data || {}; if (!script || !uri) { - this.logger.warn("Received incomplete script update payload"); + this.logger.warn("Invalid script update payload", { uri }); return; } try { - // 使用 URI 作为 Seed 生成固定 ID - const stableId = uuidv5(uri, uuidv5.URL); + const stableId = uuidv5(uri, CONFIG.NAMESPACE); await this.scriptClient.installByCode(stableId, script, "vscode"); + this.logger.info("Script installed/updated", { uuid: stableId, uri }); + } catch (e) { + this.logger.error("Install failed", Logger.E(e)); + } + } - this.logger.info("Script synced successfully", { - uri, - uuid: stableId, - }); - } catch (err) { - this.logger.error("Failed to install script from VSCode", Logger.E(err)); + private send(msg: VSCodeMessage): void { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(msg)); } } // ──────────────────────────────────────────────── - // 生命周期管理 + // 辅助与生命周期 // ──────────────────────────────────────────────── - private queueReconnect(): void { - if (!this.lastParams?.reconnect || this.reconnectTimerId) return; - + private scheduleReconnect(): void { + if (this.isReconnecting) return; + // 如果不允许重连,或者已经在重连中,或者 Socket 还是开启状态,则跳过 + if (!this.currentParams?.reconnect || this.reconnectTimer) return; + const sessionEpoch = this.epoch; // 锁定当前的 epoch + this.isReconnecting = true; // 上锁 this.logger.debug(`Scheduling reconnect in ${this.reconnectDelay}ms`); - this.reconnectTimerId = setTimeout(() => { - this.reconnectTimerId = null; + this.reconnectTimer = setTimeout(() => { + // 修正 3: 双重检查 epoch,确保在等待重连期间没有开启新的 Session + if (sessionEpoch !== this.epoch) return; - // 指数退避策略 + this.reconnectTimer = null; + this.isReconnecting = false; this.reconnectDelay = Math.min(this.reconnectDelay * 1.5, CONFIG.MAX_RECONNECT_DELAY); - if (this.lastParams) { - void this.connect(this.lastParams); - } + this.connect(sessionEpoch); }, this.reconnectDelay); } /** - * 彻底清理资源,确保没有悬挂的 Socket 或 Timer + * 销毁当前连接资源 */ - private cleanup(): void { - if (this.connectTimerId) { - clearTimeout(this.connectTimerId); - this.connectTimerId = null; + private dispose(): void { + this.isReconnecting = false; // 彻底销毁时重置状态 + + // 1. 停止所有定时器 + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; } - if (this.reconnectTimerId) { - clearTimeout(this.reconnectTimerId); - this.reconnectTimerId = null; + if (this.connectTimeoutTimer) { + clearTimeout(this.connectTimeoutTimer); + this.connectTimeoutTimer = null; } + + // 2. 关闭 Socket 并移除事件监听 (通过设为 null 配合 GC) if (this.ws) { - // 移除所有回调防止内存泄漏 this.ws.onopen = null; this.ws.onclose = null; - this.ws.onerror = null; this.ws.onmessage = null; - - if (this.ws.readyState === WebSocket.CONNECTING || this.ws.readyState === WebSocket.OPEN) { - this.ws.close(); - } + this.ws.onerror = null; + this.ws.close(); this.ws = null; } }