Summary
browser4-cli records the backend it starts in <state dir>/cli-managed-processes.json and later stops it by PID. That PID is frequently not the JVM that owns the port:
- it is resolved as the last descendant of the launcher process (a heuristic), which for a launcher that fans out into JVM + Chrome + helpers can be a Chrome renderer — observed on a real run below;
- even when it is the right process, that process can exit and its PID be reused by an unrelated one before
stop runs.
The stop path verifies the recorded PID (java + Browser4 markers in its command line) and silently downgrades a mismatch to "already stopped", so stop can report success while the backend keeps listening, the registry entry is never repaired, and the next run repeats it.
Found while testing the development-mode workspace isolation work (3d6d983294, branch 4.14.x), where it also broke the e2e harness's own in-process stop.
Environment
|
|
| Branch / commit |
4.14.x @ 3d6d983294 |
| OS / CPU |
Windows 11 |
| JDK |
GraalVM 25.0.3 |
| CLI |
browser4-cli built from source (./b4w.ps1) |
| Command |
./b4w.ps1 open --headless https://example.com (development mode → backend port 8282) |
Evidence
1. The registry points at a Chrome renderer, not the backend
# ~/.browser4/workspaces/Browser4-4.14-feat-06d043d8/cli-managed-processes.json
{ "processes": [ { "pid": 34592, "baseUrl": "http://127.0.0.1:8282", "port": 8282,
"startedAt": "2026-09-15T04:12:54Z", "version": "local",
"workspaceRoot": "D:/workspace/Browser4/Browser4-4.14-feat" } ] }
PS> Get-CimInstance Win32_Process -Filter "ProcessId=34592"
PID 34592 : chrome.exe
"C:\Program Files\Google\Chrome\Application\chrome.exe" --type=renderer
--user-data-dir="C:\Users\pereg\.browser4\workspaces\Browser4-4.14-feat-06d043d8\app-data\browser\chrome\default\PULSAR_CHROME" ...
PS> Get-NetTCPConnection -State Listen -LocalPort 8282
LocalPort 8282 OwningProcess 3956
PS> Get-CimInstance Win32_Process -Filter "ProcessId=3956"
PID 3956 : java.exe
"...\browser4-bundle-runtime-windows-x64\runtime\bin\java.exe"
@C:\Users\pereg\AppData\Local\Temp\browser4-argfile-29252-1789445567354618500\java-args.txt
Three different PIDs are involved — the CLI that wrote the argfile (29252, encoded in the argfile name), the launcher handed to spawn() and the JVM that owns the port (3956) — and the registry ended up with none of them.
2. The recorded PID is a heuristic, not the port owner
daemon.rs:6960 (and the provisional write at 6904) pushes child.id() through resolve_managed_server_pid → resolve_windows_managed_server_pid (daemon.rs:7250, 7263), which walks the process tree from the launcher and returns $ids[-1] — the last descendant enumerated by WMI. Nothing ties that choice to the process that binds the port, so a launcher that fans out (JVM + Chrome renderer/GPU/utility + helpers) makes "the managed server" whichever descendant came last. Here it was a renderer of the browser the backend itself launched.
3. Verifying the recorded PID cannot work after launch
Stop verifies entries with is_browser4_server_process (managed_processes.rs:1087) = "process is java/javaw" and command_line_matches_browser4_server (managed_processes.rs:785). The backend is launched as java @<temp argfile> (written by write_java_argfile, daemon.rs:6054), and that argfile is gone seconds later:
argfile: C:\Users\pereg\AppData\Local\Temp\browser4-argfile-29252-…\java-args.txt
exists: False
expand_windows_argfile (managed_processes.rs:1301) therefore cannot recover the main class, and a live backend looks like a process with no Browser4 marker at all. In this run recognition only succeeded because the runtime-bundle path happens to contain browser4; an install under a path without that substring (e.g. D:\apps\b4\…) would leave the backend unrecognizable to every sweep.
4. What stop actually does / prints
PS> ./b4w.ps1 stop
🛑 Stopping the Browser4 server(s) of this workspace ...
Stopped Browser4 process(es): 3956
Already stopped Browser4 process(es): 34592 ← the registry entry (a Chrome PID): silently skipped
Forced Browser4 process(es) after graceful timeout: 3956
Fallback-killed Browser4 backend process(es): 3956
Browser4 server stopped.
The registry path contributed nothing (shutdown_managed_server_processes_matching, managed_processes.rs:219, takes the 247 "PID may have been reused by a non-Browser4 process; treat as stale" branch and reports missing_pids). The JVM died only because the fallback sweep matched by luck.
When the fallback cannot match, the backend survives a "successful" stop: managed_server_ports_for_sweep() (managed_processes.rs:993) only covers the ports recorded in the registry plus 8182/18182, so a backend started on an explicit --server http://127.0.0.1:<anything else> is outside it.
5. Independent sighting: the e2e harness
That is exactly how the e2e suite exposed this. The harness starts the backend on an arbitrary free port (--server=http://127.0.0.1:34879 open …) and stops it in-process through this same code (browser4_cli::managed_processes::stop_browser4_server_forcibly). The stop did not take effect, and the later test_e2e_kill_all_no_running_processes scenario found a live backend the harness believed stopped — failing in 3 consecutive full runs of the default suite before being worked around in the test itself.
Impact
- Silent no-op stop:
stop prints "Browser4 server stopped." while the JVM keeps the port; the stale registry entry stays and the next stop repeats the same output. A following open can then adopt (or collide with) a backend nobody tracks.
- Wrong-process risk in the other direction: the check accepts a reused PID that happens to be java with a Browser4-looking command line — the registry path can kill a process that is not this workspace's backend.
- Tooling inherits it: anything that stops the backend through the library (e2e harness,
bin/test-production.ps1, scripts) has the same blind spot.
- Diagnostics are misleading: "Already stopped Browser4 process(es): N" is printed for a process that was never ours.
Root cause
- Recording — the registry stores a PID selected by an "last descendant" heuristic instead of the process that owns the listening port, together with no identity that survives PID reuse (
ManagedServerProcess, managed_processes.rs:38: pid / baseUrl / port / jarPath / startedAt / version / workspaceRoot).
- Verifying — the stop path combines PID liveness (
is_process_running, managed_processes.rs:1334) with a command-line check that a normally launched backend cannot pass once its @argfile is gone, and turns the mismatch into a silent "already stopped" instead of a report + repair.
Suggested fix
- Resolve the managed PID from the listening port (
port → owning PID, polled until the port is up) instead of $ids[-1]; that is the process stop must kill.
- Store a strong identity next to the PID and verify it on stop: process creation time + executable path + port (+ the
workspaceRoot token added in 3d6d983294). A mismatch must mean "stale entry", not "already stopped".
- Make the backend recognizable without the argfile: put the marker in an argument that stays on the command line (e.g.
-Dbrowser4.managed=<uuid> passed outside the argfile), or keep the argfile for the process lifetime and delete it on stop.
- Fail loudly: when a registry entry cannot be verified or killed, say so ("registry entry stale: pid N is not a Browser4 backend") and drop the entry, rather than reporting "no server was running".
- Extend the port sweep to every port the workspace registry has ever recorded, and to the resolved
--server port (development-mode stop already does the latter since 3d6d983294).
References
cli/browser4-cli/src/managed_processes.rs: 38 (registry entry), 69 (register), 219/247 (stop matching + reuse branch), 785/802/1087/1301 (identity checks), 993 (sweep ports), 1334 (is_process_running)
cli/browser4-cli/src/daemon.rs: 6054 (argfile), 6904/6960 (registration), 7250/7263 (PID resolution)
- Related commit:
3d6d983294 — development-mode isolation; its port sweep now kills JVM processes only, which is what made the registry path's blind spot observable.
Summary
browser4-clirecords the backend it starts in<state dir>/cli-managed-processes.jsonand later stops it by PID. That PID is frequently not the JVM that owns the port:stopruns.The stop path verifies the recorded PID (java + Browser4 markers in its command line) and silently downgrades a mismatch to "already stopped", so
stopcan report success while the backend keeps listening, the registry entry is never repaired, and the next run repeats it.Found while testing the development-mode workspace isolation work (
3d6d983294, branch4.14.x), where it also broke the e2e harness's own in-process stop.Environment
4.14.x@3d6d983294browser4-clibuilt from source (./b4w.ps1)./b4w.ps1 open --headless https://example.com(development mode → backend port 8282)Evidence
1. The registry points at a Chrome renderer, not the backend
Three different PIDs are involved — the CLI that wrote the argfile (
29252, encoded in the argfile name), the launcher handed tospawn()and the JVM that owns the port (3956) — and the registry ended up with none of them.2. The recorded PID is a heuristic, not the port owner
daemon.rs:6960(and the provisional write at6904) pusheschild.id()throughresolve_managed_server_pid→resolve_windows_managed_server_pid(daemon.rs:7250,7263), which walks the process tree from the launcher and returns$ids[-1]— the last descendant enumerated by WMI. Nothing ties that choice to the process that binds the port, so a launcher that fans out (JVM + Chrome renderer/GPU/utility + helpers) makes "the managed server" whichever descendant came last. Here it was a renderer of the browser the backend itself launched.3. Verifying the recorded PID cannot work after launch
Stop verifies entries with
is_browser4_server_process(managed_processes.rs:1087) = "process is java/javaw" andcommand_line_matches_browser4_server(managed_processes.rs:785). The backend is launched asjava @<temp argfile>(written bywrite_java_argfile,daemon.rs:6054), and that argfile is gone seconds later:expand_windows_argfile(managed_processes.rs:1301) therefore cannot recover the main class, and a live backend looks like a process with no Browser4 marker at all. In this run recognition only succeeded because the runtime-bundle path happens to containbrowser4; an install under a path without that substring (e.g.D:\apps\b4\…) would leave the backend unrecognizable to every sweep.4. What
stopactually does / printsThe registry path contributed nothing (
shutdown_managed_server_processes_matching,managed_processes.rs:219, takes the247"PID may have been reused by a non-Browser4 process; treat as stale" branch and reportsmissing_pids). The JVM died only because the fallback sweep matched by luck.When the fallback cannot match, the backend survives a "successful" stop:
managed_server_ports_for_sweep()(managed_processes.rs:993) only covers the ports recorded in the registry plus8182/18182, so a backend started on an explicit--server http://127.0.0.1:<anything else>is outside it.5. Independent sighting: the e2e harness
That is exactly how the e2e suite exposed this. The harness starts the backend on an arbitrary free port (
--server=http://127.0.0.1:34879 open …) and stops it in-process through this same code (browser4_cli::managed_processes::stop_browser4_server_forcibly). The stop did not take effect, and the latertest_e2e_kill_all_no_running_processesscenario found a live backend the harness believed stopped — failing in 3 consecutive full runs of the default suite before being worked around in the test itself.Impact
stopprints "Browser4 server stopped." while the JVM keeps the port; the stale registry entry stays and the nextstoprepeats the same output. A followingopencan then adopt (or collide with) a backend nobody tracks.bin/test-production.ps1, scripts) has the same blind spot.Root cause
ManagedServerProcess,managed_processes.rs:38: pid / baseUrl / port / jarPath / startedAt / version / workspaceRoot).is_process_running,managed_processes.rs:1334) with a command-line check that a normally launched backend cannot pass once its@argfileis gone, and turns the mismatch into a silent "already stopped" instead of a report + repair.Suggested fix
port → owning PID, polled until the port is up) instead of$ids[-1]; that is the processstopmust kill.workspaceRoottoken added in3d6d983294). A mismatch must mean "stale entry", not "already stopped".-Dbrowser4.managed=<uuid>passed outside the argfile), or keep the argfile for the process lifetime and delete it on stop.--serverport (development-modestopalready does the latter since3d6d983294).References
cli/browser4-cli/src/managed_processes.rs:38(registry entry),69(register),219/247(stop matching + reuse branch),785/802/1087/1301(identity checks),993(sweep ports),1334(is_process_running)cli/browser4-cli/src/daemon.rs:6054(argfile),6904/6960(registration),7250/7263(PID resolution)3d6d983294— development-mode isolation; its port sweep now kills JVM processes only, which is what made the registry path's blind spot observable.