baa-conductor

git clone 

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
A bugs/archive/FIX-BUG-027.md
+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"`
M plugins/baa-firefox/controller.js
+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,
A plugins/baa-firefox/controller.test.cjs
+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+});