Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 60 additions & 8 deletions packages/message/extension_message.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import EventEmitter from "eventemitter3";
import type { Message, MessageConnect, MessageSend, RuntimeMessageSender, TMessage, TMessageCommAction } from "./types";
import { uuidv4 } from "@App/pkg/utils/uuid";

const listenerMgr = new EventEmitter<string, any>(); // 单一管理器
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这段代码引入了严重的内存泄漏问题。使用全局单例 listenerMgr 来管理所有连接实例的监听器会导致以下问题:

  1. 全局 EventEmitter 累积:每个 MessageConnect 实例都在全局 listenerMgr 上注册监听器,但即使连接断开并清理,EventEmitter 本身仍然占用内存。
  2. listenerId 累积:每个连接都生成唯一的 listenerId,这些 ID 对应的事件键会永久保留在 EventEmitter 的内部映射中。
  3. handler 闭包引用:line 157-159 的 handler 函数捕获了 this 引用,而这个 handler 被添加到 Chrome 的 con.onMessage 监听器中(line 171),同时也在 line 173 通过 listenerMgr.once 注册(这个用途不明确)。

建议的替代方案:

  • 使用实例级别的 EventEmitter,而非全局单例
  • 或者确保在 cleanup 时完全移除所有相关的事件键,包括从全局 listenerMgr 中删除

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

全部都有好好清理。别閙了copilot


export class ExtensionMessage implements Message {
constructor(private backgroundPrimary = false) {}
Expand All @@ -19,8 +23,6 @@ export class ExtensionMessage implements Message {
if (lastError) {
console.error("chrome.runtime.lastError in chrome.runtime.sendMessage:", lastError);
// 通信API出错不回继续对话
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

删除这两行代码改变了错误处理行为。原有代码在遇到 chrome.runtime.lastError 时会:

  1. resolve 置为 null(防止后续调用)
  2. 提前返回(不执行后续的 resolve 调用)

删除后,即使发生错误,代码仍会继续执行到 line 27 的 resolve!(resp),此时 resp 的值可能是 undefined,这可能不是预期的行为。

建议保留 return; 语句,以保持原有的错误处理逻辑。删除 resolve = null; 是合理的(因为下面会重新赋值),但应该保留提前返回。

Suggested change
// 通信API出错不回继续对话
// 通信API出错不回继续对话
return;

Copilot uses AI. Check for mistakes.
resolve = null;
return;
}
resolve!(resp);
resolve = null;
Expand Down Expand Up @@ -146,25 +148,75 @@ export class ExtensionMessage implements Message {
}

export class ExtensionMessageConnect implements MessageConnect {
constructor(private con: chrome.runtime.Port) {}
private readonly listenerId = `${uuidv4()}`; // 使用 uuidv4 确保唯一
private con: chrome.runtime.Port | null;
private isSelfDisconnected = false;

constructor(con: chrome.runtime.Port) {
this.con = con; // 强引用
const handler = (msg: TMessage, _con: chrome.runtime.Port) => {
listenerMgr.emit(`onMessage:${this.listenerId}`, msg);
};
const cleanup = (con: chrome.runtime.Port) => {
if (this.con) {
this.con = null;
listenerMgr.removeAllListeners(`cleanup:${this.listenerId}`);
con.onMessage.removeListener(handler);
con.onDisconnect.removeListener(cleanup);
listenerMgr.emit(`onDisconnect:${this.listenerId}`, this.isSelfDisconnected);
listenerMgr.removeAllListeners(`onDisconnect:${this.listenerId}`);
listenerMgr.removeAllListeners(`onMessage:${this.listenerId}`);
}
};
con.onMessage.addListener(handler);
con.onDisconnect.addListener(cleanup);
listenerMgr.once(`cleanup:${this.listenerId}`, cleanup);
}

sendMessage(data: TMessage) {
this.con.postMessage(data);
if (!this.con) {
console.warn("Attempted to sendMessage on a disconnected port.");
// 無法 sendMessage 不应该屏蔽错误
throw new Error("Attempted to sendMessage on a disconnected port.");
}
this.con?.postMessage(data);
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里使用了可选链操作符 ?.,但前面已经检查了 !this.con,这是多余的。如果代码执行到这里,this.con 一定不为 null。

建议直接使用:this.con.postMessage(data);

Copilot uses AI. Check for mistakes.
}

onMessage(callback: (data: TMessage) => void) {
this.con.onMessage.addListener(callback);
if (!this.con) {
console.error("onMessage Invalid Port");
// 無法監聽的話不应该屏蔽错误
throw new Error("onMessage Invalid Port");
}
listenerMgr.addListener(`onMessage:${this.listenerId}`, callback);
Comment on lines 185 to 191
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里存在逻辑错误。当 this.con 为 null 时记录了错误日志,但仍然继续执行 listenerMgr.addListener,这会导致后续无法接收到任何消息(因为没有 Chrome port 在监听)。

建议改为:

  1. 如果 this.con 为 null,应该直接返回或抛出错误
  2. 或者在添加监听器之前检查连接状态

Copilot uses AI. Check for mistakes.
}

disconnect() {
this.con.disconnect();
if (!this.con) {
console.warn("Attempted to disconnect on a disconnected port.");
// 重复 disconnect() 不应该屏蔽错误
throw new Error("Attempted to disconnect on a disconnected port.");
}
this.isSelfDisconnected = true;
this.con?.disconnect();
// Note: .disconnect() will NOT automatically trigger the 'cleanup' listener
listenerMgr.emit(`cleanup:${this.listenerId}`);
Comment on lines 194 to 203
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里存在竞态条件问题。在 line 196 设置 isSelfDisconnected = true 后,line 197 调用 this.con?.disconnect(),然后 line 199 立即触发 cleanup。但是:

  1. Chrome 的 port.disconnect() 会异步触发 onDisconnect 监听器(line 172 注册的 cleanup 函数)
  2. Line 199 的 listenerMgr.emit('cleanup:...') 会立即同步执行
  3. 这可能导致 cleanup 被执行两次(一次同步,一次异步),虽然有 if (this.con) 保护,但这种设计容易产生混乱

建议:

  • 要么只依赖 Chrome 的 onDisconnect 自动清理(删除 line 199)
  • 要么在手动触发 cleanup 前先移除 Chrome 的监听器,避免重复清理

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bdc4a9e 推前了
eventemitter3 是同步的
假如有这个情况发生 (实际上不会因为 Chrome.runtime.Port 自己呼叫时不会触发 onDisconnected )
this.con 会先清空
保证一次

}

onDisconnect(callback: () => void) {
this.con.onDisconnect.addListener(callback);
onDisconnect(callback: (isSelfDisconnected: boolean) => void) {
if (!this.con) {
console.error("onDisconnect Invalid Port");
// 無法監聽的話不应该屏蔽错误
throw new Error("onDisconnect Invalid Port");
}
listenerMgr.once(`onDisconnect:${this.listenerId}`, callback);
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

使用 listenerMgr.once 限制了每个 listenerId 只能有一个 onDisconnect 回调。但在 forwardMessage 函数(server.ts:282-283)中,对同一个连接调用了两次 onDisconnect

fromConnect.onDisconnect(toCon.disconnect.bind(toCon));
toCon.onDisconnect(fromConnect.disconnect.bind(fromConnect));

这意味着第二次调用会覆盖第一次的回调,导致其中一个不会被执行。这是一个严重的 bug,会破坏连接转发的清理逻辑。

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

不会. EventEmitter3 的 once 不会 覆盖原有的callback

Comment on lines 206 to 212
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

同样的错误:当 this.con 为 null 时记录错误但继续执行。这里应该在连接已断开的情况下直接返回或抛出错误,而不是继续注册监听器。

另外,listenerMgr.once 意味着这个回调只会被触发一次,但如果用户多次调用 onDisconnect 添加多个回调,只有最后一个会被执行(因为都注册到同一个事件键 onDisconnect:${this.listenerId} 上,且都使用 once)。这与原有的行为不一致。

原有实现允许添加多个 onDisconnect 监听器(通过 con.onDisconnect.addListener),现在的实现只支持一个。

Copilot uses AI. Check for mistakes.
}

getPort(): chrome.runtime.Port {
if (!this.con) {
console.error("Port is already disconnected.");
throw new Error("Port is already disconnected.");
}
return this.con;
}
}
Expand Down
6 changes: 3 additions & 3 deletions packages/message/mock_message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@ export class MockMessageConnect implements MessageConnect {
}

disconnect(): void {
this.EE.emit("disconnect");
this.EE.emit("disconnect", true); // MockMessageConnect 未有模拟由另一端触发 disconnect() 的情况
}

onDisconnect(callback: () => void): void {
this.EE.on("disconnect", callback);
onDisconnect(callback: (isSelfDisconnected: boolean) => void) {
this.EE.once("disconnect", callback);
}
}

Expand Down
16 changes: 8 additions & 8 deletions packages/message/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,15 +273,15 @@ export function forwardMessage(
senderTo: MessageSend,
middleware?: ApiFunctionSync
) {
const handler = (params: any, fromCon: IGetSender) => {
const fromConnect = fromCon.getConnect();
const handler = async (params: any, fromCon: IGetSender): Promise<any> => {
const fromConnect: MessageConnect | undefined = fromCon.getConnect();
if (fromConnect) {
connect(senderTo, `${prefix}/${path}`, params).then((toCon: MessageConnect) => {
fromConnect.onMessage(toCon.sendMessage.bind(toCon));
toCon.onMessage(fromConnect.sendMessage.bind(fromConnect));
fromConnect.onDisconnect(toCon.disconnect.bind(toCon));
toCon.onDisconnect(fromConnect.disconnect.bind(fromConnect));
});
const toCon: MessageConnect = await connect(senderTo, `${prefix}/${path}`, params);
fromConnect.onMessage(toCon.sendMessage.bind(toCon));
toCon.onMessage(fromConnect.sendMessage.bind(fromConnect));
fromConnect.onDisconnect(toCon.disconnect.bind(toCon));
toCon.onDisconnect(fromConnect.disconnect.bind(fromConnect));
return undefined;
Comment on lines +276 to +284
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这个改动将 handler 函数改为 async,但没有处理可能的 Promise rejection。如果 connect() 或后续操作抛出异常,这个 Promise 不会被捕获,可能导致未处理的 Promise rejection。

建议:

  1. 添加 try-catch 包装
  2. 或者保持原有的 Promise 处理方式(使用 .then().catch())
  3. 或者确保调用方能够处理 async 函数的 rejection

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

返回 undefined 是明确的,但在原有代码中,这个分支没有显式返回值。虽然 JavaScript 中函数默认返回 undefined,但显式返回 undefined 可能会影响调用方的类型推断。

建议检查调用方是否期望这个返回值,如果不需要,可以省略 return 语句。

Suggested change
return undefined;

Copilot uses AI. Check for mistakes.
} else {
return sendMessage(senderTo, prefix + "/" + path, params);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/message/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export interface MessageConnect {
onMessage(callback: (data: TMessage) => void): void;
sendMessage(data: TMessage): void;
disconnect(): void;
onDisconnect(callback: () => void): void;
onDisconnect(callback: (isSelfDisconnected: boolean) => void): void;
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这是一个破坏性的 API 变更。原有的 onDisconnect 回调不接受任何参数,现在改为接受 isSelfDisconnected: boolean 参数。这会导致现有代码中所有使用 onDisconnect 的地方出现问题。

在代码库中发现的使用示例(如 src/app/service/service_worker/gm_api/gm_api.ts:746):

msgConn.onDisconnect(() => {
  isConnDisconnected = true;
});

这些现有代码的回调函数签名与新接口不匹配,虽然在运行时不会报错(因为 JavaScript 允许忽略额外参数),但违反了类型约定。

建议:

  1. 保持向后兼容,将参数设为可选:callback: (isSelfDisconnected?: boolean) => void
  2. 或者在 PR 中同步更新所有使用该 API 的代码
  3. 或者使用函数重载来支持两种签名
Suggested change
onDisconnect(callback: (isSelfDisconnected: boolean) => void): void;
onDisconnect(callback: (isSelfDisconnected?: boolean) => void): void;

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

msgConn.onDisconnect(() => { 只是它不使用
但API里有提供

}

export type ExtMessageSender = {
Expand Down
62 changes: 52 additions & 10 deletions packages/message/window_message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import type { Message, MessageConnect, MessageSend, RuntimeMessageSender, TMessa
import { uuidv4 } from "@App/pkg/utils/uuid";
import EventEmitter from "eventemitter3";

const listenerMgr = new EventEmitter<string, any>(); // 单一管理器

Comment on lines +5 to +6
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

与 extension_message.ts 相同的全局 EventEmitter 内存泄漏问题。每个 WindowMessageConnect 实例都在全局单例 listenerMgr 上注册监听器,即使连接断开,这些事件键仍会保留在 EventEmitter 的内部映射中,导致内存累积。

Suggested change
const listenerMgr = new EventEmitter<string, any>(); // 单一管理器
// 自动清理监听器的全局管理器,避免长期累积事件键导致内存泄漏
class AutoCleanEventEmitter extends EventEmitter<string, any> {
override on(event: string, fn: (...args: any[]) => void, context?: any): this {
const wrapped = (...args: any[]) => {
// 触发一次后立刻移除监听,避免在全局单例上无限累积
this.off(event, wrapped, context);
// 保持原有回调调用语义
// 使用提供的 context,如果没有则保持 EventEmitter 默认行为
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
fn.apply(context ?? this, args);
};
return super.on(event, wrapped, context);
}
}
const listenerMgr = new AutoCleanEventEmitter(); // 单一管理器,自动清理监听

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这什么鬼?不需要

// 通过 window.postMessage/onmessage 实现通信

export interface PostMessage {
Expand Down Expand Up @@ -121,19 +123,41 @@ export class WindowMessage implements Message {
}

export class WindowMessageConnect implements MessageConnect {
private readonly listenerId = `${uuidv4()}`; // 使用 uuidv4 确保唯一
private target: PostMessage | null;
private isSelfDisconnected = false;

constructor(
private messageId: string,
private EE: EventEmitter<string, any>,
private target: PostMessage
EE: EventEmitter<string, any>,
target: PostMessage
) {
this.onDisconnect(() => {
// 移除所有监听
this.EE.removeAllListeners("connectMessage:" + this.messageId);
this.EE.removeAllListeners("disconnect:" + this.messageId);
});
this.target = target; // 强引用
const handler = (msg: TMessage) => {
listenerMgr.emit(`onMessage:${this.listenerId}`, msg);
};
const cleanup = () => {
if (this.target) {
this.target = null;
listenerMgr.removeAllListeners(`cleanup:${this.listenerId}`);
EE.removeAllListeners("connectMessage:" + this.messageId); // 模拟 con.onMessage.removeListener
EE.removeAllListeners("disconnect:" + this.messageId); // 模拟 con.onDisconnect.removeListener
listenerMgr.emit(`onDisconnect:${this.listenerId}`, this.isSelfDisconnected);
listenerMgr.removeAllListeners(`onDisconnect:${this.listenerId}`);
listenerMgr.removeAllListeners(`onMessage:${this.listenerId}`);
}
};
EE.addListener(`connectMessage:${this.messageId}`, handler); // 模拟 con.onMessage.addListener
EE.addListener(`disconnect:${this.messageId}`, cleanup); // 模拟 con.onDisconnect.addListener
listenerMgr.once(`cleanup:${this.listenerId}`, cleanup);
}

sendMessage(data: TMessage) {
if (!this.target) {
console.error("Attempted to sendMessage on a disconnected Target.");
// 無法 sendMessage 不应该屏蔽错误
throw new Error("Attempted to sendMessage on a disconnected Target.");
}
const body: WindowMessageBody<TMessage> = {
messageId: this.messageId,
type: "connectMessage",
Expand All @@ -143,20 +167,38 @@ export class WindowMessageConnect implements MessageConnect {
}

onMessage(callback: (data: TMessage) => void) {
this.EE.addListener(`connectMessage:${this.messageId}`, callback);
if (!this.target) {
console.error("onMessage Invalid Target");
// 無法監聽的話不应该屏蔽错误
throw new Error("onMessage Invalid Target");
}
listenerMgr.addListener(`onMessage:${this.listenerId}`, callback);
Comment on lines 169 to 175
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

与 extension_message.ts 相同的问题:当 this.target 为 null 时记录错误但继续执行。这会导致监听器被添加但永远不会被触发,造成无声的失败。建议在连接已断开时直接返回或抛出错误。

Copilot uses AI. Check for mistakes.
}

disconnect() {
if (!this.target) {
console.warn("Attempted to disconnect on a disconnected Target.");
// 重复 disconnect() 不应该屏蔽错误
throw new Error("Attempted to disconnect on a disconnected Target.");
}
this.isSelfDisconnected = true;
const body: WindowMessageBody<TMessage> = {
messageId: this.messageId,
type: "disconnect",
data: null,
};
this.target.postMessage(body);
// Note: .disconnect() will NOT automatically trigger the 'cleanup' listener
listenerMgr.emit(`cleanup:${this.listenerId}`);
}

onDisconnect(callback: () => void) {
this.EE.addListener(`disconnect:${this.messageId}`, callback);
onDisconnect(callback: (isSelfDisconnected: boolean) => void) {
if (!this.target) {
console.error("onDisconnect Invalid Target");
// 無法監聽的話不应该屏蔽错误
throw new Error("onDisconnect Invalid Target");
}
listenerMgr.once(`onDisconnect:${this.listenerId}`, callback);
Comment on lines 195 to 201
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

与 extension_message.ts 相同的问题:

  1. 竞态条件:postMessage 和 cleanup emit 可能导致重复清理
  2. 使用 listenerMgr.once 限制只能有一个 onDisconnect 回调,破坏了多次调用的能力
  3. 当 target 为 null 时继续执行

Copilot uses AI. Check for mistakes.
}
}

Expand Down
Loading