Skip to content

fix(browser): wait for extension tabs after relay drop (#32331)#32461

Merged
steipete merged 2 commits intoopenclaw:mainfrom
AaronWander:fix/32331-extension-relay-tab-wait
Mar 8, 2026
Merged

fix(browser): wait for extension tabs after relay drop (#32331)#32461
steipete merged 2 commits intoopenclaw:mainfrom
AaronWander:fix/32331-extension-relay-tab-wait

Conversation

@AaronWander
Copy link
Contributor

Summary

  • Problem: Chrome extension relay profiles can briefly return an empty /json/list during transient relay/extension reconnects, causing the browser tool to immediately fail with “no attached Chrome tabs” and repeatedly prompt the user to re-attach.
  • Why it matters: This adds frequent manual intervention and breaks otherwise recoverable browser tool flows.
  • What changed: When using an extension-driven profile and a previous target exists (lastTargetId), tab selection waits briefly (up to 3s) for tabs to reappear before failing; added a regression test covering the transient-empty case.
  • What did NOT change (scope boundary): No new relay protocol/extension changes; no new config knobs; initial “never attached” cases still fail fast with the same guidance.

Change Type (select all)

  • Bug fix
  • Feature
  • Refactor
  • Docs
  • Security hardening
  • Chore/infra

Scope (select all touched areas)

  • Gateway / orchestration
  • Skills / tool execution
  • Auth / tokens
  • Memory / storage
  • Integrations
  • API / contracts
  • UI / DX
  • CI/CD / infra

Linked Issue/PR

User-visible / Behavior Changes

  • Browser tool calls using the Chrome extension relay profile are less likely to get stuck prompting the user to re-attach after a transient relay/extension reconnect.

Security Impact (required)

  • New permissions/capabilities? (No)
  • Secrets/tokens handling changed? (No)
  • New/changed network calls? (No)
  • Command/tool execution surface changed? (No)
  • Data access scope changed? (No)

Repro + Verification

Environment

  • OS: macOS
  • Runtime/container: Node 22
  • Model/provider: N/A
  • Integration/channel (if any): N/A
  • Relevant config (redacted): N/A

Steps

  1. Run pnpm test src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts.
  2. Run pnpm check.

Expected

  • Tests pass; extension tab selection tolerates a brief empty /json/list after a prior selection.

Actual

  • Previously: selection failed immediately and prompted manual re-attach.

Evidence

  • Passing after: targeted unit test + pnpm check

Human Verification (required)

  • Verified scenarios: targeted unit test; pnpm check
  • Edge cases checked: transient empty list after prior selection
  • What you did not verify: full end-to-end repro with a real Chrome extension drop/reconnect

Compatibility / Migration

  • Backward compatible? (Yes)
  • Config/env changes? (No)
  • Migration needed? (No)

Failure Recovery (if this breaks)

  • Revert this commit.

Risks and Mitigations

None.

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 3, 2026

Greptile Summary

This PR adds a short polling retry (up to 3 s, 200 ms cadence) inside ensureTabAvailable for Chrome extension relay profiles when /json/list returns an empty array but a prior lastTargetId is known — covering the common MV3 service-worker / relay-restart transient-reconnect scenario. The structural refactor that moves openTab("about:blank") into an explicit else branch is semantically equivalent to the original. A regression test using vi.useFakeTimers is added to verify the happy path.

Key points:

  • The retry logic is correctly guarded by profileState.lastTargetId?.trim(), so "never attached" callers still fail fast with the same user-facing message — no regression there.
  • The while loop evaluates the deadline at the top of each iteration, meaning the loop can overrun the 3 s deadline by up to one polling interval (~200 ms + listTabs latency) on the last iteration. This is a minor timing precision issue.
  • The deadline-exceeded / never-recovers path (tabs stay empty for the full 3 s) is not covered by an automated test; adding a fake-timer test for that case would close the coverage gap.

Confidence Score: 4/5

  • This PR is safe to merge; the change is narrow, backward-compatible, and the new behavior is well-reasoned with a regression test.
  • The fix is minimal and correctly scoped — only extension profiles with a known prior target are affected. The structural refactor of the else branch is semantically equivalent. Two style-level concerns prevent a full 5: the while loop can slightly overrun the 3 s deadline (minor), and the deadline-exceeded error path lacks an automated test.
  • No files require special attention; both changed files are straightforward.

Last reviewed commit: 4fc6f98

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

2 files reviewed, 2 comments

Edit Code Review Agent Settings | Greptile

Comment on lines +41 to +44
while (tabs1.length === 0 && Date.now() < deadlineAt) {
await new Promise((resolve) => setTimeout(resolve, 200));
tabs1 = await listTabs();
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Deadline check precedes sleep — loop can overrun by one interval

The while condition evaluates Date.now() < deadlineAt before the 200 ms setTimeout and the subsequent listTabs() call. This means the last iteration can start with only 1 ms remaining on the deadline, sleep 200 ms, call listTabs(), and resolve up to ~200 ms + network time past the 3 s deadline before the throw is reached. In most cases the overrun is harmless, but it slightly weakens the "fail within 3 s" guarantee.

A common fix is to also check the deadline after waking, or to measure elapsed time with an AbortSignal/clearTimeout pattern. As a minimal fix, moving the deadline check to after the sleep would ensure the timeout is observed more faithfully:

Suggested change
while (tabs1.length === 0 && Date.now() < deadlineAt) {
await new Promise((resolve) => setTimeout(resolve, 200));
tabs1 = await listTabs();
}
while (tabs1.length === 0) {
await new Promise((resolve) => setTimeout(resolve, 200));
if (Date.now() >= deadlineAt) break;
tabs1 = await listTabs();
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/browser/server-context.selection.ts
Line: 41-44

Comment:
**Deadline check precedes sleep — loop can overrun by one interval**

The `while` condition evaluates `Date.now() < deadlineAt` *before* the 200 ms `setTimeout` and the subsequent `listTabs()` call. This means the last iteration can start with only 1 ms remaining on the deadline, sleep 200 ms, call `listTabs()`, and resolve up to ~200 ms + network time *past* the 3 s deadline before the throw is reached. In most cases the overrun is harmless, but it slightly weakens the "fail within 3 s" guarantee.

A common fix is to also check the deadline after waking, or to measure elapsed time with an `AbortSignal`/`clearTimeout` pattern. As a minimal fix, moving the deadline check to after the sleep would ensure the timeout is observed more faithfully:

```suggestion
          while (tabs1.length === 0) {
            await new Promise((resolve) => setTimeout(resolve, 200));
            if (Date.now() >= deadlineAt) break;
            tabs1 = await listTabs();
          }
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +126 to +153
it("waits briefly for extension tabs to reappear when a previous target exists", async () => {
vi.useFakeTimers();
try {
const responses = [
// First call: select tab A and store lastTargetId.
[{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }],
[{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }],
// Second call: transient drop, then the extension re-announces attached tab A.
[],
[{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }],
[{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }],
];
stubChromeJsonList(responses);
const state = makeBrowserState();

const ctx = createBrowserRouteContext({ getState: () => state });
const chrome = ctx.forProfile("chrome");
const first = await chrome.ensureTabAvailable();
expect(first.targetId).toBe("A");

const secondPromise = chrome.ensureTabAvailable();
await vi.advanceTimersByTimeAsync(250);
const second = await secondPromise;
expect(second.targetId).toBe("A");
} finally {
vi.useRealTimers();
}
});
Copy link
Contributor

Choose a reason for hiding this comment

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

Deadline-exceeded path is not tested

The new test only covers the happy path (tabs reappear within one polling interval). The complementary case — lastTargetId is set, /json/list stays empty throughout the 3 s window, and ensureTabAvailable must still throw with the "no attached Chrome tabs" message — has no automated test.

This is the code path the PR description says "was not verified end-to-end," and it's also the path that ensures the retry doesn't silently swallow the error on a genuinely broken relay. A test like the one below would complete near-instantly with fake timers:

it("throws after deadline when extension tabs never reappear (lastTargetId set)", async () => {
  vi.useFakeTimers();
  try {
    // Provide enough empty responses to cover the retry window plus one first call
    const responses = [
      [{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }],
      [{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }],
      // All subsequent polls return empty
      ...Array(20).fill([]),
    ];
    stubChromeJsonList(responses);
    const state = makeBrowserState();
    const ctx = createBrowserRouteContext({ getState: () => state });
    const chrome = ctx.forProfile("chrome");
    await chrome.ensureTabAvailable(); // sets lastTargetId = "A"

    const failPromise = chrome.ensureTabAvailable();
    await vi.advanceTimersByTimeAsync(4_000); // past the 3 s deadline
    await expect(failPromise).rejects.toThrow(/no attached Chrome tabs/i);
  } finally {
    vi.useRealTimers();
  }
});
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts
Line: 126-153

Comment:
**Deadline-exceeded path is not tested**

The new test only covers the happy path (tabs reappear within one polling interval). The complementary case — `lastTargetId` is set, `/json/list` stays empty throughout the 3 s window, and `ensureTabAvailable` must still throw with the "no attached Chrome tabs" message — has no automated test.

This is the code path the PR description says "was not verified end-to-end," and it's also the path that ensures the retry doesn't silently swallow the error on a genuinely broken relay. A test like the one below would complete near-instantly with fake timers:

```ts
it("throws after deadline when extension tabs never reappear (lastTargetId set)", async () => {
  vi.useFakeTimers();
  try {
    // Provide enough empty responses to cover the retry window plus one first call
    const responses = [
      [{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }],
      [{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }],
      // All subsequent polls return empty
      ...Array(20).fill([]),
    ];
    stubChromeJsonList(responses);
    const state = makeBrowserState();
    const ctx = createBrowserRouteContext({ getState: () => state });
    const chrome = ctx.forProfile("chrome");
    await chrome.ensureTabAvailable(); // sets lastTargetId = "A"

    const failPromise = chrome.ensureTabAvailable();
    await vi.advanceTimersByTimeAsync(4_000); // past the 3 s deadline
    await expect(failPromise).rejects.toThrow(/no attached Chrome tabs/i);
  } finally {
    vi.useRealTimers();
  }
});
```

How can I resolve this? If you propose a fix, please make it concise.

@steipete steipete force-pushed the fix/32331-extension-relay-tab-wait branch from 4fc6f98 to 79c96b0 Compare March 8, 2026 19:11
@steipete steipete merged commit 0692f71 into openclaw:main Mar 8, 2026
8 of 9 checks passed
@steipete
Copy link
Contributor

steipete commented Mar 8, 2026

Landed via temp rebase onto main.

  • Gate: pnpm check, pnpm build, pnpm test
  • Land commit: 79c96b0
  • Merge commit: 0692f71

Thanks @AaronWander!

GordonSH-oss pushed a commit to GordonSH-oss/openclaw that referenced this pull request Mar 9, 2026
jenawant pushed a commit to jenawant/openclaw that referenced this pull request Mar 10, 2026
sauerdaniel pushed a commit to sauerdaniel/openclaw that referenced this pull request Mar 11, 2026
Moshiii pushed a commit to Moshiii/openclaw that referenced this pull request Mar 11, 2026
Moshiii pushed a commit to Moshiii/openclaw that referenced this pull request Mar 11, 2026
dhoman pushed a commit to dhoman/chrono-claw that referenced this pull request Mar 11, 2026
alexey-pelykh pushed a commit to remoteclaw/remoteclaw that referenced this pull request Mar 14, 2026
alexey-pelykh added a commit to remoteclaw/remoteclaw that referenced this pull request Mar 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Chrome Extension Relay frequently disconnects or disables itself, requiring manual user intervention to restore functionality.

2 participants