Skip to content

fix(browser): a stopped turn is not a broken browser - #140

Closed
aniruddhaadak80 wants to merge 1 commit into
CopilotKit:mainfrom
aniruddhaadak80:fix/stopping-a-turn-is-not-a-broken-browser
Closed

aniruddhaadak80 wants to merge 1 commit into
CopilotKit:mainfrom
aniruddhaadak80:fix/stopping-a-turn-is-not-a-broken-browser

Conversation

@aniruddhaadak80

Copy link
Copy Markdown

What this changes

BrowserService.openOwned records the session as broken on any failure:

} catch (error) {
  await this.save(
    owner,
    { ...value, url: target, status: "error", updatedAt: new Date().toISOString() },
    id,
  );
  throw error;
}

One of those failures is the person asking for the turn to end. request reports a cancelled request by throwing the signal's own abort (browser.ts:67 and :84), so a Stop that lands while the worker is still opening the session wrote status: "error" to a browser whose profile, cookies and storage were all fine.

That status is not cosmetic. In apps/mobile/src/computer.tsx it is what:

  • shows "Needs attention" in place of the page title (:78-79)
  • hides the preview behind a "Open the browser to reconnect" fallback (:84, :104)
  • offers "Reconnect browser" instead of "Take control" (:111-112)

So stopping a turn left the session demanding a reconnect nobody needed — and because the status is persisted, it survived a restart.

The other half of the rule

Only the caller's own abort is treated as a Stop:

if (!signal?.aborted) {
  await this.save(owner, { ...value, url: target, status: "error", ... }, id);
}

request composes AbortSignal.any([signal, AbortSignal.timeout(45000)]), so a request can also fail on the 45-second timeout. That is a real failure and it leaves the caller's signal untouched, so it still records the error — which is what the second new test pins down. Without that test this change could quietly become "never record an error", which would hide a browser that really is broken.

Test fixture change

browserFixture's handler is now awaited, so a test can model a worker that has not answered yet — which is the state a Stop actually arrives into. This is additive: existing handlers are synchronous, and awaiting a non-promise changes nothing for them. It was necessary because the pre-flight signal?.throwIfAborted() at the top of observeForThread (browser.ts:182) and inside serial (:202) means an already-aborted signal never reaches openOwned at all, so the only way to reach the cancellation path is to abort while the request is genuinely in flight.

Verification

  • pnpm test — baseline on main: 340 tests, 338 pass, 0 fail, 2 skipped. After: 342 tests, 340 pass, 0 fail, 2 skipped. The two new ones in tests/browser.test.ts:
    • stopping a turn does not leave the browser marked as needing attention — the regression. It polls until the worker has actually received the request, aborts, and asserts the stored status is still "idle". I confirmed red first: against main it failed with expected: 'idle' / actual: 'error', repeatedly (3/3 runs), and passes here (2/2 runs). The existing cancellation test at tests/browser.test.ts:245 does not catch this — it aborts inside the fetch handler after the response was already built, so openOwned succeeds and the catch never runs, and it asserts a row count rather than a status.
    • a worker that genuinely fails a turn still marks the browser as needing attention — a 500 from the worker still records "error".
  • pnpm typecheck — exit 0.
  • pnpm exec biome check on the three changed files — clean.

Two honest notes:

  • pnpm lint over the whole repo fails here on main too (143 CRLF errors from core.autocrlf=true on this Windows checkout). The three files I touched pass individually.
  • While iterating I saw worker protects all controls, validates before launch, and health reveals no sessions fail once and then pass on three subsequent runs, including on unmodified main. I could not tie it to this change and believe it is pre-existing flakiness in that test (it starts a real createWorkerServer and binds a port), but I am flagging it rather than assuming it away.

Relationship to open PRs

None of them touch openOwned's catch.

openOwned records the session as broken on any failure:

  } catch (error) {
    await this.save(owner, { ...value, url: target, status: "error", ... }, id);
    throw error;
  }

One of those failures is the person asking for the turn to end. request
reports a cancelled request by throwing the signal's own abort, so a Stop
that lands while the worker is still opening the session wrote "error" to a
browser whose profile, cookies and storage are all fine.

That status is not cosmetic. The app reads it as "Needs attention", and it is
what hides the preview behind a fallback and offers "Reconnect browser"
instead of "Take control" - so stopping a turn left the session demanding a
reconnect the person did not need, and the error survived a restart because
it is persisted.

Only the caller's own abort is treated as a Stop. The 45s request timeout is
a real failure and leaves the signal untouched, so it still records, and the
new test for that side is what keeps this from becoming "never record an
error".

browserFixture's handler is now awaited so a test can model a worker that has
not answered yet, which is the state a Stop actually arrives into.
Copilot AI balanced review requested due to automatic review settings October 4, 2026 11:40

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@aniruddhaadak80

Copy link
Copy Markdown
Author

Closing this: #31 already fixes this exact defect, and does it with less code than mine.

#31 adds signal?.throwIfAborted() as the first statement in openOwned's catch, before the status: "error" save:

} catch (error) {
+      signal?.throwIfAborted();
       await this.save(
         owner,
         { ...value, url: target, status: "error", updatedAt: new Date().toISOString() },
         id,
       );

so a caller's Stop re-throws before anything is persisted. That is the same outcome as the if (!signal?.aborted) guard I wrote, in one line rather than a conditional, and it sits in the shared catch so it covers the chat path (observeForThread → openOwned) that I found this through, not only the delegated-task path #31 targets. #31 also already carries the matching regression — "requires the saved browser session to remain non-error" — so there is nothing left for a second PR to add on the production side.

I only noticed the overlap at the end, when I stopped trusting my earlier recon and listed the files of every open PR rather than the 60 most recent. That is on me: my first pass read as "no collisions" because the list I checked was truncated.

Two things from my branch that #31 does not obviously have, offered rather than pushed:

  • browserFixture's handler is awaitable, so a test can model a worker that has not answered yet — which is the state a Stop actually arrives into. Without it, aborting mid-flight is not deterministic and the regression tends to pass vacuously.
  • A test asserting a genuine worker failure still records "error". That is the guard against this fix quietly becoming "never record an error", which would hide a browser that really is broken.

Neither is worth a competing PR on its own, but say the word if either is useful and I will open it as a focused follow-up.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants