[GH-ISSUE #4659] [Bug] capture-browser-scenario leaves its temporary Chrome profile (cookies, history) and Playwright artifacts directory behind when the process is terminated #2429

Open
opened 2026-10-17 21:04:32 +00:00 by bsakaguchi · 3 comments
Owner

Originally created by @ethen-carter on GitHub (Oct 17, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/4659

Before submitting

  • I searched the open and closed issues for a related report.
  • I removed credentials, tokens, private paths, and sensitive target data.

Area

Process capture or runtime observation

Observed behavior

In launch mode, capture-browser-scenario creates two directories in the OS temp directory: a Chrome profile rea-browser-scenario-* and a Playwright playwright-artifacts-*. Both are removed only by in-process cleanup. When the REA process is terminated before it completes, both remain, and nothing removes them later.

The leftover profile is a full Chrome profile, about 10 MB even for a one-line local page. It includes Default/History, Default/Network/Cookies, Default/Cache, and Default/Local Storage of the visited origin.

On the reporter's machine, %TEMP% held 14 rea-browser-scenario-* profiles (about 14 MB each) and 40 empty playwright-artifacts-* directories. They came from earlier REA runs that a wrapper or its parent app ended early: timeouts, cancellations, and a force-closed parent app. I did not match each folder to a specific run. I also did not establish why there were more artifacts directories than profiles.

Reproduction

Windows 11, rea-agents 6.5.0 from npm, Chrome 155.0.8059.41. Reproduced 3/3. A small Node harness performed these steps:

  1. Set TEMP and TMP to an empty directory for the REA process, so that only its own temp directories are observed.
  2. Serve a one-line HTML page on http://127.0.0.1:<port>/.
  3. Run node <rea-agents>/scripts/rea.mjs capture-browser-scenario scenario.json --format json with the scenario below.
  4. Wait until <TEMP>\rea-browser-scenario-*\lockfile exists, which took about 1.3 s, then wait 3 s more.
  5. taskkill /PID <rea-pid> /T /F. REA exits with code 1.
  6. After 5 s, list <TEMP> and the processes whose command line references it.
{
  "browser": { "mode": "launch", "executable_path": "C:/Program Files/Google/Chrome/Application/chrome.exe", "headless": true },
  "start_url": { "url": "http://127.0.0.1:<port>/", "query": [] },
  "actions": [{ "step_id": "wait", "action": "wait_for_timeout", "duration_ms": 60000 }],
  "capture": { "events": ["console"], "at_end": ["url"] }
}

Control, reproduced 2/2: the same scenario with duration_ms: 1000 completes with exit code 0 and leaves no directories.

Expected behavior

I am not asking REA to run cleanup after it has been killed. I am asking that a later REA run can reclaim what a killed run left behind, and that wrappers have a way to cancel REA that runs the existing cleanup. Any of the following would address it:

  1. Reclaim stale scenario directories when a new run starts, with proof of ownership. For example, keep both directories under one REA-owned run root that records an owner token. Remove a root only when its owner is provably gone and the Chrome profile lock is not held. Never infer ownership from the playwright-artifacts-* name alone, because other Playwright users on the same machine create directories with that prefix.
  2. Put Playwright's temp directories inside that run root. Playwright creates playwright-artifacts-* under os.tmpdir() for every launch and has no option to place it elsewhere. A supervised driver process whose TEMP/TMP/TMPDIR points into the run root would make both directories reclaimable together.
  3. Offer a cancellation channel for one-shot CLI commands that also works on Windows. One example is treating stdin EOF as cancellation, as the MCP server already does. Then wrappers could stop a run through the existing abort-and-cleanup path instead of terminating the process.
  4. Document the limitation in docs/browser-scenario-contract.md. It currently says the launch-mode profile is "closed and deleted during cleanup", but abrupt termination skips that cleanup and the profile keeps cookies and history.

A process-local exit handler or a watchdog child would not cover taskkill /T /F, because the tree kill ends those too.

Impact and scope

  • Every launch-mode scenario that ends by termination leaves about 10–14 MB plus cookies, history, cache, and local storage of the analyzed origins in the user's temp directory, with no later cleanup by REA.
  • On Windows, a parent has no ordinary way to request graceful cancellation from a Node CLI. In this environment, child.kill("SIGTERM") and child.kill("SIGINT") from Node ended the child without running its signal handlers or its exit event (1/1 each). So the SIGINT/SIGTERM path in withCommandCancellation cannot be reached by a wrapper, and every wrapper timeout or cancellation on Windows leaks these directories.
  • Inference, not tested: if a scenario signs in with secret values, the resulting session cookies could stay in the leftover profile.

Not tested: Linux and macOS (no Chrome was available in the WSL environment here), Ctrl+C in an interactive console, headed mode, the MCP capture_browser_scenario tool with the server process terminated, and other Playwright-based REA commands.

REA version

rea-agents 6.5.0 (npm), with its installed playwright-core 1.58.2. Source references below are from the 6.5.0 source archive.

Runtime and platform

Windows 11 Pro 10.0.26200, x64; Node 24.16.0; Chrome 155.0.8059.41

Target and artifact context

One-line local HTML page served on 127.0.0.1; not a binary target

Interface

CLI

Earliest failure stage

Cleanup, cancellation, or lifecycle

Relevant output or evidence

Harness output for one terminated run (user path redacted; the other two runs were identical except for the random suffixes):

browser_ready=true after_ms=1300
before_kill  [{"name":"playwright-artifacts-fjOw5y","entries":0,"bytes":0,"lockfile":false},
              {"name":"rea-browser-scenario-zrXlO5","entries":17,"bytes":10268322,"lockfile":true}]
rea_exit code=1 signal=null
left_after_5s [{"name":"playwright-artifacts-fjOw5y","entries":0,"bytes":0,"lockfile":false},
               {"name":"rea-browser-scenario-zrXlO5","entries":16,"bytes":10268322,"lockfile":false}]
processes_referencing_temp=0

Control run:

rea_exit code=0 signal=null
left_after_5s []
processes_referencing_temp=0

Leftover profile, top level of Default/ (selection):

Cache  Code Cache  History  Local Storage  Network/Cookies  Network/Trust Tokens

Signal probe on Windows (Node 24.16.0, parent calls child.kill(sig) on a child that registers process.on(sig) and process.on("exit")):

SIGTERM: exit code=null signal=SIGTERM handler_ran=false exit_event_ran=false
SIGINT:  exit code=null signal=SIGINT  handler_ran=false exit_event_ran=false

Typed outcome or recovery expected

The terminated run cannot report anything. A later run that reclaims stale directories could report what it removed or kept in its cleanup details, so that leftovers are visible rather than silent.

Analysis or suspected cause

Source-derived (REA 6.5.0):

  • src/browser/PlaywrightScenarioBrowser.ts:642 creates the profile with mkdtemp(join(tmpdir(), "rea-browser-scenario-")).
  • PlaywrightScenarioBrowser.ts:646-672 launches it with launchPersistentContext, with handleSIGHUP/SIGINT/SIGTERM: false (661-663).
  • The profile is removed only by rm(profilePath, …) in the cleanup owner (PlaywrightScenarioBrowser.ts:685-689) or on launch failure (673-683).
  • src/cliBrowserScenarioCommands.ts:34-36 and 62-64 run the capture inside withCommandCancellation, then provider.close() in finally. src/cli/commandCancellation.ts:15-24 maps SIGINT/SIGTERM to an abort.
  • In src/, the string rea-browser-scenario- appears only at line 642. I found no startup sweep or later reclamation.
  • src/main.ts:32-36: the MCP server also shuts down on stdin end/close. One-shot CLI commands have no equivalent.

Source-derived (playwright-core 1.58.2, as installed with rea-agents 6.5.0):

  • lib/server/browserType.js:157-158 creates playwright-artifacts-* under os.tmpdir() for every launch and registers it as a temp directory. Because REA passes its own userDataDir, the profile is not registered (159-166).
  • lib/server/utils/processLauncher.js:118-126 removes the registered temp directories after the browser closes. processLauncher.js:162 installs a process exit handler (55-58) that runs killProcessAndCleanup (registered at 170, defined at 211-221). SIGINT/SIGTERM/SIGHUP handlers are installed only when requested (163-168), and REA does not request them.

None of these paths can run after TerminateProcess on Windows or SIGKILL on POSIX.

Additional context

  • Related: #4227 (lifecycle contract audit; this is a concrete instance of cleanup after abrupt termination), #2520 (closed; a different path that left an empty browser profile).
  • Workaround in our wrapper: just before it terminates REA on timeout or cancellation, it records the --user-data-dir of the browser processes descended from REA. Afterwards it removes only those profiles, provided they are new since the run started, no live process command line references them, and their Chrome lockfile can be opened. It then removes the playwright-artifacts-* directory closest in creation time (within 5 s, matched one-to-one). If it cannot list processes, it falls back to "new and unused" and warns. This works, but ownership is still inferred from outside. Only REA can prove which directories it owns.
  • Proposed checks (not run): repeat on Linux and macOS with kill -9; confirm that Ctrl+C in an interactive console removes both directories through withCommandCancellation.
Originally created by @ethen-carter on GitHub (Oct 17, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/4659 ### Before submitting - [x] I searched the open and closed issues for a related report. - [x] I removed credentials, tokens, private paths, and sensitive target data. ### Area Process capture or runtime observation ### Observed behavior In launch mode, `capture-browser-scenario` creates two directories in the OS temp directory: a Chrome profile `rea-browser-scenario-*` and a Playwright `playwright-artifacts-*`. Both are removed only by in-process cleanup. When the REA process is terminated before it completes, both remain, and nothing removes them later. The leftover profile is a full Chrome profile, about 10 MB even for a one-line local page. It includes `Default/History`, `Default/Network/Cookies`, `Default/Cache`, and `Default/Local Storage` of the visited origin. On the reporter's machine, `%TEMP%` held 14 `rea-browser-scenario-*` profiles (about 14 MB each) and 40 empty `playwright-artifacts-*` directories. They came from earlier REA runs that a wrapper or its parent app ended early: timeouts, cancellations, and a force-closed parent app. I did not match each folder to a specific run. I also did not establish why there were more artifacts directories than profiles. ### Reproduction Windows 11, rea-agents 6.5.0 from npm, Chrome 155.0.8059.41. Reproduced 3/3. A small Node harness performed these steps: 1. Set `TEMP` and `TMP` to an empty directory for the REA process, so that only its own temp directories are observed. 2. Serve a one-line HTML page on `http://127.0.0.1:<port>/`. 3. Run `node <rea-agents>/scripts/rea.mjs capture-browser-scenario scenario.json --format json` with the scenario below. 4. Wait until `<TEMP>\rea-browser-scenario-*\lockfile` exists, which took about 1.3 s, then wait 3 s more. 5. `taskkill /PID <rea-pid> /T /F`. REA exits with code 1. 6. After 5 s, list `<TEMP>` and the processes whose command line references it. ```json { "browser": { "mode": "launch", "executable_path": "C:/Program Files/Google/Chrome/Application/chrome.exe", "headless": true }, "start_url": { "url": "http://127.0.0.1:<port>/", "query": [] }, "actions": [{ "step_id": "wait", "action": "wait_for_timeout", "duration_ms": 60000 }], "capture": { "events": ["console"], "at_end": ["url"] } } ``` Control, reproduced 2/2: the same scenario with `duration_ms: 1000` completes with exit code 0 and leaves no directories. ### Expected behavior I am not asking REA to run cleanup after it has been killed. I am asking that a later REA run can reclaim what a killed run left behind, and that wrappers have a way to cancel REA that runs the existing cleanup. Any of the following would address it: 1. **Reclaim stale scenario directories when a new run starts**, with proof of ownership. For example, keep both directories under one REA-owned run root that records an owner token. Remove a root only when its owner is provably gone and the Chrome profile lock is not held. Never infer ownership from the `playwright-artifacts-*` name alone, because other Playwright users on the same machine create directories with that prefix. 2. **Put Playwright's temp directories inside that run root.** Playwright creates `playwright-artifacts-*` under `os.tmpdir()` for every launch and has no option to place it elsewhere. A supervised driver process whose `TEMP`/`TMP`/`TMPDIR` points into the run root would make both directories reclaimable together. 3. **Offer a cancellation channel for one-shot CLI commands that also works on Windows.** One example is treating stdin EOF as cancellation, as the MCP server already does. Then wrappers could stop a run through the existing abort-and-cleanup path instead of terminating the process. 4. **Document the limitation** in `docs/browser-scenario-contract.md`. It currently says the launch-mode profile is "closed and deleted during cleanup", but abrupt termination skips that cleanup and the profile keeps cookies and history. A process-local `exit` handler or a watchdog child would not cover `taskkill /T /F`, because the tree kill ends those too. ### Impact and scope - Every launch-mode scenario that ends by termination leaves about 10–14 MB plus cookies, history, cache, and local storage of the analyzed origins in the user's temp directory, with no later cleanup by REA. - On Windows, a parent has no ordinary way to request graceful cancellation from a Node CLI. In this environment, `child.kill("SIGTERM")` and `child.kill("SIGINT")` from Node ended the child without running its signal handlers or its `exit` event (1/1 each). So the SIGINT/SIGTERM path in `withCommandCancellation` cannot be reached by a wrapper, and every wrapper timeout or cancellation on Windows leaks these directories. - Inference, not tested: if a scenario signs in with `secret` values, the resulting session cookies could stay in the leftover profile. Not tested: Linux and macOS (no Chrome was available in the WSL environment here), Ctrl+C in an interactive console, headed mode, the MCP `capture_browser_scenario` tool with the server process terminated, and other Playwright-based REA commands. ### REA version rea-agents 6.5.0 (npm), with its installed playwright-core 1.58.2. Source references below are from the 6.5.0 source archive. ### Runtime and platform Windows 11 Pro 10.0.26200, x64; Node 24.16.0; Chrome 155.0.8059.41 ### Target and artifact context One-line local HTML page served on `127.0.0.1`; not a binary target ### Interface CLI ### Earliest failure stage Cleanup, cancellation, or lifecycle ### Relevant output or evidence Harness output for one terminated run (user path redacted; the other two runs were identical except for the random suffixes): ```text browser_ready=true after_ms=1300 before_kill [{"name":"playwright-artifacts-fjOw5y","entries":0,"bytes":0,"lockfile":false}, {"name":"rea-browser-scenario-zrXlO5","entries":17,"bytes":10268322,"lockfile":true}] rea_exit code=1 signal=null left_after_5s [{"name":"playwright-artifacts-fjOw5y","entries":0,"bytes":0,"lockfile":false}, {"name":"rea-browser-scenario-zrXlO5","entries":16,"bytes":10268322,"lockfile":false}] processes_referencing_temp=0 ``` Control run: ```text rea_exit code=0 signal=null left_after_5s [] processes_referencing_temp=0 ``` Leftover profile, top level of `Default/` (selection): ```text Cache Code Cache History Local Storage Network/Cookies Network/Trust Tokens ``` Signal probe on Windows (Node 24.16.0, parent calls `child.kill(sig)` on a child that registers `process.on(sig)` and `process.on("exit")`): ```text SIGTERM: exit code=null signal=SIGTERM handler_ran=false exit_event_ran=false SIGINT: exit code=null signal=SIGINT handler_ran=false exit_event_ran=false ``` ### Typed outcome or recovery expected The terminated run cannot report anything. A later run that reclaims stale directories could report what it removed or kept in its cleanup details, so that leftovers are visible rather than silent. ### Analysis or suspected cause Source-derived (REA 6.5.0): - `src/browser/PlaywrightScenarioBrowser.ts:642` creates the profile with `mkdtemp(join(tmpdir(), "rea-browser-scenario-"))`. - `PlaywrightScenarioBrowser.ts:646-672` launches it with `launchPersistentContext`, with `handleSIGHUP/SIGINT/SIGTERM: false` (661-663). - The profile is removed only by `rm(profilePath, …)` in the cleanup owner (`PlaywrightScenarioBrowser.ts:685-689`) or on launch failure (673-683). - `src/cliBrowserScenarioCommands.ts:34-36` and `62-64` run the capture inside `withCommandCancellation`, then `provider.close()` in `finally`. `src/cli/commandCancellation.ts:15-24` maps SIGINT/SIGTERM to an abort. - In `src/`, the string `rea-browser-scenario-` appears only at line 642. I found no startup sweep or later reclamation. - `src/main.ts:32-36`: the MCP server also shuts down on stdin `end`/`close`. One-shot CLI commands have no equivalent. Source-derived (playwright-core 1.58.2, as installed with rea-agents 6.5.0): - `lib/server/browserType.js:157-158` creates `playwright-artifacts-*` under `os.tmpdir()` for every launch and registers it as a temp directory. Because REA passes its own `userDataDir`, the profile is not registered (159-166). - `lib/server/utils/processLauncher.js:118-126` removes the registered temp directories after the browser closes. `processLauncher.js:162` installs a process `exit` handler (`55-58`) that runs `killProcessAndCleanup` (registered at `170`, defined at `211-221`). SIGINT/SIGTERM/SIGHUP handlers are installed only when requested (163-168), and REA does not request them. None of these paths can run after `TerminateProcess` on Windows or `SIGKILL` on POSIX. ### Additional context - Related: #4227 (lifecycle contract audit; this is a concrete instance of cleanup after abrupt termination), #2520 (closed; a different path that left an empty browser profile). - Workaround in our wrapper: just before it terminates REA on timeout or cancellation, it records the `--user-data-dir` of the browser processes descended from REA. Afterwards it removes only those profiles, provided they are new since the run started, no live process command line references them, and their Chrome `lockfile` can be opened. It then removes the `playwright-artifacts-*` directory closest in creation time (within 5 s, matched one-to-one). If it cannot list processes, it falls back to "new and unused" and warns. This works, but ownership is still inferred from outside. Only REA can prove which directories it owns. - Proposed checks (not run): repeat on Linux and macOS with `kill -9`; confirm that Ctrl+C in an interactive console removes both directories through `withCommandCancellation`.
Author
Owner

@aron-intframe commented on GitHub (Oct 17, 2026):

Linux results for the scope you marked untested, plus a source-backed cause for the artifacts-vs-profiles count mismatch.

Environment: Ubuntu 24.04.4, Linux 6.8.0, Node v24.21.0, rea-agents@6.5.0 from npm with its bundled playwright-core 1.58.2, Playwright's Chromium build 1243 (Google Chrome for Testing 153.0.8010.12), headless. Harness mirrors yours: TMPDIR/TEMP/TMP point at an empty directory, one-line page on 127.0.0.1, wait_for_timeout: 60000, wait until a rea-browser-scenario-* directory has content (~0.7 s), +3 s, then process.kill(reaPid, SIG) on the REA process only (no tree kill), list after 5 s.

1. On Linux the leak is SIGKILL-only; SIGTERM and SIGINT do reach withCommandCancellation

##### CONTROL (duration_ms 1000)
signal=CONTROL browser_ready=true after_ms=704
rea_exit code=0 signal=null
left_after_5s []
processes_referencing_temp=0

##### SIGTERM
signal=SIGTERM browser_ready=true after_ms=705
before_kill  [{"name":"org.chromium.Chromium.uznbZY","entries":2,"bytes":0},
              {"name":"playwright-artifacts-IlHTsC","entries":0,"bytes":0},
              {"name":"rea-browser-scenario-50nh4W","entries":163,"bytes":2901002}]
rea_exit code=143 signal=null
left_after_5s []
processes_referencing_temp=0

##### SIGINT
rea_exit code=130 signal=null
left_after_5s []

##### SIGKILL
signal=SIGKILL browser_ready=true after_ms=810
before_kill  [{"name":"org.chromium.Chromium.Aa5KYS","entries":2,"bytes":0},
              {"name":"playwright-artifacts-fhxqXX","entries":0,"bytes":0},
              {"name":"rea-browser-scenario-YWp6RI","entries":163,"bytes":2901004}]
rea_exit code=null signal=SIGKILL
left_after_5s [{"name":".org.chromium.Chromium.pxIxGU","entries":0,"bytes":0},
               {"name":"playwright-artifacts-fhxqXX","entries":0,"bytes":0},
               {"name":"rea-browser-scenario-YWp6RI","entries":164,"bytes":2776102}]
processes_referencing_temp=0
termination runs REA exit left in TEMP
none (control) 1/1 code=0 nothing
SIGTERM 3/3 code=143 nothing
SIGINT 3/3 code=130 nothing
SIGKILL 5/5 signal=SIGKILL rea-browser-scenario-* (164 entries, 2.8 MB) + empty playwright-artifacts-*

143 and 130 are withCommandCancellation's own exit codes, so on POSIX a wrapper already has the graceful channel Windows lacks: proposal 3 is a Windows-specific gap, not a general one. Proposal 1 is not Windows-specific. kill -9, an OOM kill, or timeout -s KILL leak on Linux exactly as taskkill /T /F does, and nothing later reclaims them. A full successful run in the same TEMP leaves the previous leftovers untouched:

after kill -9:      [playwright-artifacts-WiGoXL rea-browser-scenario-7aiiFl]
second run exit=0
after a full OK run:[playwright-artifacts-WiGoXL rea-browser-scenario-7aiiFl]

Consistent with the shipped bundle: rea-browser-scenario- occurs only at the three mkdtemp sites in dist/, with no sweep. One Linux difference worth recording: after the parent is SIGKILLed, Chromium exits on its own (processes_referencing_temp=0), so no orphan keeps the profile alive here. In 1 of 5 SIGKILL runs a third, dot-prefixed .org.chromium.Chromium.* stub also stayed behind.

2. The ownership proof cannot be lockfile on POSIX

There is no lockfile in the Linux profile. Chrome writes three symlinks instead, observed during a live run:

SingletonCookie -> 7335955855454980762
SingletonLock   -> hel1-core-2469553        # <hostname>-<pid>
SingletonSocket -> /tmp/<temp>/org.chromium.Chromium.8zE5Zl/SingletonSocket

Chromium removes all three on its own exit, so a leaked profile carries no lock at all; ls <profile>/SingletonLock after the kill returns "No such file or directory". "Lock not held" therefore does mean "reclaimable" on Linux, but only with an age guard, because a live run has no SingletonLock for its first ~0.7 s and a concurrent sweeper could delete a sibling's live profile. The owner token in your proposal 1 closes that race; SingletonLock's <hostname>-<pid> is a usable secondary check. The leftover does carry browsing state here too: Default/History 160 KB and Default/Cookies 20 KB, note Default/Cookies on Linux rather than Default/Network/Cookies, ~2.8 MB total for the one-line page.

3. Why there are more playwright-artifacts-* than profiles

A clean failure path leaks one with no profile at all. No kill involved, just a scenario whose executable_path does not exist:

run1 exit=1 leftover=[playwright-artifacts-MvmW0q]
run2 exit=1 leftover=[playwright-artifacts-rVIrIq]
run3 exit=1 leftover=[playwright-artifacts-5SBXED]

3/3, each 4 KB and empty. Cause in playwright-core 1.58.2: _prepareToLaunch creates the artifacts directory and pushes it onto tempDirectories at lib/server/browserType.js:157-158, then throws Failed to launch chromium because executable doesn't exist at ... at lib/server/browserType.js:177-178. tempDirectories is only ever handed to launchProcess (lib/server/utils/processLauncher.js:104), which owns both removal paths: cleanup() at 118-126 and killProcessAndCleanup() at 210-221. When the throw happens before launchProcess is reached, nothing in the process ever learns that the directory exists. REA's own launch-failure branch does delete its profile (rm(profilePath, ...), src 673-683), so every failed launch contributes one empty artifacts directory and zero profiles. 40 artifacts against 14 profiles fits that shape better than 40 terminated runs.

Two consequences. The documentation fix (4) should cover a non-terminated case as well, since exit code 1 on a bad executable_path also leaves a directory behind. And your wrapper's "closest in creation time, matched one-to-one" pairing can mis-pair, because a failed run contributes an artifacts directory that has no profile to pair with. Of the four options, proposal 2 (a driver whose TMPDIR points inside the run root) is the only one that also contains this failure-path leak, as it does not depend on any REA or Playwright cleanup running.

Not tested here: macOS, headed mode, Ctrl+C in an interactive console, the MCP tool path, and real Google Chrome rather than Playwright's Chromium build.

We hit the other side of this shape, where a wrapper's timeout kill ends the agent but not what it spawned; the field note is item 8 in our repo (ours): https://github.com/aron-intframe/agent-guards

<!-- gh-comment-id:6229113117 --> @aron-intframe commented on GitHub (Oct 17, 2026): Linux results for the scope you marked untested, plus a source-backed cause for the artifacts-vs-profiles count mismatch. Environment: Ubuntu 24.04.4, Linux 6.8.0, Node v24.21.0, `rea-agents@6.5.0` from npm with its bundled `playwright-core` 1.58.2, Playwright's Chromium build 1243 (`Google Chrome for Testing 153.0.8010.12`), headless. Harness mirrors yours: `TMPDIR`/`TEMP`/`TMP` point at an empty directory, one-line page on `127.0.0.1`, `wait_for_timeout: 60000`, wait until a `rea-browser-scenario-*` directory has content (~0.7 s), +3 s, then `process.kill(reaPid, SIG)` on the REA process only (no tree kill), list after 5 s. ## 1. On Linux the leak is SIGKILL-only; SIGTERM and SIGINT do reach `withCommandCancellation` ```text ##### CONTROL (duration_ms 1000) signal=CONTROL browser_ready=true after_ms=704 rea_exit code=0 signal=null left_after_5s [] processes_referencing_temp=0 ##### SIGTERM signal=SIGTERM browser_ready=true after_ms=705 before_kill [{"name":"org.chromium.Chromium.uznbZY","entries":2,"bytes":0}, {"name":"playwright-artifacts-IlHTsC","entries":0,"bytes":0}, {"name":"rea-browser-scenario-50nh4W","entries":163,"bytes":2901002}] rea_exit code=143 signal=null left_after_5s [] processes_referencing_temp=0 ##### SIGINT rea_exit code=130 signal=null left_after_5s [] ##### SIGKILL signal=SIGKILL browser_ready=true after_ms=810 before_kill [{"name":"org.chromium.Chromium.Aa5KYS","entries":2,"bytes":0}, {"name":"playwright-artifacts-fhxqXX","entries":0,"bytes":0}, {"name":"rea-browser-scenario-YWp6RI","entries":163,"bytes":2901004}] rea_exit code=null signal=SIGKILL left_after_5s [{"name":".org.chromium.Chromium.pxIxGU","entries":0,"bytes":0}, {"name":"playwright-artifacts-fhxqXX","entries":0,"bytes":0}, {"name":"rea-browser-scenario-YWp6RI","entries":164,"bytes":2776102}] processes_referencing_temp=0 ``` | termination | runs | REA exit | left in TEMP | |---|---|---|---| | none (control) | 1/1 | `code=0` | nothing | | SIGTERM | 3/3 | `code=143` | nothing | | SIGINT | 3/3 | `code=130` | nothing | | SIGKILL | 5/5 | `signal=SIGKILL` | `rea-browser-scenario-*` (164 entries, 2.8 MB) + empty `playwright-artifacts-*` | 143 and 130 are `withCommandCancellation`'s own exit codes, so on POSIX a wrapper already has the graceful channel Windows lacks: proposal 3 is a Windows-specific gap, not a general one. Proposal 1 is not Windows-specific. `kill -9`, an OOM kill, or `timeout -s KILL` leak on Linux exactly as `taskkill /T /F` does, and nothing later reclaims them. A full successful run in the same TEMP leaves the previous leftovers untouched: ```text after kill -9: [playwright-artifacts-WiGoXL rea-browser-scenario-7aiiFl] second run exit=0 after a full OK run:[playwright-artifacts-WiGoXL rea-browser-scenario-7aiiFl] ``` Consistent with the shipped bundle: `rea-browser-scenario-` occurs only at the three `mkdtemp` sites in `dist/`, with no sweep. One Linux difference worth recording: after the parent is SIGKILLed, Chromium exits on its own (`processes_referencing_temp=0`), so no orphan keeps the profile alive here. In 1 of 5 SIGKILL runs a third, dot-prefixed `.org.chromium.Chromium.*` stub also stayed behind. ## 2. The ownership proof cannot be `lockfile` on POSIX There is no `lockfile` in the Linux profile. Chrome writes three symlinks instead, observed during a live run: ```text SingletonCookie -> 7335955855454980762 SingletonLock -> hel1-core-2469553 # <hostname>-<pid> SingletonSocket -> /tmp/<temp>/org.chromium.Chromium.8zE5Zl/SingletonSocket ``` Chromium removes all three on its own exit, so a leaked profile carries no lock at all; `ls <profile>/SingletonLock` after the kill returns "No such file or directory". "Lock not held" therefore does mean "reclaimable" on Linux, but only with an age guard, because a live run has no `SingletonLock` for its first ~0.7 s and a concurrent sweeper could delete a sibling's live profile. The owner token in your proposal 1 closes that race; `SingletonLock`'s `<hostname>-<pid>` is a usable secondary check. The leftover does carry browsing state here too: `Default/History` 160 KB and `Default/Cookies` 20 KB, note `Default/Cookies` on Linux rather than `Default/Network/Cookies`, ~2.8 MB total for the one-line page. ## 3. Why there are more `playwright-artifacts-*` than profiles A clean failure path leaks one with no profile at all. No kill involved, just a scenario whose `executable_path` does not exist: ```text run1 exit=1 leftover=[playwright-artifacts-MvmW0q] run2 exit=1 leftover=[playwright-artifacts-rVIrIq] run3 exit=1 leftover=[playwright-artifacts-5SBXED] ``` 3/3, each 4 KB and empty. Cause in playwright-core 1.58.2: `_prepareToLaunch` creates the artifacts directory and pushes it onto `tempDirectories` at `lib/server/browserType.js:157-158`, then throws `Failed to launch chromium because executable doesn't exist at ...` at `lib/server/browserType.js:177-178`. `tempDirectories` is only ever handed to `launchProcess` (`lib/server/utils/processLauncher.js:104`), which owns both removal paths: `cleanup()` at 118-126 and `killProcessAndCleanup()` at 210-221. When the throw happens before `launchProcess` is reached, nothing in the process ever learns that the directory exists. REA's own launch-failure branch does delete its profile (`rm(profilePath, ...)`, src 673-683), so every failed launch contributes one empty artifacts directory and zero profiles. 40 artifacts against 14 profiles fits that shape better than 40 terminated runs. Two consequences. The documentation fix (4) should cover a non-terminated case as well, since exit code 1 on a bad `executable_path` also leaves a directory behind. And your wrapper's "closest in creation time, matched one-to-one" pairing can mis-pair, because a failed run contributes an artifacts directory that has no profile to pair with. Of the four options, proposal 2 (a driver whose `TMPDIR` points inside the run root) is the only one that also contains this failure-path leak, as it does not depend on any REA or Playwright cleanup running. Not tested here: macOS, headed mode, Ctrl+C in an interactive console, the MCP tool path, and real Google Chrome rather than Playwright's Chromium build. We hit the other side of this shape, where a wrapper's timeout kill ends the agent but not what it spawned; the field note is item 8 in our repo (ours): https://github.com/aron-intframe/agent-guards
Author
Owner

@morluto commented on GitHub (Oct 17, 2026):

Documentation clarification published in d09be9cfa2 on main. This is not a recovery implementation or a claim that the issue is fixed.

Current source still launches the profile with process-local cleanup and has no later reclamation. The installed locked Playwright1.58.2 source creates its artifacts directory before checking the selected executable. A fresh Node24.18/Darwin arm64 public CLI missing-executable case confirmed exit1, no remaining REA profile, and one empty playwright-artifacts directory in the experiment's private temporary root. The experiment then removed only its own root. No browser process/navigation occurred; forced termination and Windows signals were not rerun.

The contract now states the abrupt-termination profile/data retention risk, the missing-executable artifacts leak, absence of later cleanup, and the distinction between POSIX graceful signals and Windows process termination. It explicitly rejects prefix/time matching as ownership proof. docs:check with five README checks and normal hooks passed.

Durable run-root ownership, containing opaque SDK artifacts, safe reclamation while owners/locks may still be live, cleanup retry, Windows qualification, and a wrapper cancellation channel remain open design/integration work. Process-local handlers or an age-based sweep would not establish those invariants. The issue remains open.

<!-- gh-comment-id:6230375949 --> @morluto commented on GitHub (Oct 17, 2026): Documentation clarification published in d09be9cfa2ee988bfba04b80f7b8d38297b7903c on main. This is **not** a recovery implementation or a claim that the issue is fixed. Current source still launches the profile with process-local cleanup and has no later reclamation. The installed locked Playwright1.58.2 source creates its artifacts directory before checking the selected executable. A fresh Node24.18/Darwin arm64 public CLI missing-executable case confirmed exit1, no remaining REA profile, and one empty playwright-artifacts directory in the experiment's private temporary root. The experiment then removed only its own root. No browser process/navigation occurred; forced termination and Windows signals were not rerun. The contract now states the abrupt-termination profile/data retention risk, the missing-executable artifacts leak, absence of later cleanup, and the distinction between POSIX graceful signals and Windows process termination. It explicitly rejects prefix/time matching as ownership proof. docs:check with five README checks and normal hooks passed. Durable run-root ownership, containing opaque SDK artifacts, safe reclamation while owners/locks may still be live, cleanup retry, Windows qualification, and a wrapper cancellation channel remain open design/integration work. Process-local handlers or an age-based sweep would not establish those invariants. The issue remains open.
Author
Owner

@pablodiazjorge commented on GitHub (Oct 17, 2026):

Windows measurements for the scope you left unverified, on main 149a0fc06 (Windows 11 Pro 26300 x64, Node 22.22.3, Chrome 155.0.8059.80). Harness as in the report: TEMP/TMP on an empty folder, a one-line page on 127.0.0.1, wait_for_timeout: 60000, and REA started with CREATE_NEW_PROCESS_GROUP so a wrapper can address it.

Termination REA exit Left in TEMP after 5 s
none (1 s scenario, control) 0 nothing from REA
TerminateProcess (what child.kill() does on Windows) 1 rea-browser-scenario-* (232 entries, 10.1 MB) and playwright-artifacts-*
CTRL_BREAK_EVENT to the process group 0xC000013A the same two directories

Ctrl+Break is the one console signal a Windows parent can send to a child in its own process group, and Node delivers it as SIGBREAK; with no listener, the default action ends the process without cleanup. As a local experiment I made withCommandCancellation also listen for SIGBREAK (abort, exit code 149). With that change, CTRL_BREAK_EVENT ran the existing abort-and-cleanup path in 2 of 2 runs: exit 149, both directories removed, and a typed cancelled error with cleanup: "complete" and the partial observation.

That only gives Windows wrappers a channel into the cleanup REA already has; it does not touch abrupt termination or later reclamation, which your comment keeps as run-root ownership work. Would a PR for just that piece be useful (the listener in src/cli/commandCancellation.ts, its host type, a test with an injected host, and a line in docs/browser-scenario-contract.md), or would you rather keep it inside the wrapper-channel design?

<!-- gh-comment-id:6233632003 --> @pablodiazjorge commented on GitHub (Oct 17, 2026): Windows measurements for the scope you left unverified, on main 149a0fc06 (Windows 11 Pro 26300 x64, Node 22.22.3, Chrome 155.0.8059.80). Harness as in the report: `TEMP`/`TMP` on an empty folder, a one-line page on `127.0.0.1`, `wait_for_timeout: 60000`, and REA started with `CREATE_NEW_PROCESS_GROUP` so a wrapper can address it. | Termination | REA exit | Left in TEMP after 5 s | |---|---|---| | none (1 s scenario, control) | 0 | nothing from REA | | `TerminateProcess` (what `child.kill()` does on Windows) | 1 | `rea-browser-scenario-*` (232 entries, 10.1 MB) and `playwright-artifacts-*` | | `CTRL_BREAK_EVENT` to the process group | `0xC000013A` | the same two directories | Ctrl+Break is the one console signal a Windows parent can send to a child in its own process group, and Node delivers it as `SIGBREAK`; with no listener, the default action ends the process without cleanup. As a local experiment I made `withCommandCancellation` also listen for `SIGBREAK` (abort, exit code 149). With that change, `CTRL_BREAK_EVENT` ran the existing abort-and-cleanup path in 2 of 2 runs: exit 149, both directories removed, and a typed `cancelled` error with `cleanup: "complete"` and the partial observation. That only gives Windows wrappers a channel into the cleanup REA already has; it does not touch abrupt termination or later reclamation, which your comment keeps as run-root ownership work. Would a PR for just that piece be useful (the listener in `src/cli/commandCancellation.ts`, its host type, a test with an injected host, and a line in `docs/browser-scenario-contract.md`), or would you rather keep it inside the wrapper-channel design?
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#2429
No description provided.