Skip to content

cli: the managed-backend registry can record a non-backend PID, so 'stop' silently leaves the backend running #594

Description

@platonai

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

  1. 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).
  2. 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

  1. 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.
  2. 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".
  3. 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.
  4. 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".
  5. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    CLIbugSomething isn't workingreliabilityrustPull requests that update rust code

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions