can1357/oh-my-pi · error · ToolError

Tab ${JSON.stringify(name)} is busy

Error message

Tab ${JSON.stringify(name)} is busy

What it means

Each tab serializes its work: runInTabWithSnapshot throws ToolError if the tab's pending set is non-empty, meaning another run is already in flight on that tab. This prevents interleaved scripts from racing on the same page.

Source

Thrown at packages/coding-agent/src/tools/browser/tab-supervisor.ts:520

		},
	);
}

async function runInTabWithSnapshot(
	name: string,
	opts: { code: string; timeoutMs: number; signal?: AbortSignal; session?: ToolSession },
	snapshot: SessionSnapshot,
): Promise<RunResultOk> {
	const tab = tabs.get(name);
	if (!tab || tab.state === "dead") {
		const killed = killedTabs.get(name);
		throw new ToolError(
			killed
				? `Tab ${JSON.stringify(name)} was killed: ${killed}. Reopen it.`
				: `Tab ${JSON.stringify(name)} is not alive. Open it first with action:"open".`,
		);
	}
	if (tab.pending.size > 0) throw new ToolError(`Tab ${JSON.stringify(name)} is busy`);
	const id = Snowflake.next();
	const { promise, resolve, reject } = Promise.withResolvers<RunResultOk>();
	// `releaseTab` calls `pending.reject(closeError)` when the tab dies
	// out from under an in-flight run (sibling `browser close --all`,
	// session-scoped reap, etc.). Both backends below MUST end up awaiting
	// this same `promise` so:
	//   1. The caller sees `Tab ... was closed` immediately instead of
	//      blocking to the run's timeout, and
	//   2. `reject(...)` always has an attached handler — a zero-consumer
	//      rejection would fire `unhandledRejection` and the CLI's
	//      top-level handler would tear the whole session down, killing
	//      every other tab and subagent sharing the process (issue #4499).
	// The cmux branch also composes `closeAc.signal` into the run's abort
	// signal so `wait(...)`, cmux socket calls, and the facade proxies
	// unwind promptly when the tab is closed — otherwise a `wait(60_000)`
	// with no in-flight socket request would keep `runCmuxCode` blocked
	// until timeout even after the tab is gone.
	const closeAc = new AbortController();

View on GitHub (pinned to 9690622007)

Solutions

  1. Await the in-flight run before issuing the next one — serialize calls per tab.
  2. Open additional tabs and spread concurrent work across distinct tab names.
  3. Abort/kill the stuck run (or the tab) if the previous operation is hung, then retry.
  4. Reduce per-call timeoutMs so hung scripts fail fast and free the tab.

Example fix

// before
await Promise.all([
  browserTool({ action: "eval", name: "docs", code: a }),
  browserTool({ action: "eval", name: "docs", code: b }), // busy
]);
// after
await browserTool({ action: "eval", name: "docs", code: a });
await browserTool({ action: "eval", name: "docs", code: b });
Defensive patterns

Strategy: validation

Validate before calling

const tab = getTab(name);
if (tab && tab.pending.size > 0) {
  await waitForTabIdle(name); // or use a different tab
}

Type guard

function isTabIdle(t: ReturnType<typeof getTab>): boolean {
  return !!t && t.state !== "dead" && t.pending.size === 0;
}

Try / catch

try {
  await runInTab(name, code);
} catch (e) {
  if (e instanceof ToolError && e.message.endsWith("is busy")) {
    await Bun.sleep(250);
    return runInTab(name, code); // bounded retry after prior run drains
  }
  throw e;
}

Prevention

When it happens

Trigger: Issuing a second eval/navigate on the same tab name while a previous run (long script, slow navigation, hung wait) has not finished; concurrent tool calls targeting one tab.

Common situations: An agent issuing parallel tool calls that all target the same tab; a previous script stuck on a never-resolving waitFor; retry logic firing while the original run is still pending.

Related errors


AI-assisted analysis of can1357/oh-my-pi@9690622007 (2026-08-31). Data as JSON: /api/errors/5178e0ad5e9468ce. Report an issue: GitHub.