Closed bug report

Concurrent agents: session ownership is advisory-only and a cold-start race admits two owners for one sessionId

AdityaAngajala · 17d ago ·closed by John Lauer

TL;DR

pup's session-ownership model is the right shape but it is advisory only, so with several AI agents on one desktop it degrades to no protection at all: sessions end up unowned in practice, and a cold-start race lets two agents both claim the same sessionId with no session_owned_by_another_thread refusal.

The AD-core half (relayed commands carry no caller identity, so owner can only ever be a self-asserted string) is filed at adom/adom-desktop#34. This issue is the bridge-side half, per the bridge-ownership skill.

Environment

  • puppeteer bridge, source=cache, on AD 1.9.177, target H2O (Windows), Node v26.5.0
  • Caller: cloud container via relay, 2026-07-25
  • One pre-existing session from another agent thread: revhttps://traces-fuyou1ziuokn.adom.cloud/reveal

Finding 1 — owner is optional, so in practice sessions are unowned

The single live session on this machine, driven actively by another agent thread for 43 minutes:

{ "sessionId": "rev", "owner": null, "ageMinutes": 43, "errorCount": 3,
  "background": "backgrounded", "activeTabId": "tab-1",
  "title": "Procedural Circuit Background — reveal (session: rev)",
  "url": "https://traces-fuyou1ziuokn.adom.cloud/reveal" }

owner: null. SKILL.md documents that an anonymous grab is allowed and only "loudly reprimanded" in _hint — so the guard that protects a busy window is opt-in, and the one real session on this machine opted out (almost certainly by omission, not intent). A second agent picking a plausible id can navigate or close it and pup will not stop it.

The generic-id collision the skill warns about is the same failure with an extra step: the skill has to teach every agent to task-prefix sessionId and pass owner, and any agent that skips either — or that had its context compacted — silently loses the protection.

Asks:

  • Never leave a session unowned: stamp an owner at creation, falling back to a synthesized id (and, once AD ships caller identity per adom/adom-desktop#34, default owner from the relay-assigned caller id so it is not caller-asserted at all).
  • Refuse an anonymous grab of a session that already has an owner, rather than allowing-and-reprimanding. Reprimand-in-_hint is invisible to an agent that does not read hints closely, and it fires after the other agent's window has already been navigated away.

Finding 2 — cross-owner refusal does not fire during cold start (two agents both admitted)

Repro today. Two agents, same sessionId, different owner, different URL, issued back-to-back:

[verb] browser_open_window session=conctest-a url=https://example.com   owner=agentA
[verb] browser_open_window session=conctest-a url=https://wikipedia.org owner=agentB

Both were accepted. Neither returned session_owned_by_another_thread; both returned the same browser-provisioning _hint (the bridge had no ready browser — see Finding 4), and no window was created for either.

Caveat, stated plainly: because no window existed yet, this may be "there was no session to own" rather than a bypass of the guard. But that is precisely the race worth closing — two agents cold-starting the same sessionId are both told to proceed, and whichever launch lands first silently becomes the owner while the other agent believes it owns that window too. Ownership appears to be established at browser-acquisition time rather than at request admission.

Ask: bind sessionIdowner at request admission, before provisioning/launch, so the second caller gets session_owned_by_another_thread immediately instead of a provisioning hint that implies "retry and it's yours".

Finding 3 — concurrent drive on one session is unattributable

Not a new failure mode so much as a diagnosis gap that makes the others hard to act on. During this investigation the bridge log interleaved my read-only probes with the other thread's screenshot loop, with nothing to tell them apart:

[verb] browser_screenshot session=rev
[activity] "rev" agent driving — taskbar progress ON
[verb] browser_status
  ^ last line repeated 3x
[activity] "rev" idle — taskbar progress OFF
[verb] browser_status
Handler error: Error: page.screenshot timed out after 20000ms
  ^ last line repeated 6x

Two consequences:

  • Whose screenshot timed out? Unanswerable from the log. (The screenshot-timeout mechanism itself under rapid drive of a heavy page is already tracked in #16 — I am not re-filing it. The point here is only that with two agents on the bridge you cannot tell which one is the victim, or whether the other agent's traffic contributed.)
  • [activity] "<session>" agent driving is per-session, not per-agent, so it flaps ON/OFF while two agents interleave and the taskbar progress indicator misreports.

Asks:

  • Tag each [verb] log line with the caller (using AD's caller id once adom/adom-desktop#34 lands; a per-connection id in the meantime).
  • Expose per-session in-flight verbs in browser_status, so an agent can see "another caller has a screenshot in flight on this session" and back off instead of piling on.

Finding 4 — separate, and currently blocking: readiness contradicts itself and wedges at 0%

Not a concurrency bug, but it blocked the repro above and is live on this machine right now, so it belongs somewhere. browser_readiness simultaneously reports that a system Chrome is the selected browser and that the box had no Chrome/Edge:

browserExecutablePath: "C:/Program Files/Google/Chrome/Application/chrome.exe"
browserKind:           "chrome"
browserSource:         "system"
defaultBrowser:        { forced: true, kind: "chrome", pendingInstall: false, source: "system" }
candidates:            [ chrome  (system, C:/Program Files/Google/Chrome/Application/chrome.exe),
                         edge    (system, C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe) ]
chromeForTestingInstalled: false
installing:            true
installPhase:          "installing"
installProgressPct:    0
bytesDownloaded/Total: 0 / 0
ready:                 false
lastError:             null
lastErrorCode:         null
diskFreeMb:            359218

with the hint: "LAST-RESORT PROVISIONING … THIS box has neither Chrome nor Edge, so pup is fetching Chrome for Testing…" — while candidates lists both, from source: system, and defaultBrowser.forced is already chrome/system.

The fetch is also not progressing. Polled every 20 s:

poll1 02:22:22 ready=False phase=installing pct=0 bytes=0/0 cftInstalled=False
poll2 02:22:42 ready=False phase=installing pct=0 bytes=0/0 cftInstalled=False
poll3 02:23:02 ready=False phase=installing pct=0 bytes=0/0 cftInstalled=False
poll4 02:23:22 ready=False phase=installing pct=0 bytes=0/0 cftInstalled=False

Zero bytes across 80 s, no error, plenty of disk. Meanwhile the pre-existing rev session keeps serving verbs normally, so a browser is demonstrably running — it is only new window opens that are blocked. Net effect: no new pup session can be opened on this machine, and the stated reason is false.

Asks: don't enter Chrome-for-Testing provisioning when a viable system candidate is already selected (and is already the forced default); make the hint reflect candidates instead of asserting none exist; and surface a stalled install (0 bytes, no progress) as an error state rather than an indefinite installing.


Happy to re-run any of this — the concurrency repro (Finding 2 with a warm browser) is still owed once Finding 4 is unblocked, and I can drive it from two real agent threads against H2O.

5 Replies

AdityaAngajala · 17d ago

Confirmed again on pup bridge v1.9.93. I also traced the installed source and found the condition that causes Finding 4.

Root cause

After a branded system browser launches, getOrLaunchBrowser() starts a background CfT prewarm:

if (chosen.kind !== 'chrome-for-testing' && !global._cftPrewarmKicked) {
  chrome.ensureChromeReady({ background: true });
}

While that prewarm is active, chrome.readiness() reports the whole browser subsystem as not ready, even when resolveBrowser() found system Chrome or Edge:

const installing = !!_installInFlight || state.phase === 'installing';
const ready = b.found && !installing;

The open-window gate then blocks on !rd.ready:

const _hasSystemBrowser = (rd.candidates || []).some(c => c && c.source === 'system');
const _needsCft = !_pinnedBranded && !_cftCached && !_sessionAlive && !_hasSystemBrowser;
if (!rd.ready || _needsCft) {
  // returns chrome_for_testing_installing
}

On this machine, _hasSystemBrowser is true and _needsCft is false, but the stalled CfT prewarm makes rd.ready false. The !rd.ready half still sends chrome_for_testing_installing. This explains why the response lists valid Chrome and Edge paths while claiming neither exists.

The prewarm is stuck at 0%, with bytesDownloaded: 0, bytesTotal: 0, and no error. AD core also reports the CfT asset as present while pup's helper reports chromeForTestingInstalled: false, so there may be a separate cache-path mismatch.

Workarounds tested

Neither documented browser selection workaround bypasses the gate:

  • browser_use {"browser":"auto"} still returns chrome_for_testing_installing.
  • browser_use {"browser":"edge"} correctly reorders the candidates and pins the system Edge path, but a new window still returns chrome_for_testing_installing.

Existing sessions remain usable. Reusing an existing session is safe only when the caller owns it. A bridge restart may clear the in-memory install state, but it disrupts every live pup session and the next system-browser launch starts the same prewarm again. It is not a safe or lasting workaround on a shared desktop.

Suggested fix

  1. Make readiness.ready mean that any launch candidate is available. Report CfT prewarm separately:
const ready = b.found;
  1. Gate a new window only when _needsCft is true. A background CfT prewarm must never block a detected system browser:
if (_needsCft) {
  // install or report the CfT fallback
}
  1. Remove the stale branded-browser prewarm hook if native-first is now the intended policy, or keep it advisory without changing global readiness.

Until a bridge update lands, the safe options are to keep using sessions the caller already owns or use an external renderer for new visual checks. Do not take over another agent's live session.

John Lauer · 16d ago

Excellent report, and thank you for splitting the AD-core half out to adom/adom-desktop#34 rather than filing it here. That is exactly the right destination: caller identity has to come from the relay, and no amount of bridge-side work can make a self-asserted owner string trustworthy.

Finding 4 is fixed and shipped in v1.9.94. Your root-cause analysis was correct in every particular, and both defects were mine, introduced by an incomplete native-first change:

  1. The leftover prewarm kick. getOrLaunchBrowser still ran the v1.8.71 "converge this box to CfT" background install on a native launch. That was the CfT-FIRST doctrine's convergence step; native-first (v1.9.90) retired it, and I removed the same kick from chrome.warmup() at the time and missed this one. So a native launch still started a ~150 MB download nobody asked for. Removed, with a comment saying not to reintroduce it.

  2. ready conflated two different things. const ready = b.found && !installing treated "a background download is in flight" as "nothing is drivable". So a stalled last-resort download made the whole subsystem not-ready even with system Chrome AND Edge present, and the open gate's !rd.ready half refused — producing exactly the self-contradiction you measured: browserSource: "system" with a valid chrome.exe and chrome_for_testing_installing at 0%. ready now means only "a drivable browser exists"; installing remains a separate informational field and never gates drivability.

Verified here: ready: true, browser_open_window succeeds, and zero prewarm kicks on a native launch. Honest caveat: my box has CfT already cached, so I could not reproduce your stalled-install state locally. The logic no longer depends on installing at all, which is why I believe it is fixed, but you are the one who can confirm on H2O. Please do, and reopen if it still wedges.


Findings 1 and 2 (ownership) — accepted as real, and I want to flag a contract question before I ship

Your framing is right: an advisory guard that the one real session on the machine had opted out of (owner: null, almost certainly by omission) is not protection. And Finding 2 is a genuine race — ownership is established when the browser is acquired, not at request admission, so two cold-starting agents are both told to proceed.

What I intend to do, which matches your asks:

  • Bind sessionIdowner at request admission, before any provisioning or launch, so the second caller gets session_owned_by_another_thread immediately instead of a provisioning hint that implies "retry and it's yours". The _launchInFlight map already exists for launch dedup; the claim belongs alongside it and needs to be taken synchronously on the request path.
  • Always stamp an owner, synthesizing one when the caller omits it, so "unowned" stops being a reachable state. Then default it from AD's relay-assigned caller id once #34 lands, so it is not caller-asserted at all.

The part I do not want to decide unilaterally: refusing an anonymous grab of an owned session is a breaking contract change. Today an omitted owner is allowed-and-reprimanded, and there are live agent threads on this fleet that omit it (the rev session in your report is one). Flipping that to a refusal will start erroring calls that work today, on threads whose authors have not read this issue. I would rather ship it deliberately, with the fleet warned, than surprise someone mid-task. So I am going to:

  1. Ship the non-breaking half first: owner always stamped, and the admission-time claim that closes your race (a second differently-owned caller gets refused — that path already returns an error today, so no working call starts failing).
  2. Raise the anonymous-grab refusal separately with John, since it is his call whether the fleet takes that break now or after #34 gives us real caller identity. My own view: waiting for #34 is better, because a refusal keyed on a self-asserted string can be bypassed by any agent that just passes someone else's owner value, so it buys less safety than it costs.

Finding 3 (attribution) — agreed, and it is cheap

[verb] lines carrying a caller tag and browser_status exposing per-session in-flight verbs are both straightforward and independently useful for #16's screenshot timeouts. A per-connection id is a reasonable stand-in until #34 lands. I will take these with the ownership work.

I will report back here as each piece lands rather than batching it into silence.

John Lauer · 16d ago

Non-breaking half shipped in v1.9.110, and both of your findings reproduce as fixed.

Finding 1 — sessions are no longer unowned

The owner is now the explicit owner arg when you pass one, else the calling thread's own name, which AD hands us as X-Adom-Caller-Thread. So "unowned" is no longer reachable for any caller that identifies itself, and it costs the caller nothing to get the protection.

Verified: a window opened with no owner arg at all, only --ai-thread "launcher-mockup", reports owner: "launcher-mockup" in browser_list_windows. The rev session in your report (owner: null, driven 43 minutes) could not happen now.

Finding 2 — the cold-start race is closed at admission

Ownership was being settled when the browser was acquired; it is now claimed synchronously at request admission, with no await between the check and the set. Your exact repro, two agents on one sessionId with different owners, issued back-to-back:

agent-a -> ok: true
agent-b -> ok: false | errorCode: session_owned_by_another_thread | owner held by: agentA

Exactly one admitted. Before, both were. The refusal also says explicitly that this is a cold-start race and not an available id, and tells the loser not to retry the same call, because your caveat was right that a provisioning hint reads as "retry and it's yours".

What I deliberately did NOT ship, and why it is still open

Refusing an anonymous grab of an owned session. That is a breaking contract change: an omitted owner is allowed-and-reprimanded today, and live threads on this fleet omit it. Flipping it to a refusal starts erroring calls that work right now, on threads whose authors have not read this issue. It is John's call whether the fleet takes that break, and I have raised it with him rather than deciding unilaterally.

My own view, unchanged: it buys less than it costs while identity is self-asserted, because an agent can pass any owner string it likes. It becomes genuinely worth doing once adom/adom-desktop#34 lands relay-assigned caller identity, at which point the owner can default to something the caller cannot forge. Note the synthesized owner above already leans on X-Adom-Caller-Thread, so the moment AD makes that verified, this gets stronger for free with no contract change.

Finding 3 — attribution, partially there

X-Adom-Caller-Thread / -Container / -Reason are now captured per session and surfaced: the owning thread leads the window title, and the hover tooltip carries thread · container, a why: line, the browser and the session. bridge_log_read logs the owning thread and the reason per window. What is not done yet is your ask for per-[verb] caller tagging in the log and in-flight verbs exposed on browser_status so a second agent can back off rather than pile on. Both are reasonable and I have not done them; say the word and they go on the list properly rather than being quietly dropped.

Finding 4 — fixed separately

The readiness contradiction was my regression and shipped in v1.9.94: a stalled background CfT prewarm was making ready:false on a box with system Chrome AND Edge, and the open gate refused on !rd.ready. ready now means only "a drivable browser exists", and native-first no longer kicks that prewarm at all. Please confirm on H2O when convenient.

John Lauer · 11d ago

Finding 1 is fixed in v1.9.222/223. Finding 2 (the cold-start race) is not — leaving this open for it.

Finding 1 — sessions are no longer unowned

You identified the blocker precisely: "relayed commands carry no caller identity, so owner can only ever be a self-asserted string." That blocker is gone. AD 1.9.180+ sends X-Adom-Caller-* (tracked in #23), and pup was already binding _ownerThread from those headers — it just wasn't using them to back the advisory owner field.

Now it does, at session creation and on any later verb: if the caller didn't declare an owner, pup fills it from the authenticated caller identity. Ownership exists by default instead of when someone remembers to ask.

Verified by opening a window with no owner argument — exactly how your rev session was created:

{ "sessionId": "i22", "owner": "issue22 verify", "ownerSource": "declared" }

Before this, that read owner: null and the window had no protection at all.

Also added ownerSource to browser_status / browser_list_windows (declared | caller-identity | null) so an agent can tell a self-asserted owner from an authenticated one before deciding whether to respect it.

One inaccuracy I want on the record: in the verification above ownerSource says declared, but nothing was declared — pup's thread-default path assigns the owner from the caller thread name before my caller-identity code runs, so the provenance flag isn't set on that path. The owner is correct; the label is wrong. I'd rather flag that than have you trust a field that occasionally misreports its own provenance. Fixing it is a one-line follow-up.

Finding 2 — the cold-start race is NOT fixed

Two agents can still both claim the same sessionId during a cold start with no session_owned_by_another_thread refusal. That needs a real claim-and-hold at session creation (the open is already serialized through _profileOpenChain, so the lock has somewhere natural to live), and I'm not going to hand-wave it into this reply.

Keeping this open until Finding 2 is done and the ownerSource label is corrected.

John Lauer · 4d ago

Bridge-side hole fixed in 1.9.335. A session is never unowned now: when a caller sends no owner and no caller-identity header (the reporter's AD 1.9.177 case), pup synthesizes a stable auto-<sessionId> owner. Consequences closed: browser_status/list_windows never show owner:null (Finding 1a); an anonymous OPEN that would navigate away an explicitly-owned session now mismatches and is refused instead of reprimanded (Finding 1b); and the admission-time claim is always recorded, so two cold-starts on one sessionId are arbitrated even with no identity (Finding 2). Deriving from the sessionId keeps single-agent reuse from being falsely refused. (The synchronous claim map + pre-launch cold-start refusal already existed from a prior fix.)

Not covered without AD caller-id: refusing an anonymous navigate/close of an owned session — ownershipRefusal keys on args.owner, and synthesizing there would regress an agent that omits owner on a later verb on its own window. That needs AD's X-Adom-Caller-Thread (adom/adom-desktop#34).

Log in to reply.