fix(browser): land PR #21277 dedupe concurrent relay init

Add shared per-port relay initialization dedupe so concurrent callers await a single startup lifecycle, with regression coverage and changelog entry.
Landed from contributor @HOYALIM (PR #21277).

Co-authored-by: Ho Lim <subhoya@gmail.com>
This commit is contained in:
Peter Steinberger
2026-02-26 14:30:46 +00:00
parent 65d5a91242
commit 5416cabdf8
3 changed files with 626 additions and 593 deletions

View File

@@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai
- Browser/Extension relay auth: allow `?token=` query-param auth on relay `/json*` endpoints (consistent with relay WebSocket auth) so curl/devtools-style `/json/version` and `/json/list` probes work without requiring custom headers. Landed from contributor PR #26015 by @Sid-Qin. (#25928) - Browser/Extension relay auth: allow `?token=` query-param auth on relay `/json*` endpoints (consistent with relay WebSocket auth) so curl/devtools-style `/json/version` and `/json/list` probes work without requiring custom headers. Landed from contributor PR #26015 by @Sid-Qin. (#25928)
- Browser/Extension relay shutdown: flush pending extension-request timers/rejections during relay `stop()` before socket/server teardown so in-flight extension waits do not survive shutdown windows. Landed from contributor PR #24142 by @kevinWangSheng. - Browser/Extension relay shutdown: flush pending extension-request timers/rejections during relay `stop()` before socket/server teardown so in-flight extension waits do not survive shutdown windows. Landed from contributor PR #24142 by @kevinWangSheng.
- Browser/Chrome extension handshake: bind relay WS message handling before `onopen` and add non-blocking `connect.challenge` response handling for gateway-style handshake frames, avoiding stuck `…` badge states when challenge frames arrive immediately on connect. Landed from contributor PR #22571 by @pandego. (#22553) - Browser/Chrome extension handshake: bind relay WS message handling before `onopen` and add non-blocking `connect.challenge` response handling for gateway-style handshake frames, avoiding stuck `…` badge states when challenge frames arrive immediately on connect. Landed from contributor PR #22571 by @pandego. (#22553)
- Browser/Extension relay init: dedupe concurrent same-port relay startup with shared in-flight initialization promises so callers await one startup lifecycle and receive consistent success/failure results. Landed from contributor PR #21277 by @HOYALIM. (Related #20688)
- Auth/Auth profiles: normalize `auth-profiles.json` alias fields (`mode -> type`, `apiKey -> key`) before credential validation so entries copied from `openclaw.json` auth examples are no longer silently dropped. (#26950) thanks @byungsker. - Auth/Auth profiles: normalize `auth-profiles.json` alias fields (`mode -> type`, `apiKey -> key`) before credential validation so entries copied from `openclaw.json` auth examples are no longer silently dropped. (#26950) thanks @byungsker.
- Cron/Hooks isolated routing: preserve canonical `agent:*` session keys in isolated runs so already-qualified keys are not double-prefixed (for example `agent:main:main` no longer becomes `agent:main:agent:main:main`). Landed from contributor PR #27333 by @MaheshBhushan. (#27289, #27282) - Cron/Hooks isolated routing: preserve canonical `agent:*` session keys in isolated runs so already-qualified keys are not double-prefixed (for example `agent:main:main` no longer becomes `agent:main:agent:main:main`). Landed from contributor PR #27333 by @MaheshBhushan. (#27289, #27282)
- iOS/Talk mode: stop injecting the voice directive hint into iOS Talk prompts and remove the Voice Directive Hint setting, reducing model bias toward tool-style TTS directives and keeping relay responses text-first by default. (#27543) thanks @ngutman. - iOS/Talk mode: stop injecting the voice directive hint into iOS Talk prompts and remove the Voice Directive Hint setting, reducing model bias toward tool-style TTS directives and keeping relay responses text-first by default. (#27543) thanks @ngutman.

View File

@@ -208,6 +208,17 @@ describe("chrome extension relay server", () => {
expect(err.message).toContain("401"); expect(err.message).toContain("401");
}); });
it("deduplicates concurrent relay starts for the same requested port", async () => {
const port = await getFreePort();
cdpUrl = `http://127.0.0.1:${port}`;
const [first, second] = await Promise.all([
ensureChromeExtensionRelayServer({ cdpUrl }),
ensureChromeExtensionRelayServer({ cdpUrl }),
]);
expect(first).toBe(second);
expect(first.port).toBe(port);
});
it("allows CORS preflight from chrome-extension origins", async () => { it("allows CORS preflight from chrome-extension origins", async () => {
const port = await getFreePort(); const port = await getFreePort();
cdpUrl = `http://127.0.0.1:${port}`; cdpUrl = `http://127.0.0.1:${port}`;

View File

@@ -172,6 +172,7 @@ function rejectUpgrade(socket: Duplex, status: number, bodyText: string) {
} }
const relayRuntimeByPort = new Map<number, RelayRuntime>(); const relayRuntimeByPort = new Map<number, RelayRuntime>();
const relayInitByPort = new Map<number, Promise<ChromeExtensionRelayServer>>();
function isAddrInUseError(err: unknown): boolean { function isAddrInUseError(err: unknown): boolean {
return ( return (
@@ -219,6 +220,12 @@ export async function ensureChromeExtensionRelayServer(opts: {
return existing.server; return existing.server;
} }
const inFlight = relayInitByPort.get(info.port);
if (inFlight) {
return await inFlight;
}
const initPromise = (async (): Promise<ChromeExtensionRelayServer> => {
const relayAuthToken = resolveRelayAuthTokenForPort(info.port); const relayAuthToken = resolveRelayAuthTokenForPort(info.port);
const relayAuthTokens = new Set(resolveRelayAcceptedTokensForPort(info.port)); const relayAuthTokens = new Set(resolveRelayAcceptedTokensForPort(info.port));
@@ -491,7 +498,9 @@ export async function ensureChromeExtensionRelayServer(opts: {
return true; return true;
}; };
if (handleTargetActionRoute(path.match(/^\/json\/activate\/(.+)$/), "Target.activateTarget")) { if (
handleTargetActionRoute(path.match(/^\/json\/activate\/(.+)$/), "Target.activateTarget")
) {
return; return;
} }
if (handleTargetActionRoute(path.match(/^\/json\/close\/(.+)$/), "Target.closeTarget")) { if (handleTargetActionRoute(path.match(/^\/json\/close\/(.+)$/), "Target.closeTarget")) {
@@ -586,7 +595,12 @@ export async function ensureChromeExtensionRelayServer(opts: {
return; return;
} }
if (parsed && typeof parsed === "object" && "id" in parsed && typeof parsed.id === "number") { if (
parsed &&
typeof parsed === "object" &&
"id" in parsed &&
typeof parsed.id === "number"
) {
const pending = pendingExtension.get(parsed.id); const pending = pendingExtension.get(parsed.id);
if (!pending) { if (!pending) {
return; return;
@@ -847,6 +861,13 @@ export async function ensureChromeExtensionRelayServer(opts: {
relayRuntimeByPort.set(port, { server: relay, relayAuthToken }); relayRuntimeByPort.set(port, { server: relay, relayAuthToken });
return relay; return relay;
})();
relayInitByPort.set(info.port, initPromise);
try {
return await initPromise;
} finally {
relayInitByPort.delete(info.port);
}
} }
export async function stopChromeExtensionRelayServer(opts: { cdpUrl: string }): Promise<boolean> { export async function stopChromeExtensionRelayServer(opts: { cdpUrl: string }): Promise<boolean> {