Three files. Three line ranges. Read it yourself.

Two Playwright agents on one machine is not a Playwright workers problem.

When two agents drive their own Chromium against a shared persistent profile, the things that break are not concurrency primitives the way Playwright thinks about them. They are operating-system housekeeping: a stale symlink whose target is the string hostname-PID, a leftover Chrome process pinned to a --user-data-dir path, and two run-artifact trees that race to write events.json into the same folder. This page is about the three pieces of real code in @m13v/assrt that make those collisions disappear, with file paths and line numbers you can paste into your editor.

M
Matthew Diakonov
9 min read

The persistent-profile launch path, in order

  1. 1

    Per-run UUID dir

    Disjoint paths under tmpdir, one per assrt_test call.

  2. 2

    Singleton scrub

    Unlink stale SingletonLock, SingletonSocket, SingletonCookie before --user-data-dir.

  3. 3

    Orphan-PID walk

    ps aux + Singleton symlink target, SIGKILL anything pinned to the profile.

  4. 4

    Isolated fallback

    If the lock cannot be removed, drop persistent, log it, run --isolated.

Layer 1

A fresh UUID directory per run, before anything else happens

The cheapest piece, and the one that does the most work. Every call to the assrt_test MCP tool mints a fresh runId with crypto.randomUUID() at the very top of the handler, before the agent class is even constructed. The run directory is computed as join(tmpdir(), "assrt", runId). Screenshots, video, the line-by-line log, and the structured event stream all hang off that directory. Two parallel assrt_test calls produce two disjoint UUIDs and therefore two disjoint trees on disk. There is no shared mutable file in the run path. The shared path is the persistent browser profile, and that is what the next two layers protect.

// /Users/matthewdi/assrt-mcp/src/mcp/server.ts, lines 427-432
// Every assrt_test call mints a fresh runId at the very top of the
// handler, before the agent is constructed. Two parallel calls get
// two disjoint directories on disk. No shared mutable file.

const crypto = await import("crypto");
const runId = crypto.randomUUID();
const runDir = join(tmpdir(), "assrt", runId);
const screenshotDir = join(runDir, "screenshots");
mkdirSync(screenshotDir, { recursive: true });

// Later in the same handler:
//   const videoDir   = join(runDir, "video");        // line 498
//   const logFile    = join(runDir, "execution.log"); // line 601
//   const eventsFile = join(runDir, "events.json");   // line 605
2^122

UUID v4 collisions are not a real failure mode at this volume. Two agents launched simultaneously still write into completely disjoint directories.

server.ts:429-432, runId minting

Layer 2

Three Chromium files that have to die before --user-data-dir can be passed

Chromium writes three sibling files into a profile while it runs: SingletonLock, SingletonSocket, and SingletonCookie. They exist so that a second chrome --user-data-dir=X invocation can find an already-running instance and forward its arguments instead of starting fresh. If the previous Chrome exited cleanly, the files are removed for you. If it crashed, was SIGKILLed, or you Ctrl-C'd the agent mid-run, the symlink stays. SingletonLock is the load-bearing one: it is a symlink whose target is the literal string hostname-PID, and Playwright launching into a profile with a live-looking lock is how you end up with a 180-second hung navigate.

The fix is a few lines of fs work. The detail that catches everyone is that existsSync follows symlinks and returns false when the target PID does not exist, so it would silently skip the unlink. You have to use lstatSync and ignore the resolved target.

// /Users/matthewdi/assrt-mcp/src/core/browser.ts, lines 311-347
// Persistent path. Run AFTER killOrphanChromeProcesses, BEFORE the
// --user-data-dir flag is appended. lstatSync (not existsSync) is
// load-bearing here because SingletonLock is a symlink whose target
// is the literal string "hostname-PID".

const userDataDir = join(homedir(), ".assrt", "browser-profile");
mkdirSync(userDataDir, { recursive: true });

await McpBrowserManager.killOrphanChromeProcesses(userDataDir);

const singletonFiles = ["SingletonLock", "SingletonSocket", "SingletonCookie"];
for (const name of singletonFiles) {
  const lockPath = join(userDataDir, name);
  let hasLock = false;
  try { lstatSync(lockPath); hasLock = true; } catch { /* no lock */ }
  if (hasLock) {
    try {
      unlinkSync(lockPath);
      console.error(`[browser] removed stale ${name} from browser profile`);
    } catch {
      console.error(`[browser] could not remove ${name}, falling back to isolated mode`);
      args.push("--isolated");
      isolated = true;
      break;
    }
  }
}
if (!isolated) {
  args.push("--user-data-dir", userDataDir);
}

Note the fallback. If any of the three files cannot be unlinked (because Chrome is actually still alive and holding it, which is the two-agent collision case), Assrt does not fight. It pushes --isolated onto the args, sets a local isolated = true, and the persistent path drops out. The losing agent runs in an in-memory profile for that one call and reports it in the launch log line.

Layer 3

The ps walk that runs before the singleton scrub

This is the layer that surprised me when I first read the source. The SingletonLock files are not the only thing that survives a crashed run. The Chrome process itself, and sometimes the Playwright MCP wrapper that spawned it, can stick around as orphans. They keep the user-data-dir busy. Removing the lock files while one of those orphans is still alive does not help; the orphan re-creates them.

So before touching the singleton files, Assrt walks three sources of PIDs and SIGKILLs everything pinned to the profile path. The three sources are:

  • ps aux | grep for every process whose command line contains the exact --user-data-dir=<path> string. Catches the main Chrome and most renderer/utility children.
  • The integer parsed from the SingletonLock symlink target after the - character. Catches the case where the main Chrome PID was missed by the ps grep, which I have seen on macOS specifically.
  • A one-level parent walk that runs ps -o ppid= -p <pid> on every PID found so far, and adds the parent if the parent's command contains the substring playwright. That is how the Playwright MCP wrapper gets caught when its child Chrome was the only thing on ps aux.
// /Users/matthewdi/assrt-mcp/src/core/browser.ts, lines 149-182
// Three sources of truth, unioned. Then a one-level parent walk to
// catch the Playwright MCP wrapper if it owns one of the discovered
// pids. Returns a deduped Set of integers, never a string.

private static async collectProfilePids(userDataDir: string): Promise<number[]> {
  const pids = new Set<number>();

  // Source 1: every process with --user-data-dir=<path> on its cmdline
  const psOutput = execSync(
    `ps aux | grep -E "user-data-dir.*${userDataDir.replace(/\//g, "\\/")}" | grep -v grep || true`,
    { encoding: "utf-8", timeout: 5000 }
  ).trim();
  for (const line of psOutput.split("\n").filter(Boolean)) {
    const pid = parseInt(line.trim().split(/\s+/)[1], 10);
    if (pid && !isNaN(pid)) pids.add(pid);
  }

  // Source 2: the SingletonLock symlink target (encoded as hostname-PID)
  try {
    const target = readlinkSync(join(userDataDir, "SingletonLock"));
    const lockPid = parseInt(target.split("-").pop() || "", 10);
    if (lockPid && !isNaN(lockPid)) pids.add(lockPid);
  } catch { /* no lock */ }

  // Source 3: parents of (1) and (2) that are Playwright MCP wrappers
  for (const pid of [...pids]) {
    const ppid = parseInt(execSync(`ps -o ppid= -p ${pid}`).trim(), 10);
    if (ppid && ppid > 1) {
      const parentCmd = execSync(`ps -o command= -p ${ppid}`).trim();
      if (parentCmd.includes("playwright")) pids.add(ppid);
    }
  }
  return [...pids];
}

The kill loop runs twice with a 750 ms gap between sweeps. The second sweep covers the race where a child respawned, or a process whose PID was missed by the first ps. If the second sweep still returns PIDs, the function logs a warning and continues; the singleton-scrub layer downstream will either succeed or fall back to --isolated.

What it looks like when two agents collide

Two Claude Code sessions, both with @m13v/assrt configured as an MCP server, both calling assrt_test on the same target URL within the same second. Agent A wins the race. Agent B does not error; it transparently demotes itself to isolated mode for that one run. Here is the message sequence.

Two agents, one persistent profile, separated by 50ms

Agent AAgent BlaunchLocalps + fsChromiumlaunchLocal(persistent)ps aux | grep user-data-dirno orphanslstat SingletonLock -> nonespawn chromium with --user-data-dirok, lock writtenlaunchLocal(persistent) [t+50ms]ps aux | grep user-data-diragent A's chrome is owner, not orphanunlink SingletonLockEBUSY (chrome A holds it)fall back to --isolatedin-memory profile, logged-out chrome

The single most important line in this exchange is the one labeled EBUSY. That is the catch block at browser.ts:335 firing, which pushes --isolated and breaks out of the singleton loop. The losing agent runs in memory, the winning agent owns the persistent session, and neither throws.

What raw launchPersistentContext does, and what the agent path adds

Both code paths use real Chromium. The difference is what happens before and around the launch.

Two agents, one host

// What two raw @playwright/mcp agents typically do
// when both want a persistent profile on the same host.

import { chromium } from "playwright";

const userDataDir = "/Users/me/.cache/playwright-profile";

// Agent A
const ctxA = await chromium.launchPersistentContext(userDataDir, {
  headless: true,
});
// ... runs scenario, exits, but child process leaks.
// SingletonLock on disk now points at a dead PID.

// Agent B starts a few seconds later.
const ctxB = await chromium.launchPersistentContext(userDataDir, {
  headless: true,
});
// chromium sees SingletonLock, opens-in-existing-browser-session
// behavior kicks in, the new browser routes commands into the
// dead-but-not-cleaned-up session, navigate() hangs for 180s,
// MCP stdio drops, you get "MCP client not connected" on the
// agent log with no actionable signal.
-4% line shape, but the work shifts

The on-disk state, before and after a launch

Toggle the panel below to see what the relevant filesystem paths look like during a clean two-agent run. The persistent profile is one path, owned by one Chrome at a time. The two run directories are disjoint UUIDs, owned by their respective agents forever.

~/.assrt and /tmp/assrt during two parallel runs

Agent A crashed five minutes ago. Chrome was SIGKILLed by the OS. The persistent profile still has stale singleton files. The orphan PID 84421 is still alive, holding /Users/me/.assrt/browser-profile open. Agent B is about to launch.

  • ~/.assrt/browser-profile/SingletonLock -> 'mac.local-84421' (dead)
  • ~/.assrt/browser-profile/SingletonSocket (orphan FD)
  • ps aux | grep 84421 -> chrome --user-data-dir=/Users/me/.assrt/browser-profile
  • /tmp/assrt/<old-uuid>/ (artifacts from the crashed run, harmless)

What this means for you, in practice

A few opinions from running this in earnest for the last few months.

  • If you only ever run one agent at a time, the persistent default is fine and you can ignore everything above. It just works because the orphan walk and the singleton scrub are no-ops on a clean host.
  • If two Claude Code sessions are both going to call assrt_test against your local app, accept that one of them will run in isolated mode. That session will start logged out. Either run the login #Case first in that session, or use --extension mode and have both agents attach to your real Chrome.
  • On CI, set ASSRT_ISOLATED=1 unconditionally. There is no reason to share state across runs in a CI environment, and isolated mode skips all three layers above for a small startup-time win.
  • If your runner crashes mid-test (Ctrl-C in a hot loop, an OOM, a forced container kill), the next launch will quietly clean up after itself. You do not need to rm -rf the profile or restart the box. The orphan walk is exactly the mechanism that absorbs that case.

Want a second pair of eyes on how your agents share a host?

Fifteen minutes. Bring your CI logs, or a story about the time SingletonLock ate two hours of your week. We will dig in.

Frequently asked questions

What does 'Playwright agent isolation' actually mean here? It is not the same as Playwright workers, right?

Right. Playwright workers are processes spawned by `playwright test` to run spec files in parallel inside one project. Each worker gets its own browser context and an integer worker index. That model assumes one orchestrator. A Playwright agent is different: it is a long-running process driving a real Chromium, often via @playwright/mcp, often steered by an LLM. When you run two agents on one machine, e.g. two Claude Code sessions both calling `assrt_test`, you have two orchestrators that do not know about each other. They cannot share a `WorkerInfo`. They will collide on disk paths, on the SingletonLock symlink inside a persistent profile, and on lingering Chrome processes from a previous crash. The fixes look like operating-system housekeeping, not Playwright config. The three concrete things Assrt does are at server.ts:429-432 (per-run UUID dir under tmpdir), browser.ts:326 (unlink SingletonLock, SingletonSocket, SingletonCookie), and browser.ts:149-214 (a ps walk that finds orphan PIDs pinned to the profile's user-data-dir and SIGKILLs them).

Why not just give every agent --isolated and call it done?

You can, and Assrt supports it: pass `isolated: true` to assrt_test, set `ASSRT_ISOLATED=1`, or pass `--isolated` on the CLI. That maps to `--isolated` on @playwright/mcp, which keeps the profile in memory and persists nothing. The cost is real: every run starts logged out, every signup re-creates an account, every OAuth dance runs from scratch, and your dev server's session cookies are useless. Most teams want the persistent profile at `~/.assrt/browser-profile` so their tests can sit on top of an already-logged-in Chrome. The persistent path is the one that needs the SingletonLock scrub and the orphan-PID walk. Isolated mode trades login realism for not having to worry about any of that. The default in `launchLocal()` at browser.ts:307-347 is persistent, with isolated as the explicit fallback when the lock cannot be removed.

What is the SingletonLock file and why does Assrt care about it?

Chromium writes three sibling files into a user-data-dir while it runs: SingletonLock, SingletonSocket, and SingletonCookie. They are how a second `chrome --user-data-dir=X` invocation discovers an already-running instance and sends it 'open this URL' instead of starting fresh. SingletonLock is a symlink whose target is the literal string `hostname-PID`. If a previous Chrome exited cleanly, the symlink is removed. If it crashed, was SIGKILLed, or you Ctrl-C'd the agent mid-run, the symlink stays. The next agent that tries to launch into that profile sees the lock, assumes another Chrome owns it, and either fails or does the 'opening in existing browser session' thing, which from Playwright's point of view looks like a hung navigate. Assrt's fix at browser.ts:326-342 is to lstat the lock (you cannot use existsSync because it follows the broken symlink), unlink all three files, and only then pass `--user-data-dir` to @playwright/mcp. If the unlink fails, it falls back to `--isolated` and logs the reason.

Why does Assrt walk `ps aux` to find PIDs? Cannot you just look at the SingletonLock target?

Sometimes you can, sometimes you cannot. The SingletonLock encodes `hostname-PID`, but on macOS the lock is occasionally written by a child process while the actual Chrome main is a different PID. Worse, after a crashed run, the lock can point at a PID that no longer exists. Worse still, the parent of that PID might be a Playwright MCP wrapper that is also still running. Assrt's `collectProfilePids()` at browser.ts:149-182 unions three sources: a `ps aux | grep` for any process whose command line contains `--user-data-dir=<that exact path>`, the PID encoded in the SingletonLock target, and a one-level parent walk to catch the Playwright MCP wrapper if it is the parent of one of the discovered PIDs. The deduped set is what `killOrphanChromeProcesses()` SIGKILLs at browser.ts:191-214, with a 750 ms wait and a second sweep in case a child respawned. Only after both sweeps come back empty does it touch the SingletonLock files.

Where do the per-run artifacts (screenshots, video, events.json) actually go, and how do two agents avoid clobbering each other?

Every call to assrt_test mints a fresh `runId = crypto.randomUUID()` at server.ts:429 and uses it to compute `runDir = join(tmpdir(), 'assrt', runId)` (server.ts:430). All artifacts for that run live under that directory: screenshots at `runDir/screenshots/NN_stepN_action.png`, video at `runDir/video/<webm>`, the line-by-line execution log at `runDir/execution.log`, and the structured event stream at `runDir/events.json`. Because UUID v4 collisions are not a real failure mode, two agents launched simultaneously write into completely disjoint directories on disk. There is no shared mutable file in the run path. The one shared path is the persistent browser profile, and that is what the SingletonLock + orphan-PID layer protects.

What if I literally run two `assrt_test` calls at the same time on one host? Do they share the browser?

Within one MCP server process, yes: there is a sharedBrowser at server.ts:31 that gets reused across calls so you do not pay the Chromium cold start every time. Across separate MCP server processes, no, and that is the case the three isolation pieces are built for. Each process tries to launch its own Chromium against the persistent profile. The first one wins and writes a fresh SingletonLock. The second one hits `killOrphanChromeProcesses` (which finds no orphans, because the first Chrome is alive and not an orphan), then tries to remove the SingletonLock (which fails because Chrome is actively holding it), and falls back to `--isolated` mode for that run. That is the intended behavior: two truly concurrent persistent runs against one profile is not a coherent thing. One inherits the persistent session, the other transparently runs in an in-memory profile and reports it in the launch log line.

Is there a way to keep both agents on persistent state without one falling back to isolated?

Yes, give them different profile dirs. The persistent path is computed at browser.ts:313 as `join(homedir(), '.assrt', 'browser-profile')`, but if you launch the underlying CLI directly you can override the profile root. The bigger lever is mode `--extension`, which skips Assrt's profile mode entirely and connects @playwright/mcp to your already-running Chrome via the Playwright extension. In that mode the agent reuses your real Chrome session, which means two agents can both attach to the same Chrome and drive different tabs. The trade-off is your real Chrome history and your real cookies are now in the loop, so it is not what you want for clean CI runs.

Does the same isolation logic run in CI, or is it only for local concurrency?

It runs everywhere `launchLocal()` runs, which is the same code path on a developer laptop and on a GitHub Actions runner. The orphan-PID walk is harmless on a fresh ephemeral runner: `ps aux | grep` returns nothing, `collectProfilePids()` returns an empty set, `killOrphanChromeProcesses` returns immediately, and the SingletonLock check finds no symlink to unlink. You pay one `ps aux` invocation and one `lstatSync` per launch, both noise-level. Where it earns its keep is the case where a runner is reused (self-hosted CI, Vercel preview environments with persistent worker pools, or the developer's own laptop after a crashed run) and the previous Chrome left state behind.

How do I read the actual code? I want to grep for the lines myself.

The MCP server lives at github.com/assrt-ai/assrt-mcp. The three files you want are src/mcp/server.ts (the runId minting and runDir computation, around line 429), src/core/browser.ts (the launchLocal flow, the SingletonLock scrub at line 326, and the static methods collectProfilePids at line 149 and killOrphanChromeProcesses at line 191), and src/core/agent.ts (the scenario loop and the system prompt the agent runs against). The package is MIT-licensed and the npm release is `@m13v/assrt`. If you clone the repo, `npx tsc --noEmit` gives you a fast type check and the dist build under `dist/` is what gets published.

Can I disable the orphan-PID walk if I do not want Assrt killing things on my machine?

There is no flag for that today. The walk is unconditional in the persistent path and is scoped: it only kills processes whose command line contains the exact `--user-data-dir=<your assrt profile>` string, plus their direct Playwright-MCP parent. It will not touch your normal Chrome. If you really need to opt out, run with `--isolated` (or `isolated: true` from the MCP tool); that branch at browser.ts:307-309 skips the entire profile-prep block, including the orphan walk, and just hands `--isolated` to @playwright/mcp.

assrtOpen-source AI testing framework
© 2026 Assrt. MIT License.

How did this page land for you?

React to reveal totals

Comments ()

Leave a comment to see what others are saying.

Public and anonymous. No signup.