ENG-97 — Per-session isolated browser for the shared gstack browse daemon¶
Status: Design (no daemon changes shipped in this PR)
Issue: ENG-97 · Interim mitigation: PR #14063 (scripts/dev/browse-lock.ps1)
Scope of this doc: explain how the daemon resolves its single browser today, why concurrent sessions collide, and choose an isolation approach. It deliberately does not modify the running daemon — the daemon is shared infra and a bad change strands every browse-test session on the dev VM.
All
src/*.tsreferences below are in the gstack install at~/.claude/skills/gstack/browse/(the compiled artifact that actually runs is~/.claude/skills/gstack/browse/dist/server-node.mjs, built from thatsrc/).
1. Problem¶
The dev VM runs one gstack browse daemon: a single server-node.mjs process controlling one headless Chromium with one active tab. It has no per-session isolation. When two agent/dev sessions drive it at the same time, their commands interleave on the same tab — navigations get hijacked, screenshots come back blank or show the other session's page, and steps fail with no active page.
Real impact: this blocked ~3 of 5 ENG-87 fundraising/event QA captures for hours on 2026-06-15 — a parallel mobile-portal QA session held the daemon continuously and ~1h40 of retries couldn't get a clean window. The interim fix (PR #14063) is an advisory auto-expiring lock that serializes access; it does not give true parallelism and only works if every session honours it.
This doc is about the durable fix: give each session its own browser so two sessions can drive simultaneously with zero contention.
2. How the daemon resolves its single Chromium / tab today¶
The flow has three layers: a CLI that finds-or-spawns the daemon, the daemon process that owns one Chromium, and a BrowserManager that tracks tabs behind a single "active tab" cursor.
2.1 CLI → daemon resolution is keyed by project dir, not by session¶
resolveConfig() (src/config.ts:53) picks where the daemon's state file lives:
BROWSE_STATE_FILEenv, if set — derive everything from it; elsegit rev-parse --show-toplevel→<gitRoot>/.gstack/browse.json; elseprocess.cwd()fallback.
So the state file is keyed by the git toplevel. Two sessions in the same checkout resolve to the same .gstack/browse.json.
ensureServer() (src/cli.ts:404) reads that state file (readState(), src/cli.ts:109) into a ServerState ({ pid, port, token, mode, configHash, … }, src/cli.ts:92). If the recorded port answers GET /health as healthy (isServerHealthy, src/cli.ts:124) it reuses that daemon. Otherwise it takes an exclusive <stateFile>.lock (acquireServerLock, src/cli.ts:379) and spawns a new daemon, passing BROWSE_STATE_FILE to the child (src/cli.ts:341-345).
2.2 The daemon picks a port and owns one Chromium¶
In the daemon (src/server.ts):
- Port:
findPort()(src/server.ts:879) returnsBROWSE_PORTif set (src/server.ts:142), otherwise a random port in 10000–60000. It binds127.0.0.1and writes the chosen port back into the state file so the CLI can find it. - Browser: a single
BrowserManager(src/browser-manager.ts:149) launches one Chromium — headlesschromium.launch()(src/browser-manager.ts:372) or headedchromium.launchPersistentContext()(src/browser-manager.ts:557). - Profile /
user-data-dir:resolveChromiumProfile()(src/config.ts:176):CHROMIUM_PROFILEenv if set, else<GSTACK_HOME|~/.gstack>/chromium-profile. The default profile path is shared across every daemon on the machine.
2.3 One global "active tab" cursor¶
BrowserManager tracks pages: Map<number, Page> and a single activeTabId (src/browser-manager.ts:157-159). Almost every verb resolves its target through getActiveSession() (src/browser-manager.ts:956), which returns the TabSession for activeTabId or throws No active page. Use "browse goto <url>" first.
activeTabId is a single mutable pointer with no session scoping:
- newTab() sets activeTabId to the new tab (src/browser-manager.ts:790-793).
- switchTab() / syncActiveTabByUrl() move it (src/browser-manager.ts:832, :848) — the latter runs on every /sidebar-tabs poll, so a manual tab switch in the browser repoints it within ~2s.
- closeTab() repoints it to whatever tab remains (src/browser-manager.ts:821-828).
2.4 Isolation seams that already exist (key finding)¶
The daemon was already extended for pair-agent isolation, and three env seams already let an embedder run an isolated instance without any daemon code change:
| Seam | Where | Effect |
|---|---|---|
BROWSE_PORT |
src/server.ts:142, :879 |
Pin the daemon to a chosen port instead of random. |
BROWSE_STATE_FILE |
src/config.ts:60 |
Key the state file (hence the whole find-or-spawn) anywhere, not just the git root. |
CHROMIUM_PROFILE |
src/config.ts:176 |
Give the Chromium its own user-data-dir. Already used by gbrowser's gbd for per-workspace profiles. |
BROWSE_TAB + tab ownership |
src/browse-client.ts:142, src/browser-manager.ts:177 (tabOwnership), :926 (checkTabAccess) |
Pin a client to one tab id and scope writes by clientId within one browser. |
Implication: ENG-97's "preferred approach — check whether the daemon already supports a port/instance/profile selector" is largely already true. The selectors exist. The missing piece is a launcher that derives a per-session key and sets these envs — not new daemon internals. That reframes the work from "patch shared infra" to "add an opt-in resolver around it," which is exactly the low-blast-radius shape this issue wants.
3. The concurrency failure mode¶
Two sessions, same checkout (the common dev-VM case):
- Both run
git rev-parse --show-toplevel→ same<gitRoot>/.gstack/browse.json→ both resolve to the same daemon (§2.1). - That daemon has one
activeTabId(§2.3). Session B'sgoto/newtabmoves the global cursor; session A's nextscreenshot/snapshotthen captures B's page — or, if B closed the tab A was on, A hitsNo active page. - There is no per-session context, no per-session cursor, no lease — the active-tab pointer is shared global state.
Two failure modes even if you try to run a second daemon by hand:
- Profile collision: the default CHROMIUM_PROFILE is shared (§2.2). A second Chromium on the same user-data-dir hits Chromium's ProcessSingleton lock. (The cleanSingletonLocks guard at src/config.ts:201 and the headed-handoff machinery exist precisely because this profile is contended.)
- State-file collision: without a distinct BROWSE_STATE_FILE, the second spawn's health-check finds the first daemon and just reuses it — you never get a second browser.
The interim browse-lock.ps1 (PR #14063) sidesteps all of this by letting only one session drive at a time. Correct, but serial, and advisory (a session that ignores the lock still collides).
4. Options¶
Three shapes, cheapest-isolation to strongest-isolation. All three are enabled by the seams in §2.4; they differ in where the isolation boundary sits and how much daemon code must change.
Option A — Per-session daemon instance (separate browser per session)¶
A launcher derives a stable session key (Claude session id / Conductor workspace / worktree path) and sets, before invoking browse:
BROWSE_PORT= a deterministic port fromhash(key)into a reserved band (with a small linear-probe on collision),CHROMIUM_PROFILE=~/.gstack/profiles/<key>(its ownuser-data-dir),BROWSE_STATE_FILE=~/.gstack/sessions/<key>/browse.json(its own find-or-spawn).
Each session gets a fully independent Chromium + cookie jar + tab set. No daemon code changes — it composes existing env seams.
- Pros: true parallelism, zero contention; strongest isolation (separate process and cookies/storage, so no org-bleed between sessions); mirrors how gbrowser's
gbdalready does per-workspace profiles; nothing to PR upstream to ship the core behaviour. - Cons: N Chromium processes ≈ N × few-hundred-MB RAM on the dev VM; needs lifecycle/GC for idle daemons and stale
profiles/<key>dirs; needs port-band allocation + collision handling; login/cookie state is no longer shared across sessions, so a session that relied on an already-logged-in shared profile must import cookies itself (/setup-browser-cookies).
Option B — Per-session browser context inside one daemon¶
Keep one daemon and one Chromium, but give each session its own Playwright BrowserContext (own cookie jar/storage) and its own active-tab cursor, scoped by clientId.
- Pros: one Chromium process → much lower RAM than Option A;
BrowserContexts are cheap; still gives true cookie/storage isolation between sessions. - Cons: requires real daemon changes to shared infra — today there is one global
activeTabIdand onecontext(§2.3); this needs a per-client context map + per-client active-tab + threadingclientIdthrough every command's target resolution. ThetabOwnership/checkTabAccessplumbing is a start but does not yet scope the active-tab cursor or the context. One daemon crash takes down all sessions (shared blast radius). Should be PR'd upstream, raising lead time and review surface — exactly the "don't rebuild shared infra under time pressure" risk this issue flags.
Option C — Tab-pool with leases (one daemon, one context)¶
Keep one browser and one context; a session leases a tab (gets a tabId), all its commands pin to that tab via BROWSE_TAB/clientId, and the lease auto-expires.
- Pros: smallest change —
BROWSE_TABpinning andtabOwnershipalready exist (§2.4); one Chromium → lowest RAM. - Cons: tabs in one context share cookies/localStorage/sessionStorage — not true isolation. Two sessions QA-ing different orgs would bleed into each other, which is the exact org-bleed bug class we already fight (see
project_browse_digital_card_org_bleed). Doesn't satisfy the ticket's "own browser instance." Still one crash = all sessions down.
Comparison¶
| A: per-session daemon | B: per-session context | C: tab-pool + leases | |
|---|---|---|---|
| True parallelism | ✅ | ✅ | ✅ (serial within a tab) |
| Process isolation | ✅ | ❌ (shared crash) | ❌ (shared crash) |
| Cookie/storage isolation | ✅ | ✅ | ❌ (bleed) |
| Daemon code change | none | large | small |
| Upstream PR needed | no | yes | small |
| RAM cost | high (N browsers) | low | lowest |
| Lifecycle/GC work | yes (daemons + profiles) | moderate | low |
5. Recommendation¶
Adopt Option A (per-session daemon) as the durable fix, and keep browse-lock.ps1 as the documented fallback for the shared-instance case.
Rationale:
- It is the only option that delivers true isolation across all three boundaries (process, profile, cookies) — and cookie isolation matters because our QA frequently switches orgs, and shared-context options reintroduce org-bleed.
- It needs no change to the shared daemon, which is the whole constraint of this issue: it leans entirely on BROWSE_PORT / CHROMIUM_PROFILE / BROWSE_STATE_FILE, seams that already ship (one of them already used in production by gbrowser). The deliverable is a launcher/resolver + lifecycle/GC + docs, all opt-in.
- It matches the ticket's "extend, don't rebuild" and its "check whether a selector already exists" step — the selectors do exist (§2.4), so the upstream PR the ticket anticipated may be unnecessary for the core behaviour (at most a small ergonomics flag).
Keep browse-lock.ps1 for the cases where Option A is the wrong trade: a RAM-constrained box, or sessions that intentionally share one logged-in profile. It becomes the "shared-instance" mode rather than the default.
Route heavy/parallel QA to CI. Per the ticket, the browse-smoke matrix on r2/r3/r4 already runs one isolated browser per job. For large parallel capture runs, dispatching to CI sidesteps the dev-VM entirely; Option A is for the interactive dev-VM case.
Phase 2 (only if dev-VM RAM becomes the constraint): revisit Option B (per-session context) as a memory-efficient successor and PR it upstream. Option A's launcher key (the session id) is the same key Option B would scope contexts by, so Option A is not throwaway — it's the front half of B.
Lifecycle details to nail down before implementing Option A¶
- Session key source: prefer the Conductor workspace / worktree path (stable across reconnects) over a per-invocation id. Fall back to git toplevel so today's behaviour is the degenerate single-session case.
- Port band: reserve a contiguous range (e.g. 39000–39099) distinct from the random 10000–60000 default to make collisions and cleanup easy to reason about.
- GC: reap a session daemon after an idle TTL (no command for N minutes) and delete its
profiles/<key>dir; reuse the existing health-check +killServerpaths. - Cookie bootstrap: document that an isolated session starts with a clean profile and must run
/setup-browser-cookies(or a per-session cookie import) if it needs an authenticated start.
6. Prototype (flag-gated, NOT wired into the default path)¶
A companion prototype ships in this PR at scripts/dev/browse-session.ps1:
- Activated only when
BROWSE_ISOLATED=1is set; otherwise it is a byte-for-byte pass-through to the normalbrowseCLI and the existing shared-daemon path is untouched. - When active, it resolves the session key (
GSTACK_SESSION_KEY>CLAUDE_SESSION_ID>CONDUCTOR_WORKSPACE_ID> worktree path > git toplevel), picks a port in the reserved 39000–39099 band (deterministic start, linear-probed for a free slot, then persisted per session so re-invocations reuse the same daemon), and exportsBROWSE_PORT/CHROMIUM_PROFILE(~/.gstack/profiles/<slug>) /BROWSE_STATE_FILE(~/.gstack/sessions/<slug>/browse.json) before exec'ingbrowse. The<slug>carries a short hash of the full key so long worktree paths don't collide when truncated. - It is explicitly a proof-of-concept for the §5 lifecycle decisions, not a default and not invoked by any skill or other script. The shared daemon and
server-node.mjsare not modified. Idle-daemon GC and stale-profile reaping are intentionally left for the production launcher.
This keeps the design pass safe: nothing here can strand an existing browse-test session, because the default resolution path is byte-for-byte unchanged unless a session opts in.
7. Acceptance mapping (ENG-97)¶
| Acceptance criterion | How this design meets it |
|---|---|
| Two concurrent sessions drive a browser without hijacking | Option A: each session gets its own daemon/port/profile → no shared activeTabId, no shared user-data-dir. Verify by running two capture flows in parallel under BROWSE_ISOLATED=1. |
Approach documented in .agents/context/local-iis.md |
Follow-up: add a short pointer there once Option A's launcher lands; this doc is the canonical design. |
browse-lock.ps1 retired or kept as fallback |
Kept as the documented shared-instance fallback (§5). |
8. References¶
- Interim advisory lock: PR #14063 (
scripts/dev/browse-lock.ps1) - Daemon source:
~/.claude/skills/gstack/browse/src/(config.ts,server.ts,browser-manager.ts,cli.ts,browse-client.ts) - Related: ENG-87 (QA contention writeup), ENG-188 (admin SPA prime failure)