From 90215058866598cab0242700863dd7208b4ca862 Mon Sep 17 00:00:00 2001 From: Lime <22442210+L1meSn0w@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:22:50 +0800 Subject: [PATCH 1/2] fix(browser): distinguish profile write-denial from contention Signed-off-by: Lime <22442210+L1meSn0w@users.noreply.github.com> --- CHANGELOG.md | 10 +++++ docs/CONFIGURATION.md | 12 +++++ docs/USAGE.md | 2 +- src/gflow_cli/api/client.py | 33 +++++++++++++- src/gflow_cli/errors.py | 14 ++++++ tests/api/test_concurrency.py | 84 +++++++++++++++++++++++++++++++++++ website/docs/CONFIGURATION.md | 12 +++++ website/docs/USAGE.md | 2 +- 8 files changed, 166 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a409e6b0b..dfe127322 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **Windows profile write-denial is distinguished from browser contention.** A + persistent-context launch carrying `ProcessSingleton` and a known access-denied + error-code-5 marker now raises `ProfileAccessError` (configuration exit code 11), + including generic Playwright errors that previously escaped classification. + The remediation points to complete profile-directory write access, not to + closing a competing Chrome. Other launch failures keep their existing behavior; + this does not add general cross-platform permission detection or cover CDP launch. + ## [0.80.0] — 2026-09-29 ### Security diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 882a63ccd..4cc1642b7 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -31,6 +31,18 @@ See [AUTHENTICATION § Session storage](AUTHENTICATION.md#session-storage) for t create it — a `tools/` directory of user-authored "My Tools" TOMLs (`/tools/*.toml`, auto-loaded; see [TOOLS.md § My Tools](TOOLS.md)). +#### Profile-directory permissions at browser launch + +On Windows, a Chrome `ProcessSingleton` launch failure with access-denied error +code 5 is reported as `ProfileAccessError`, a configuration error (exit code 11), +rather than suggesting contention or surfacing an unclassified launch failure. Ensure +the process can write the complete persistent profile directory, or authenticate +a profile inside a writable `GFLOW_CLI_HOME`. Relocating application-level lease +locks alone does not relocate Chrome's `ProcessSingleton`, cookies, or crash +reports. Launch failures without these access-denied markers retain their +existing classification; this does not provide general cross-platform +permission-error detection. + ### `GFLOW_CLI_PROFILE` **What:** Default profile name used when `--profile` isn't passed on the CLI. diff --git a/docs/USAGE.md b/docs/USAGE.md index 2300da738..a8dca5d41 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -1951,7 +1951,7 @@ shell scripts can branch on the failure mode without parsing stderr. | `8` | `AuthMissingError` | Required auth credential is absent from profile | `gflow auth login --profile ` | | `9` | `TransportTimeoutError` | Browser/API operation exceeded its timeout (incl. an i2v frame UUID not found in the media picker, #287; or a wedged submission stage — the error names the stage and your Playwright version) | Retry; raise the relevant timeout — for a frame-UUID miss verify the UUID belongs to the `--project` passed; for a `stage_stalled` abort check your Playwright is in range (see [KNOWN_ISSUES](../KNOWN_ISSUES.md)) | | `10` | `WafRejectionError` | Flow security layer rejected the request | Change prompt/request and retry | -| `11` | `ConfigurationError` | Local configuration or browser mode is invalid — on the migrated `flow.google.com` host also a request the host cannot take as given (no `--project`, a model its menu does not offer, a `--duration` its settings pane renders no control for); includes `ProfileLockedError` (same-profile lease contention: another `gflow`/daemon/MCP call already owns this profile) and `ProfileEngineDowngradeError` (the profile was last written by a newer Chromium major than the bundled engine about to open it — see [AUTHENTICATION § Chromium downgrade guard](AUTHENTICATION.md#chromium-downgrade-guard)) | Fix the option/env var shown in the error; for lease contention wait, use a different `--profile`, or set `GFLOW_CLI_LEASE_WAIT_SECONDS=N` to wait bounded; upgrade gflow-cli/Playwright or re-run `gflow auth login` for a downgrade refusal | +| `11` | `ConfigurationError` | Local configuration or browser mode is invalid — on the migrated `flow.google.com` host also a request the host cannot take as given (no `--project`, a model its menu does not offer, a `--duration` its settings pane renders no control for); includes `ProfileLockedError` (same-profile lease contention: another `gflow`/daemon/MCP call already owns this profile), `ProfileAccessError` (Windows Chrome cannot write the profile directory), and `ProfileEngineDowngradeError` (the profile was last written by a newer Chromium major than the bundled engine about to open it — see [AUTHENTICATION § Chromium downgrade guard](AUTHENTICATION.md#chromium-downgrade-guard)) | Fix the option/env var shown in the error; for lease contention wait, use a different `--profile`, or set `GFLOW_CLI_LEASE_WAIT_SECONDS=N` to wait bounded; for profile access denial grant write access to the complete profile directory or use a writable profile; upgrade gflow-cli/Playwright or re-run `gflow auth login` for a downgrade refusal | | `12` | `AuthLoginTimeoutError` | Browser sign-in was not completed in time | Re-run login or raise `GFLOW_CLI_AUTH_LOGIN_TIMEOUT` | | `13` | `SecurityError` | Unsafe local profile or secret handling blocked | Follow the error's safety guidance | | `14` | `AuthBrowserRejectedError` | Sign-in rejected the browser for `navigator.webdriver` | Re-run `gflow auth login`; with Chrome installed the `chrome` strategy retries automatically | diff --git a/src/gflow_cli/api/client.py b/src/gflow_cli/api/client.py index 7ccdc9793..375654759 100644 --- a/src/gflow_cli/api/client.py +++ b/src/gflow_cli/api/client.py @@ -98,6 +98,7 @@ FlowApiError, # re-exported via gflow_cli.api.__init__ FlowHostMigratedError, NetworkError, + ProfileAccessError, ProfileLockedError, RateLimitError, SceneConcatError, @@ -165,6 +166,21 @@ def _dc_replace(obj: _DataclassT, /, **changes: Any) -> _DataclassT: ) +_PROFILE_ACCESS_DENIED_MARKERS = ( + "Lock file can not be created! Error code: 5", + "Access is denied. (0x5)", + "拒绝访问。 (0x5)", +) + + +def _is_profile_access_denied(exc: BaseException) -> bool: + """True when Chrome's ProcessSingleton failed because the profile is read-only.""" + message = str(exc) + return "ProcessSingleton" in message and any( + marker in message for marker in _PROFILE_ACCESS_DENIED_MARKERS + ) + + def _is_target_closed(exc: BaseException) -> bool: """Heuristic — True if ``exc`` is Playwright's TargetClosedError or wraps it.""" name = type(exc).__name__ @@ -1093,11 +1109,26 @@ async def _settled_lang(self, page: Any) -> str | None: return after async def _launch_persistent_context(self, kwargs: JsonObject) -> BrowserContext: - """Launch the persistent context; translate a launch-time crash into ProfileLockedError.""" + """Launch the context and classify profile access versus contention failures.""" assert self._pw is not None try: return await self._pw.chromium.launch_persistent_context(**kwargs) except Exception as exc: + if _is_profile_access_denied(exc): + raise ProfileAccessError( + detail=( + f"Chrome cannot create runtime files under {self.profile_dir}; " + "the directory is not writable by this process" + ), + remediation_hint=( + "Check filesystem permissions or sandbox policy for the full " + "profile directory. Grant this process write access, " + "or authenticate a profile inside a writable GFLOW_CLI_HOME. " + "Relocating application-level coordination locks is not enough; " + "Chrome still writes ProcessSingleton, cookies, and Crashpad " + "files inside the profile." + ), + ) from exc if _is_target_closed(exc): # A TargetClosedError at LAUNCH usually means the profile dir # is held by another Chrome — most commonly a stale browser diff --git a/src/gflow_cli/errors.py b/src/gflow_cli/errors.py index 083de4730..295667054 100644 --- a/src/gflow_cli/errors.py +++ b/src/gflow_cli/errors.py @@ -45,6 +45,7 @@ "NetworkError", "OwnerEvidence", "ProblemDetails", + "ProfileAccessError", "QueueSchemaError", "RateLimitError", "SceneConcatError", @@ -459,6 +460,19 @@ class ConfigurationError(GFlowError): ) +class ProfileAccessError(ConfigurationError): + """Raised when Chrome cannot write its persistent profile directory.""" + + problem_type = "https://gflow-cli.dev/errors/profile-access" + title = "Profile directory not writable" + _default_remediation = ( + "Grant the current process write access to the full Chrome profile " + "directory, or use a writable profile. Relocating only application-level " + "lock files does not move Chrome's own ProcessSingleton, cookie, and " + "crash-report files." + ) + + @dataclass(frozen=True, slots=True) class OwnerEvidence: """Private diagnostic evidence about the recorded lease owner (§6.4 of the diff --git a/tests/api/test_concurrency.py b/tests/api/test_concurrency.py index 5a3b80114..65e5f4cd0 100644 --- a/tests/api/test_concurrency.py +++ b/tests/api/test_concurrency.py @@ -376,3 +376,87 @@ class TargetClosedError(Exception): await client.__aenter__() assert "stale Chrome" in (exc_info.value.remediation_hint or "") pw.stop.assert_awaited() # partial-setup guard still tears down the driver + + +@pytest.mark.parametrize( + "access_denied_marker", + [ + "Lock file can not be created! Error code: 5", + "Access is denied. (0x5)", + "拒绝访问。 (0x5)", + ], +) +@pytest.mark.asyncio +async def test_process_singleton_access_denied_raises_profile_access_error( + tmp_path: Path, settings_n4: Settings, access_denied_marker: str +) -> None: + """A launch-time access denial is not evidence of a competing Chrome owner.""" + from gflow_cli.errors import EXIT_CODE_MAP, ConfigurationError, ProfileAccessError + + class TargetClosedError(Exception): + pass + + detail = ( + f"Failed to create a ProcessSingleton for your profile directory.\n{access_denied_marker}" + ) + with patch("gflow_cli.api.client.async_playwright") as mock_pw_factory: + pw = MagicMock() + pw.stop = AsyncMock() + pw.chromium.launch_persistent_context = AsyncMock(side_effect=TargetClosedError(detail)) + mock_pw_factory.return_value.start = AsyncMock(return_value=pw) + client = FlowApiClient(profile_dir=tmp_path, settings=settings_n4) + with pytest.raises(ProfileAccessError, match="not writable") as exc_info: + await client.__aenter__() + error = exc_info.value + assert isinstance(error, ConfigurationError) + assert ( + next( + code + for error_class, code in EXIT_CODE_MAP.items() + if isinstance(error, error_class) + ) + == 11 + ) + assert error.to_problem_details()["type"] == "https://gflow-cli.dev/errors/profile-access" + assert "filesystem permissions" in (error.remediation_hint or "") + pw.stop.assert_awaited() + + +@pytest.mark.parametrize( + "detail", + [ + "Access is denied. (0x5)", + "ProcessSingleton is already held by another process", + "Target closed", + ], +) +def test_profile_access_denied_requires_singleton_and_permission_markers(detail: str) -> None: + from gflow_cli.api.client import _is_profile_access_denied + + assert not _is_profile_access_denied(RuntimeError(detail)) + + +@pytest.mark.asyncio +async def test_chrome_access_denied_payload_without_target_closed_marker( + tmp_path: Path, settings_n4: Settings +) -> None: + """Chrome 154's Windows ACL failure arrives as a generic Playwright Error.""" + from gflow_cli.errors import ProfileAccessError + + class Error(Exception): + pass + + detail = ( + "BrowserType.launch_persistent_context: Failed to create a ProcessSingleton " + "for your profile directory.\n" + "Lock file can not be created: 拒绝访问。 (0x5)" + ) + with patch("gflow_cli.api.client.async_playwright") as mock_pw_factory: + driver = MagicMock() + driver.stop = AsyncMock() + driver.chromium.launch_persistent_context = AsyncMock(side_effect=Error(detail)) + mock_pw_factory.return_value.start = AsyncMock(return_value=driver) + client = FlowApiClient(profile_dir=tmp_path, settings=settings_n4) + with pytest.raises(ProfileAccessError): + await client.__aenter__() + driver.stop.assert_awaited() diff --git a/website/docs/CONFIGURATION.md b/website/docs/CONFIGURATION.md index 882a63ccd..4cc1642b7 100644 --- a/website/docs/CONFIGURATION.md +++ b/website/docs/CONFIGURATION.md @@ -31,6 +31,18 @@ See [AUTHENTICATION § Session storage](AUTHENTICATION.md#session-storage) for t create it — a `tools/` directory of user-authored "My Tools" TOMLs (`/tools/*.toml`, auto-loaded; see [TOOLS.md § My Tools](TOOLS.md)). +#### Profile-directory permissions at browser launch + +On Windows, a Chrome `ProcessSingleton` launch failure with access-denied error +code 5 is reported as `ProfileAccessError`, a configuration error (exit code 11), +rather than suggesting contention or surfacing an unclassified launch failure. Ensure +the process can write the complete persistent profile directory, or authenticate +a profile inside a writable `GFLOW_CLI_HOME`. Relocating application-level lease +locks alone does not relocate Chrome's `ProcessSingleton`, cookies, or crash +reports. Launch failures without these access-denied markers retain their +existing classification; this does not provide general cross-platform +permission-error detection. + ### `GFLOW_CLI_PROFILE` **What:** Default profile name used when `--profile` isn't passed on the CLI. diff --git a/website/docs/USAGE.md b/website/docs/USAGE.md index 61a4b2cf4..cab538ae8 100644 --- a/website/docs/USAGE.md +++ b/website/docs/USAGE.md @@ -1951,7 +1951,7 @@ shell scripts can branch on the failure mode without parsing stderr. | `8` | `AuthMissingError` | Required auth credential is absent from profile | `gflow auth login --profile ` | | `9` | `TransportTimeoutError` | Browser/API operation exceeded its timeout (incl. an i2v frame UUID not found in the media picker, #287; or a wedged submission stage — the error names the stage and your Playwright version) | Retry; raise the relevant timeout — for a frame-UUID miss verify the UUID belongs to the `--project` passed; for a `stage_stalled` abort check your Playwright is in range (see [KNOWN_ISSUES](../KNOWN_ISSUES.md)) | | `10` | `WafRejectionError` | Flow security layer rejected the request | Change prompt/request and retry | -| `11` | `ConfigurationError` | Local configuration or browser mode is invalid — on the migrated `flow.google.com` host also a request the host cannot take as given (no `--project`, a model its menu does not offer, a `--duration` its settings pane renders no control for); includes `ProfileLockedError` (same-profile lease contention: another `gflow`/daemon/MCP call already owns this profile) and `ProfileEngineDowngradeError` (the profile was last written by a newer Chromium major than the bundled engine about to open it — see [AUTHENTICATION § Chromium downgrade guard](AUTHENTICATION.md#chromium-downgrade-guard)) | Fix the option/env var shown in the error; for lease contention wait, use a different `--profile`, or set `GFLOW_CLI_LEASE_WAIT_SECONDS=N` to wait bounded; upgrade gflow-cli/Playwright or re-run `gflow auth login` for a downgrade refusal | +| `11` | `ConfigurationError` | Local configuration or browser mode is invalid — on the migrated `flow.google.com` host also a request the host cannot take as given (no `--project`, a model its menu does not offer, a `--duration` its settings pane renders no control for); includes `ProfileLockedError` (same-profile lease contention: another `gflow`/daemon/MCP call already owns this profile), `ProfileAccessError` (Windows Chrome cannot write the profile directory), and `ProfileEngineDowngradeError` (the profile was last written by a newer Chromium major than the bundled engine about to open it — see [AUTHENTICATION § Chromium downgrade guard](AUTHENTICATION.md#chromium-downgrade-guard)) | Fix the option/env var shown in the error; for lease contention wait, use a different `--profile`, or set `GFLOW_CLI_LEASE_WAIT_SECONDS=N` to wait bounded; for profile access denial grant write access to the complete profile directory or use a writable profile; upgrade gflow-cli/Playwright or re-run `gflow auth login` for a downgrade refusal | | `12` | `AuthLoginTimeoutError` | Browser sign-in was not completed in time | Re-run login or raise `GFLOW_CLI_AUTH_LOGIN_TIMEOUT` | | `13` | `SecurityError` | Unsafe local profile or secret handling blocked | Follow the error's safety guidance | | `14` | `AuthBrowserRejectedError` | Sign-in rejected the browser for `navigator.webdriver` | Re-run `gflow auth login`; with Chrome installed the `chrome` strategy retries automatically | From 82013eb17907bf899040cf638be9baa0edc06595 Mon Sep 17 00:00:00 2001 From: Flavio Oliva Date: Wed, 30 Sep 2026 14:34:31 +0100 Subject: [PATCH 2/2] fix(browser): match access-denied by error code, scope docs to the client launch Review follow-up on #919. - Match ProcessSingleton + Win32 code 5 ("(0x5)" / "Error code: 5") instead of localized OS text, so the classification holds in every Windows display language (pt-BR and de-DE payloads previously fell through). - One remediation text: the class default; the single raise site no longer overrides it. - Tests: parametrize over language and exception shape, use exit_code_for, and pin that a ProcessSingleton header without code 5 is still contention. - CHANGELOG/CONFIGURATION: state that only the generation client's launch is classified; auth, standalone/experimental transports and CDP are not yet. KNOWN_ISSUES points ProcessSingleton + (0x5) at the new section. Signed-off-by: Flavio Oliva --- CHANGELOG.md | 17 ++++--- KNOWN_ISSUES.md | 2 + docs/CONFIGURATION.md | 22 ++++---- src/gflow_cli/api/client.py | 28 ++++------ src/gflow_cli/errors.py | 9 ++-- tests/api/test_concurrency.py | 96 +++++++++++++++++------------------ website/docs/CONFIGURATION.md | 22 ++++---- website/docs/KNOWN_ISSUES.md | 2 + 8 files changed, 101 insertions(+), 97 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfe127322..5cacb1be7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,13 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- **Windows profile write-denial is distinguished from browser contention.** A - persistent-context launch carrying `ProcessSingleton` and a known access-denied - error-code-5 marker now raises `ProfileAccessError` (configuration exit code 11), - including generic Playwright errors that previously escaped classification. - The remediation points to complete profile-directory write access, not to - closing a competing Chrome. Other launch failures keep their existing behavior; - this does not add general cross-platform permission detection or cover CDP launch. +- **Windows profile write-denial is distinguished from browser contention.** When + the generation client's browser launch fails with a Chrome `ProcessSingleton` + error carrying Windows access-denied code 5, it now raises `ProfileAccessError` + (configuration exit code 11) instead of a misleading "another Chrome holds it" + `ProfileLockedError` or an unclassified error. The match is on the error code, + so it works in every Windows display language. The remediation points to + profile-directory write access, not to closing a competing Chrome. Not covered + yet: `gflow auth login` / auth verification, the standalone and experimental + transport launches, and CDP launch — those still surface Chrome's raw error. + Thanks @L1meSn0w for the diagnosis and the Windows reproduction (#919). ## [0.80.0] — 2026-09-29 diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index 491089e9c..501857f0f 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -1485,6 +1485,8 @@ key — surfaces as a `RuntimeError` that `auth/cookies.py` normalizes to Chromium refuses to open two persistent contexts on the same `user-data-dir` simultaneously. Historically this surfaced as an unhelpful Chromium "ProcessSingleton: profile is locked" error partway through a run. As of the profile-lease hardening (production-readiness plan, slice D1/D3), gflow-cli enforces this itself: a cross-process advisory lock (`ProfileLease`, kernel `flock` on POSIX / `msvcrt.locking` on Windows) guards every profile directory. A second `gflow` invocation, `gflow serve` daemon task, or MCP call against an already-leased profile is rejected **immediately** by default — before any Chrome process starts — with a typed `ProfileLockedError` (**exit code 11**); it never silently corrupts the profile. Since #478, setting [`GFLOW_CLI_LEASE_WAIT_SECONDS`](docs/CONFIGURATION.md#gflow_cli_lease_wait_seconds) opts a waiter into a bounded wait that takes over as soon as the current holder finishes (holders always run to completion and are never asked to release early; same-process contention still fails fast — waiting on yourself would deadlock). Since #864 an MCP server waits 180 s by default, and queues its own calls on one profile rather than rejecting the second. +**Not every `ProcessSingleton` failure is contention.** On Windows, a `ProcessSingleton` error carrying access-denied code `(0x5)` means Chrome cannot write the profile directory; gflow reports it as `ProfileAccessError` — see [CONFIGURATION § Profile-directory permissions at browser launch](docs/CONFIGURATION.md#profile-directory-permissions-at-browser-launch). + **Workaround:** use different profiles for parallel work — different profiles acquire independent leases and run fully concurrently. ### gflow refuses to open my profile after a downgrade ("written by a newer Chromium", exit 11) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 4cc1642b7..73763402e 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -33,15 +33,19 @@ create it — a `tools/` directory of user-authored "My Tools" TOMLs #### Profile-directory permissions at browser launch -On Windows, a Chrome `ProcessSingleton` launch failure with access-denied error -code 5 is reported as `ProfileAccessError`, a configuration error (exit code 11), -rather than suggesting contention or surfacing an unclassified launch failure. Ensure -the process can write the complete persistent profile directory, or authenticate -a profile inside a writable `GFLOW_CLI_HOME`. Relocating application-level lease -locks alone does not relocate Chrome's `ProcessSingleton`, cookies, or crash -reports. Launch failures without these access-denied markers retain their -existing classification; this does not provide general cross-platform -permission-error detection. +On Windows, when a generation run's browser launch fails with a Chrome +`ProcessSingleton` error carrying access-denied code 5, gflow reports +`ProfileAccessError`, a configuration error (exit code 11), rather than suggesting +contention. Ensure the process can write the complete persistent profile directory, +or use a profile inside a writable `GFLOW_CLI_HOME`. Relocating application-level +lease locks alone does not relocate Chrome's `ProcessSingleton`, cookies, or crash +reports. + +This classification covers the generation client's launch only. `gflow auth login`, +auth verification, the standalone and experimental transports, and CDP launch still +surface Chrome's raw error for the same cause; look for `ProcessSingleton` plus +`(0x5)` in it. Launch failures without the access-denied code keep their existing +classification; this is not general cross-platform permission-error detection. ### `GFLOW_CLI_PROFILE` diff --git a/src/gflow_cli/api/client.py b/src/gflow_cli/api/client.py index 375654759..c67665579 100644 --- a/src/gflow_cli/api/client.py +++ b/src/gflow_cli/api/client.py @@ -166,19 +166,15 @@ def _dc_replace(obj: _DataclassT, /, **changes: Any) -> _DataclassT: ) -_PROFILE_ACCESS_DENIED_MARKERS = ( - "Lock file can not be created! Error code: 5", - "Access is denied. (0x5)", - "拒绝访问。 (0x5)", -) - - def _is_profile_access_denied(exc: BaseException) -> bool: - """True when Chrome's ProcessSingleton failed because the profile is read-only.""" + """True when Chrome's ProcessSingleton failed because the profile is read-only. + + Matches the Win32 code (ERROR_ACCESS_DENIED = 5), never the message text: Chrome + embeds the OS's *localized* description (``拒绝访问。 (0x5)``, ``Acesso negado. + (0x5)``), so only the code is the same on every Windows display language. + """ message = str(exc) - return "ProcessSingleton" in message and any( - marker in message for marker in _PROFILE_ACCESS_DENIED_MARKERS - ) + return "ProcessSingleton" in message and ("(0x5)" in message or "Error code: 5" in message) def _is_target_closed(exc: BaseException) -> bool: @@ -1114,20 +1110,14 @@ async def _launch_persistent_context(self, kwargs: JsonObject) -> BrowserContext try: return await self._pw.chromium.launch_persistent_context(**kwargs) except Exception as exc: + # Before _is_target_closed: an access denial can arrive as a + # TargetClosedError too, and must not be reported as contention. if _is_profile_access_denied(exc): raise ProfileAccessError( detail=( f"Chrome cannot create runtime files under {self.profile_dir}; " "the directory is not writable by this process" ), - remediation_hint=( - "Check filesystem permissions or sandbox policy for the full " - "profile directory. Grant this process write access, " - "or authenticate a profile inside a writable GFLOW_CLI_HOME. " - "Relocating application-level coordination locks is not enough; " - "Chrome still writes ProcessSingleton, cookies, and Crashpad " - "files inside the profile." - ), ) from exc if _is_target_closed(exc): # A TargetClosedError at LAUNCH usually means the profile dir diff --git a/src/gflow_cli/errors.py b/src/gflow_cli/errors.py index 295667054..d684deacd 100644 --- a/src/gflow_cli/errors.py +++ b/src/gflow_cli/errors.py @@ -466,10 +466,11 @@ class ProfileAccessError(ConfigurationError): problem_type = "https://gflow-cli.dev/errors/profile-access" title = "Profile directory not writable" _default_remediation = ( - "Grant the current process write access to the full Chrome profile " - "directory, or use a writable profile. Relocating only application-level " - "lock files does not move Chrome's own ProcessSingleton, cookie, and " - "crash-report files." + "Check filesystem permissions or sandbox policy for the full profile " + "directory. Grant this process write access, or authenticate a profile " + "inside a writable GFLOW_CLI_HOME. Relocating only application-level lock " + "files is not enough; Chrome still writes its ProcessSingleton, cookie, and " + "crash-report files inside the profile." ) diff --git a/tests/api/test_concurrency.py b/tests/api/test_concurrency.py index 65e5f4cd0..6e2dc13b0 100644 --- a/tests/api/test_concurrency.py +++ b/tests/api/test_concurrency.py @@ -378,50 +378,74 @@ class TargetClosedError(Exception): pw.stop.assert_awaited() # partial-setup guard still tears down the driver +_SINGLETON_HEADER = ( + "BrowserType.launch_persistent_context: Failed to create a ProcessSingleton " + "for your profile directory. This usually means that the profile is already " + "in use by another instance of Chromium." +) + + +@pytest.mark.parametrize("error_class_name", ["TargetClosedError", "Error"]) @pytest.mark.parametrize( - "access_denied_marker", + "lock_line", [ - "Lock file can not be created! Error code: 5", - "Access is denied. (0x5)", - "拒绝访问。 (0x5)", + "Lock file can not be created: Access is denied. (0x5)", + "Lock file can not be created: 拒绝访问。 (0x5)", # zh-CN, measured on Chrome 154 + "Lock file can not be created: Acesso negado. (0x5)", # pt-BR + "Lock file can not be created: Zugriff verweigert (0x5)", # de-DE + "Lock file can not be created! Error code: 5", # older Chrome wording ], ) @pytest.mark.asyncio async def test_process_singleton_access_denied_raises_profile_access_error( - tmp_path: Path, settings_n4: Settings, access_denied_marker: str + tmp_path: Path, settings_n4: Settings, error_class_name: str, lock_line: str ) -> None: - """A launch-time access denial is not evidence of a competing Chrome owner.""" - from gflow_cli.errors import EXIT_CODE_MAP, ConfigurationError, ProfileAccessError - - class TargetClosedError(Exception): - pass + """A launch-time access denial is not evidence of a competing Chrome owner — in + any Windows display language, and whether Playwright raises TargetClosedError + or a generic Error (Chrome 154's real shape).""" + from gflow_cli.errors import ProfileAccessError + from gflow_cli.json_output import exit_code_for - detail = ( - f"Failed to create a ProcessSingleton for your profile directory.\n{access_denied_marker}" - ) + launch_error = type(error_class_name, (Exception,), {}) with patch("gflow_cli.api.client.async_playwright") as mock_pw_factory: pw = MagicMock() pw.stop = AsyncMock() - pw.chromium.launch_persistent_context = AsyncMock(side_effect=TargetClosedError(detail)) + pw.chromium.launch_persistent_context = AsyncMock( + side_effect=launch_error(f"{_SINGLETON_HEADER}\n{lock_line}") + ) mock_pw_factory.return_value.start = AsyncMock(return_value=pw) client = FlowApiClient(profile_dir=tmp_path, settings=settings_n4) with pytest.raises(ProfileAccessError, match="not writable") as exc_info: await client.__aenter__() error = exc_info.value - assert isinstance(error, ConfigurationError) - assert ( - next( - code - for error_class, code in EXIT_CODE_MAP.items() - if isinstance(error, error_class) - ) - == 11 - ) + assert exit_code_for(error) == 11 assert error.to_problem_details()["type"] == "https://gflow-cli.dev/errors/profile-access" assert "filesystem permissions" in (error.remediation_hint or "") pw.stop.assert_awaited() +@pytest.mark.asyncio +async def test_process_singleton_without_access_code_stays_contention( + tmp_path: Path, settings_n4: Settings +) -> None: + """Playwright's real 'already in use' header with no 0x5 line is still contention.""" + from gflow_cli.errors import ProfileLockedError + + class TargetClosedError(Exception): + pass + + with patch("gflow_cli.api.client.async_playwright") as mock_pw_factory: + pw = MagicMock() + pw.stop = AsyncMock() + pw.chromium.launch_persistent_context = AsyncMock( + side_effect=TargetClosedError(_SINGLETON_HEADER) + ) + mock_pw_factory.return_value.start = AsyncMock(return_value=pw) + client = FlowApiClient(profile_dir=tmp_path, settings=settings_n4) + with pytest.raises(ProfileLockedError): + await client.__aenter__() + + @pytest.mark.parametrize( "detail", [ @@ -434,29 +458,3 @@ def test_profile_access_denied_requires_singleton_and_permission_markers(detail: from gflow_cli.api.client import _is_profile_access_denied assert not _is_profile_access_denied(RuntimeError(detail)) - - -@pytest.mark.asyncio -async def test_chrome_access_denied_payload_without_target_closed_marker( - tmp_path: Path, settings_n4: Settings -) -> None: - """Chrome 154's Windows ACL failure arrives as a generic Playwright Error.""" - from gflow_cli.errors import ProfileAccessError - - class Error(Exception): - pass - - detail = ( - "BrowserType.launch_persistent_context: Failed to create a ProcessSingleton " - "for your profile directory.\n" - "Lock file can not be created: 拒绝访问。 (0x5)" - ) - with patch("gflow_cli.api.client.async_playwright") as mock_pw_factory: - driver = MagicMock() - driver.stop = AsyncMock() - driver.chromium.launch_persistent_context = AsyncMock(side_effect=Error(detail)) - mock_pw_factory.return_value.start = AsyncMock(return_value=driver) - client = FlowApiClient(profile_dir=tmp_path, settings=settings_n4) - with pytest.raises(ProfileAccessError): - await client.__aenter__() - driver.stop.assert_awaited() diff --git a/website/docs/CONFIGURATION.md b/website/docs/CONFIGURATION.md index 4cc1642b7..73763402e 100644 --- a/website/docs/CONFIGURATION.md +++ b/website/docs/CONFIGURATION.md @@ -33,15 +33,19 @@ create it — a `tools/` directory of user-authored "My Tools" TOMLs #### Profile-directory permissions at browser launch -On Windows, a Chrome `ProcessSingleton` launch failure with access-denied error -code 5 is reported as `ProfileAccessError`, a configuration error (exit code 11), -rather than suggesting contention or surfacing an unclassified launch failure. Ensure -the process can write the complete persistent profile directory, or authenticate -a profile inside a writable `GFLOW_CLI_HOME`. Relocating application-level lease -locks alone does not relocate Chrome's `ProcessSingleton`, cookies, or crash -reports. Launch failures without these access-denied markers retain their -existing classification; this does not provide general cross-platform -permission-error detection. +On Windows, when a generation run's browser launch fails with a Chrome +`ProcessSingleton` error carrying access-denied code 5, gflow reports +`ProfileAccessError`, a configuration error (exit code 11), rather than suggesting +contention. Ensure the process can write the complete persistent profile directory, +or use a profile inside a writable `GFLOW_CLI_HOME`. Relocating application-level +lease locks alone does not relocate Chrome's `ProcessSingleton`, cookies, or crash +reports. + +This classification covers the generation client's launch only. `gflow auth login`, +auth verification, the standalone and experimental transports, and CDP launch still +surface Chrome's raw error for the same cause; look for `ProcessSingleton` plus +`(0x5)` in it. Launch failures without the access-denied code keep their existing +classification; this is not general cross-platform permission-error detection. ### `GFLOW_CLI_PROFILE` diff --git a/website/docs/KNOWN_ISSUES.md b/website/docs/KNOWN_ISSUES.md index f3f8cef4d..e5590236a 100644 --- a/website/docs/KNOWN_ISSUES.md +++ b/website/docs/KNOWN_ISSUES.md @@ -1485,6 +1485,8 @@ key — surfaces as a `RuntimeError` that `auth/cookies.py` normalizes to Chromium refuses to open two persistent contexts on the same `user-data-dir` simultaneously. Historically this surfaced as an unhelpful Chromium "ProcessSingleton: profile is locked" error partway through a run. As of the profile-lease hardening (production-readiness plan, slice D1/D3), gflow-cli enforces this itself: a cross-process advisory lock (`ProfileLease`, kernel `flock` on POSIX / `msvcrt.locking` on Windows) guards every profile directory. A second `gflow` invocation, `gflow serve` daemon task, or MCP call against an already-leased profile is rejected **immediately** by default — before any Chrome process starts — with a typed `ProfileLockedError` (**exit code 11**); it never silently corrupts the profile. Since #478, setting [`GFLOW_CLI_LEASE_WAIT_SECONDS`](CONFIGURATION.md#gflow_cli_lease_wait_seconds) opts a waiter into a bounded wait that takes over as soon as the current holder finishes (holders always run to completion and are never asked to release early; same-process contention still fails fast — waiting on yourself would deadlock). Since #864 an MCP server waits 180 s by default, and queues its own calls on one profile rather than rejecting the second. +**Not every `ProcessSingleton` failure is contention.** On Windows, a `ProcessSingleton` error carrying access-denied code `(0x5)` means Chrome cannot write the profile directory; gflow reports it as `ProfileAccessError` — see [CONFIGURATION § Profile-directory permissions at browser launch](CONFIGURATION.md#profile-directory-permissions-at-browser-launch). + **Workaround:** use different profiles for parallel work — different profiles acquire independent leases and run fully concurrently. ### gflow refuses to open my profile after a downgrade ("written by a newer Chromium", exit 11)