- commit
- 9545d7e
- parent
- 3a5a93d
- author
- codex@macbookpro
- date
- 2026-03-31 16:39:22 +0800 CST
fix: buffer startup plugin diagnostic logs before ws open
3 files changed,
+341,
-5
+35,
-0
1@@ -0,0 +1,35 @@
2+# FIX-BUG-027: 插件启动期诊断事件在 WS 建立前不再静默丢失
3+
4+## 执行状态
5+
6+- 已完成(2026-03-31,代码 + 自动化验证已落地)
7+
8+## 关联 Bug
9+
10+BUG-027-startup-plugin-diagnostic-events-lost-before-ws-open.md
11+
12+## 实际修改文件
13+
14+- `plugins/baa-firefox/controller.js`
15+- `plugins/baa-firefox/controller.test.cjs`
16+
17+## 实际修改
18+
19+- `controller.js` 现在只对 `plugin_diagnostic_log` 增加一个小型内存缓冲;当 WS 尚未 `open` 时,不再直接丢弃启动期诊断事件,而是先按原协议格式暂存在内存里。
20+- 缓冲队列采用固定上限 `50` 条,只保留最新事件,避免插件在 WS 未连通期间无上限积压日志。
21+- `connectWs().onopen` 在发送 `hello` 后会立即 flush 这批已缓冲的诊断事件,再继续现有的 “WS 已连接 / credentials / endpoints” 流程,保持 conductor 侧 `plugin_diagnostic_log` 协议兼容。
22+- 新增 `controller.test.cjs`,覆盖两类回归:
23+ - `page_bridge_ready` / `interceptor_active` 先产生、WS 后 `open` 时会自动 flush
24+ - 队列命中上限时只保留最新一批,并按原顺序 flush
25+
26+## 验收标准
27+
28+1. `page_bridge_ready`、`interceptor_active` 等启动期诊断事件在 WS 尚未 `open` 时不会静默丢失。
29+2. WS 建立后会自动补发这批早期事件,且 conductor 侧仍按现有 `plugin_diagnostic_log` 协议落盘。
30+3. 诊断缓冲有固定上限,不会无上限增长。
31+4. 自动化验证通过:
32+ - `node --test /Users/george/code/baa-conductor-bug-027-startup-plugin-diagnostic-buffer/plugins/baa-firefox/controller.test.cjs /Users/george/code/baa-conductor-bug-027-startup-plugin-diagnostic-buffer/plugins/baa-firefox/final-message.test.cjs /Users/george/code/baa-conductor-bug-027-startup-plugin-diagnostic-buffer/plugins/baa-firefox/page-interceptor.test.cjs`
33+ - `node --check /Users/george/code/baa-conductor-bug-027-startup-plugin-diagnostic-buffer/plugins/baa-firefox/controller.js`
34+ - `node --check /Users/george/code/baa-conductor-bug-027-startup-plugin-diagnostic-buffer/plugins/baa-firefox/content-script.js`
35+ - `node --check /Users/george/code/baa-conductor-bug-027-startup-plugin-diagnostic-buffer/plugins/baa-firefox/page-interceptor.js`
36+ - `pnpm -C /Users/george/code/baa-conductor-bug-027-startup-plugin-diagnostic-buffer/apps/conductor-daemon test -- --test-name-pattern "plugin diagnostic logs received over the Firefox bridge"`
+75,
-5
1@@ -36,6 +36,7 @@ const CREDENTIAL_SEND_INTERVAL = 30_000;
2 const CREDENTIAL_TTL = 15 * 60_000;
3 const NETWORK_BODY_LIMIT = 5000;
4 const LOG_LIMIT = 500;
5+const PLUGIN_DIAGNOSTIC_BUFFER_LIMIT = 50;
6 const PROXY_MESSAGE_RETRY = 10;
7 const PROXY_MESSAGE_RETRY_DELAY = 400;
8 const CONTROL_REFRESH_INTERVAL = 15_000;
9@@ -223,7 +224,8 @@ const state = {
10 claudeState: createDefaultClaudeState(),
11 controllerRuntime: createDefaultControllerRuntimeState(),
12 finalMessageRelayObservers: createPlatformMap((platform) => createFinalMessageRelayObserver(platform)),
13- logs: []
14+ logs: [],
15+ pendingPluginDiagnosticLogs: []
16 };
17
18 const ui = {};
19@@ -2255,18 +2257,80 @@ function shouldForwardDiagnosticLog(level, text) {
20 }
21 }
22
23-function sendPluginDiagnosticLog(level, text) {
24+function createPluginDiagnosticPayload(level, text) {
25 if (!shouldForwardDiagnosticLog(level, text)) {
26- return false;
27+ return null;
28 }
29
30- return wsSend({
31+ return {
32 type: "plugin_diagnostic_log",
33 ts: new Date().toISOString(),
34 level,
35 text,
36 client_id: trimToNull(state.clientId)
37- });
38+ };
39+}
40+
41+function trySendPluginDiagnosticPayload(payload) {
42+ try {
43+ return wsSend(payload);
44+ } catch (_) {
45+ return false;
46+ }
47+}
48+
49+function bufferPluginDiagnosticPayload(payload) {
50+ if (!payload || typeof payload !== "object") {
51+ return 0;
52+ }
53+
54+ state.pendingPluginDiagnosticLogs.push(payload);
55+ if (state.pendingPluginDiagnosticLogs.length > PLUGIN_DIAGNOSTIC_BUFFER_LIMIT) {
56+ state.pendingPluginDiagnosticLogs.splice(
57+ 0,
58+ state.pendingPluginDiagnosticLogs.length - PLUGIN_DIAGNOSTIC_BUFFER_LIMIT
59+ );
60+ }
61+
62+ return state.pendingPluginDiagnosticLogs.length;
63+}
64+
65+function flushBufferedPluginDiagnosticLogs() {
66+ if (state.pendingPluginDiagnosticLogs.length === 0) {
67+ return 0;
68+ }
69+
70+ if (!state.ws || state.ws.readyState !== WebSocket.OPEN) {
71+ return 0;
72+ }
73+
74+ const pending = state.pendingPluginDiagnosticLogs.slice();
75+ state.pendingPluginDiagnosticLogs = [];
76+ let flushedCount = 0;
77+
78+ for (const payload of pending) {
79+ if (!trySendPluginDiagnosticPayload(payload)) {
80+ state.pendingPluginDiagnosticLogs = pending.slice(flushedCount).concat(state.pendingPluginDiagnosticLogs);
81+ break;
82+ }
83+ flushedCount += 1;
84+ }
85+
86+ return flushedCount;
87+}
88+
89+function sendPluginDiagnosticLog(level, text) {
90+ const payload = createPluginDiagnosticPayload(level, text);
91+ if (!payload) {
92+ return false;
93+ }
94+
95+ if (trySendPluginDiagnosticPayload(payload)) {
96+ return true;
97+ }
98+
99+ bufferPluginDiagnosticPayload(payload);
100+ return true;
101 }
102
103 function normalizePath(url) {
104@@ -4839,6 +4903,7 @@ function connectWs(options = {}) {
105 lastError: null
106 });
107 sendHello();
108+ flushBufferedPluginDiagnosticLogs();
109 addLog("info", "本地 WS 已连接", false);
110 sendCredentialSnapshot(null, true);
111 sendEndpointSnapshot();
112@@ -7510,8 +7575,12 @@ function exposeControllerTestApi() {
113
114 Object.assign(target, {
115 buildPageControlSnapshotForSender,
116+ connectWs,
117+ createPluginDiagnosticPayload,
118+ flushBufferedPluginDiagnosticLogs,
119 getSenderContext,
120 handlePageBridgeReady,
121+ handlePageDiagnosticLog,
122 handlePageNetwork,
123 handlePageSse,
124 persistFinalMessageRelayCache,
125@@ -7522,6 +7591,7 @@ function exposeControllerTestApi() {
126 runProxyDeliveryAction,
127 runPageControlAction,
128 runPluginManagementAction,
129+ sendPluginDiagnosticLog,
130 serializeFinalMessageRelayCache,
131 setDesiredTabState,
132 syncPageControlFromContext,
+231,
-0
1@@ -0,0 +1,231 @@
2+const assert = require("node:assert/strict");
3+const fs = require("node:fs");
4+const path = require("node:path");
5+const test = require("node:test");
6+const vm = require("node:vm");
7+
8+const CONTROLLER_SOURCE = fs.readFileSync(
9+ path.join(__dirname, "controller.js"),
10+ "utf8"
11+);
12+
13+class FakeWebSocket {
14+ static CONNECTING = 0;
15+ static OPEN = 1;
16+ static CLOSING = 2;
17+ static CLOSED = 3;
18+ static instances = [];
19+
20+ constructor(url) {
21+ this.url = url;
22+ this.readyState = FakeWebSocket.CONNECTING;
23+ this.sent = [];
24+ this.onclose = null;
25+ this.onerror = null;
26+ this.onmessage = null;
27+ this.onopen = null;
28+ FakeWebSocket.instances.push(this);
29+ }
30+
31+ send(payload) {
32+ this.sent.push(JSON.parse(payload));
33+ }
34+
35+ close(code = 1000, reason = "") {
36+ this.readyState = FakeWebSocket.CLOSED;
37+ if (typeof this.onclose === "function") {
38+ this.onclose({ code, reason });
39+ }
40+ }
41+}
42+
43+function createControllerHarness() {
44+ FakeWebSocket.instances = [];
45+
46+ const windowListeners = new Map();
47+ const window = {
48+ addEventListener(type, listener) {
49+ if (!windowListeners.has(type)) {
50+ windowListeners.set(type, new Set());
51+ }
52+ windowListeners.get(type).add(listener);
53+ },
54+ dispatchEvent() {
55+ return true;
56+ },
57+ location: {
58+ hash: "",
59+ href: "moz-extension://baa-firefox/controller.html",
60+ hostname: "baa-firefox",
61+ origin: "moz-extension://baa-firefox",
62+ pathname: "/controller.html"
63+ },
64+ removeEventListener(type, listener) {
65+ windowListeners.get(type)?.delete(listener);
66+ }
67+ };
68+ window.location.reload = () => {};
69+
70+ const context = vm.createContext({
71+ AbortController,
72+ ArrayBuffer,
73+ Blob,
74+ FormData,
75+ Headers,
76+ Request,
77+ Response,
78+ URL,
79+ URLSearchParams,
80+ WebSocket: FakeWebSocket,
81+ browser: {
82+ runtime: {
83+ onMessage: {
84+ addListener() {},
85+ removeListener() {}
86+ },
87+ sendMessage() {
88+ return Promise.resolve();
89+ }
90+ },
91+ storage: {
92+ local: {
93+ get() {
94+ return Promise.resolve({});
95+ },
96+ remove() {
97+ return Promise.resolve();
98+ },
99+ set() {
100+ return Promise.resolve();
101+ }
102+ }
103+ },
104+ tabs: {
105+ create() {
106+ return Promise.resolve({ id: 1 });
107+ },
108+ getCurrent() {
109+ return Promise.resolve({ id: 1 });
110+ },
111+ query() {
112+ return Promise.resolve([]);
113+ },
114+ reload() {
115+ return Promise.resolve();
116+ },
117+ remove() {
118+ return Promise.resolve();
119+ },
120+ update() {
121+ return Promise.resolve();
122+ }
123+ }
124+ },
125+ clearTimeout,
126+ console: {
127+ error() {},
128+ log() {},
129+ warn() {}
130+ },
131+ document: {
132+ getElementById() {
133+ return null;
134+ }
135+ },
136+ fetch() {
137+ return Promise.resolve(new Response("{}", { status: 200 }));
138+ },
139+ location: window.location,
140+ setTimeout,
141+ window,
142+ __BAA_CONTROLLER_TEST_API__: {},
143+ __BAA_SKIP_CONTROLLER_INIT__: true
144+ });
145+
146+ vm.runInContext(CONTROLLER_SOURCE, context, {
147+ filename: "controller.js"
148+ });
149+
150+ return {
151+ api: context.__BAA_CONTROLLER_TEST_API__,
152+ context
153+ };
154+}
155+
156+test("controller flushes buffered plugin diagnostic logs after the WS opens", () => {
157+ const { api } = createControllerHarness();
158+
159+ api.state.clientId = "firefox-test";
160+ api.state.wsUrl = "ws://127.0.0.1:4317/ws/firefox";
161+
162+ api.handlePageDiagnosticLog({
163+ event: "page_bridge_ready",
164+ platform: "chatgpt",
165+ source: "content-script",
166+ url: "https://chatgpt.com/c/conv-1"
167+ }, {});
168+ api.handlePageDiagnosticLog({
169+ event: "interceptor_active",
170+ platform: "chatgpt",
171+ source: "page-interceptor",
172+ url: "https://chatgpt.com/c/conv-1"
173+ }, {});
174+
175+ assert.equal(api.state.pendingPluginDiagnosticLogs.length, 2);
176+
177+ api.connectWs({
178+ silentWhenDisabled: true
179+ });
180+
181+ const socket = FakeWebSocket.instances.at(-1);
182+ assert.ok(socket);
183+ assert.equal(api.state.pendingPluginDiagnosticLogs.length >= 3, true);
184+
185+ socket.readyState = FakeWebSocket.OPEN;
186+ socket.onopen();
187+
188+ const pluginLogTexts = socket.sent
189+ .filter((message) => message.type === "plugin_diagnostic_log")
190+ .map((message) => message.text);
191+ const pageBridgeIndex = pluginLogTexts.findIndex((text) => text.includes("page_bridge_ready"));
192+ const interceptorIndex = pluginLogTexts.findIndex((text) => text.includes("interceptor_active"));
193+ const connectedIndex = pluginLogTexts.findIndex((text) => text === "本地 WS 已连接");
194+
195+ assert.equal(socket.sent[0]?.type, "hello");
196+ assert.equal(pageBridgeIndex >= 0, true);
197+ assert.equal(interceptorIndex >= 0, true);
198+ assert.equal(connectedIndex >= 0, true);
199+ assert.equal(pageBridgeIndex < connectedIndex, true);
200+ assert.equal(interceptorIndex < connectedIndex, true);
201+ assert.equal(api.state.pendingPluginDiagnosticLogs.length, 0);
202+});
203+
204+test("controller bounds buffered plugin diagnostic logs and flushes the newest entries in order", () => {
205+ const { api } = createControllerHarness();
206+
207+ api.state.clientId = "firefox-buffer";
208+
209+ for (let index = 1; index <= 60; index += 1) {
210+ api.sendPluginDiagnosticLog("info", `buffered-log-${index}`);
211+ }
212+
213+ assert.equal(api.state.pendingPluginDiagnosticLogs.length, 50);
214+ assert.equal(api.state.pendingPluginDiagnosticLogs[0]?.text, "buffered-log-11");
215+ assert.equal(api.state.pendingPluginDiagnosticLogs[49]?.text, "buffered-log-60");
216+
217+ const sent = [];
218+ api.state.ws = {
219+ readyState: FakeWebSocket.OPEN,
220+ send(payload) {
221+ sent.push(JSON.parse(payload));
222+ }
223+ };
224+
225+ const flushedCount = api.flushBufferedPluginDiagnosticLogs();
226+
227+ assert.equal(flushedCount, 50);
228+ assert.equal(sent.length, 50);
229+ assert.equal(sent[0]?.text, "buffered-log-11");
230+ assert.equal(sent[49]?.text, "buffered-log-60");
231+ assert.equal(api.state.pendingPluginDiagnosticLogs.length, 0);
232+});