Skip to content

fix(F-871): a named profile is no longer walked to -2 by a dead browser's leftovers, and the answer says when it is - #105

Merged
AminDhouib merged 5 commits into
mainfrom
fix/F871-stale-singletonlock-profile-walk
Sep 15, 2026
Merged

AminDhouib merged 5 commits into
mainfrom
fix/F871-stale-singletonlock-profile-walk

Conversation

@AminDhouib

Copy link
Copy Markdown
Member

The defect (F-871)

A NAMED profile silently became a different profile. In CI run 34911829422 (integration Linux) three spawn attempts all passing user_data_dir="ci-warmup" ran on ci-warmup, ci-warmup, then ci-warmup-2 after an F-860 spawn-leak reap. A named profile exists precisely to keep cookies and logins; nothing in the answer said the caller had been handed another one.

Root cause, measured: clone_storage decided "busy" with Path.exists() over SingletonLock/SingletonSocket/SingletonCookie. Chrome's SingletonLock is a DANGLING symlink whose target is the string <hostname>-<pid>, so exists() never saw the one artefact that names an owner, while SingletonSocket points into a per-launch temp dir a KILLED Chrome never cleans, so its target outlives the process and reads as "in use". Full write-up: audit/stage2/finding_F871_stale_singletonlock_walks_named_profile.md.

The fix

New leaf embedded/profile_lock.py, THE one home for interpreting Chrome's profile-hold artefacts: profile_hold(profile_dir) -> Hold(pid, reason) | None reads the lock the way Chromium does (lexists + readlink, split on the last -, host must match, pid must be alive), with the process-table scan as the other witness. On Windows, where Chrome writes no Singleton* and its lockfile is delete-on-close, the scan is the only witness, and a scan that cannot be asked resolves toward "held" (one extra walk is survivable; two browsers on one profile is not). When a walk does happen, spawn_browser's answer now leads its warning with the substitution and the reason, and profile_selection carries the requested dir, the walked-to dir, and walk_reason. clone_storage.py 1057 → 1055 (grandfather row ratcheted down); browser_manager.py and process_cleanup.py untouched.

Evidence

  • RED on main: assert 'occupied-2' == 'occupied' (a residual socket walked a free profile) and assert 'occupied' == 'occupied-2' (a live lock did not walk); verified by the reviewer against main's code.
  • Unit lane on the branch: 2416 passed (reviewer, throwaway worktree). Three Opus review rounds: APPROVE.

Not fixed here (named)

  • The reaper still does not delete lock artefacts: Chromium itself unlinks an orphaned lock, and a deleter in a failure handler racing a dying browser would be a weaker second way (finding §4).
  • Chromium's extra "is the pid a Chrome" check is not reproduced; a recycled pid costs one walk.
  • Existing -2 directories are not migrated; the four JS-aspect {"error": ...} returns are F-872's business, not this branch's.

Whether a Chrome profile was busy was decided by `Path.exists()` over
`SingletonLock`, `SingletonSocket` and `SingletonCookie`. None of those names
means what that assumed. Chrome's lock is a SYMLINK whose target is the string
`<hostname>-<pid>` -- a claim about a pid, not a path -- so `exists()`, which
follows symlinks, reported the one artefact that names an owner as ABSENT;
while `SingletonSocket` points into a per-launch /tmp directory that a killed
browser never cleans up, so its target outlived the browser and answered
"busy" forever.

After F-860's reaper killed a Chrome a failed spawn had leaked, the next
`spawn_browser` on the same NAMED profile therefore read the residue as a
running browser and walked the caller to `<name>-2` -- a different, freshly
cloned identity for a profile that exists precisely to keep its cookies and
logins -- and nothing in the answer said so. Measured on the 2.1.5 release
gate (run 34911829422, job 104200794999): three warmup attempts all passing
`user_data_dir="ci-warmup"`, the third running on `ci-warmup-2`.

The question gets one home, `embedded/profile_lock.py`, which reads the lock
the way Chromium's own ParseProcessSingletonLock does: a lock naming a dead
pid is orphaned and holds nothing, exactly as Chrome concludes before
unlinking it and starting. The socket and cookie are not consulted at all --
Chrome writes them after the lock, so a live browser always has one. A live
browser's lock is now visible for the first time as well, so a held profile
can no longer be handed to a second Chrome.

When a walk does happen the answer says so: the selection dict gains
`requested_user_data_dir`, `walked_to` and `walk_reason`, present only when
the caller did not get what they asked for, and surfacing through the existing
`spawn_diagnostics.profile_selection` with no second diagnostics home.

Deliberately NOT done: the reaper still leaves the artefacts alone. Deleting
them would cover only the locks we killed -- not a crash, an OOM kill or a
reboot -- and Chromium already unlinks an orphaned lock, so it would be a
second way to do something already done, from a failure handler racing a
browser that is still dying.

clone_storage.py ratchets DOWN 1057 -> 1055 (cap == actual). Four SOFT
goldens that wrote the literal bytes "lock" into a SingletonLock and called it
a running browser are updated in place, with the reason inline: those bytes
are what Chromium calls an INVALID lockfile. tests/fakes.py gains the ONE
artefact writer (`write_singleton` / `held_profile`).

Finding: audit/stage2/finding_F871_stale_singletonlock_walks_named_profile.md
… the warning

Six nits from the review of ca06c2a, no behaviour outside them.

1. `profile_lock`'s "two witnesses, in order, and no third" was POSIX-only.
   An **On Windows** paragraph now says what is true there: Chrome writes no
   `Singleton*` at all, it takes a `lockfile` with FILE_FLAG_DELETE_ON_CLOSE
   that the kernel removes even on a hard kill, so witness 2 is vacuous and the
   process scan carries the answer alone. Its mere PRESENCE is deliberately not
   promoted to a third witness: a presence test is the exact reasoning this
   finding condemns, and a power loss leaves a `lockfile` behind forever with no
   pid in it to check.

2. The error direction was not uniform. `_pid_alive` counted an unreadable pid
   as ALIVE (toward held) while a FAILING process scan resolved toward free --
   covered by the lock on POSIX, covered by nothing on Windows. `_browser_pids`
   now distinguishes "could not be asked" (`None`) from "asked, nothing running"
   (`()`), and where there is no second witness the former is a
   `Hold(None, ...)`: one extra walk is survivable, two browsers on one profile
   is not. `tests/test_profile_pid_check.py` pinned main's non-uniform answer;
   what it exists to guard (the failure is survived, not raised) is untouched
   and it is now expressed per-platform, with the reasoning in its docstring.

3. `except TypeError: continue` was the str-vs-Path signature probe carried over
   from main, and it silently turned a TypeError raised INSIDE a working scan
   into "no browsers". The probe is kept, the exception is remembered, and
   exhausting both forms now warns and returns "never really asked".

4. "One home" was overstated: `clone_storage._REGENERABLE_PROFILE_NAMES` still
   names all three artefacts (plus `lockfile`) for the different question of
   what a clone must not copy and what a trim may delete. The module docstring
   and the CLAUDE.md row now claim the one home for INTERPRETING them.

5. `walk_reason` sat quietly in the selection dict beside an unconditional,
   prominent `warning` about the named profile persisting -- a model reads the
   loud field and never learns it was handed a different profile. The reason is
   now PREPENDED to that same warning ("NOT the profile you asked for: ... is in
   use (...), so this spawn got ... -- a DIFFERENT profile, freshly cloned, with
   none of the cookies or logins the requested one holds."), keeping the
   standing advice after it. Same field set, no second diagnostics home. Pinned
   both ways: a walk leads the warning, no walk leaves it byte-for-byte as it
   was. Finding section 6 no longer calls this an open product call.

6. `_lock_content` stripped the plain-file branch but not the symlink branch,
   for two forms its own docstring calls equivalent. Both strip now.

profile_lock.py 196 -> 251 LOC (leaf, under the 1000 default);
browser_management.py 438 -> 453; clone_storage.py unchanged at 1055/1055
(cap == actual). Unit lane 2416 passed, 1 skipped.
The substitution warning promised "a DIFFERENT profile, freshly cloned". Only
the FIRST walk to a given name clones: `_next_available_explicit_dir` returns
the first non-busy `<name>-N`, and `resolve_profile_selection` skips the copy
when that directory already exists -- which is exactly the CI shape, where
`ci-warmup-2` pre-existed. A second walk therefore hands back whatever an
earlier one left there, and telling the caller it is fresh is a claim the code
does not make good on.

The warning now says "a DIFFERENT profile, either a fresh clone of the master
snapshot or one an earlier walk left behind, with none of the cookies or logins
the requested one holds", and the pin asserts that wording AND that the old
promise is gone. The same overclaim is corrected where it was repeated: the
finding's severity line and its section 6 quote, the CHANGELOG entry, and
`test_stateful_i18n`'s barrier docstring (prose only -- that file is the
integration tier and its assertions are untouched).
CHANGELOG: the F-871 entry is placed below the two F-872 entries; every
Unreleased entry from main is kept.
@AminDhouib
AminDhouib merged commit 112901d into main Sep 15, 2026
32 checks passed
@AminDhouib AminDhouib mentioned this pull request Sep 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant