Fail fast with a clear error on event loop reuse instead of hanging

A Stealthii instance binds its lock, browser, and contexts to whichever
asyncio event loop is running the first time it is used. Using it again
from a different loop previously hung forever (the lock/semaphore/
Playwright transport are all bound to the dead first loop and never
wake the waiting coroutine). Detected in rsearch's test suite, where
pytest-asyncio's default per-test-function event loop broke the
module-level Stealthii singleton shared across tests.

Now raises StealthUnavailableError immediately with an actionable
message. README documents the constraint and the pytest-asyncio config
fix (asyncio_default_test_loop_scope = session).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0116i7dYLNZTXNR7Lqf439bV
This commit is contained in:
2026-09-10 01:58:55 +02:00
co-authored by Claude Sonnet 5
parent 23cdad8966
commit 423170e1bb
5 changed files with 105 additions and 61 deletions
+7 -7
View File
@@ -17,7 +17,7 @@ Applied in `Stealthii._ensure_browser_locked()` at browser launch.
| `ignore_default_args=['--enable-automation']` | Playwright passes `--enable-automation` to Chromium by default, which enables the automation infobar and related detectable behaviour. Suppressed. |
| `--no-sandbox` | Required to run headless Chromium as a non-root, unprivileged process in this deployment environment. Not a stealth measure. |
| `--disable-dev-shm-usage` | Avoids `/dev/shm` exhaustion under containerized/limited-memory environments. Not a stealth measure. |
| `--disk-cache-size=<bytes>` / `--media-cache-size=<bytes>` | Bounds Chromium's own on-disk HTTP/media cache, configurable via `disk_cache_bytes` (default near-zero). Not a stealth measure an operational safeguard against unbounded disk growth in a long-lived process. |
| `--disk-cache-size=<bytes>` / `--media-cache-size=<bytes>` | Bounds Chromium's own on-disk HTTP/media cache, configurable via `disk_cache_bytes` (default near-zero). Not a stealth measure - an operational safeguard against unbounded disk growth in a long-lived process. |
## JS-level patches
@@ -42,7 +42,7 @@ and `app` members shaped like the real object's public surface.
A stock headless Chromium reports `denied` for a `notifications`
permission query while `Notification.permission` itself still reports
`default` a mismatch a real browser never produces. The query is
`default` - a mismatch a real browser never produces. The query is
patched to always return the same value as `Notification.permission`
for that one permission name; all other permission names are passed
through to the original implementation unmodified.
@@ -55,7 +55,7 @@ A stock headless Chromium reports an empty plugin list and a
set (`PDF Viewer`, `Chrome PDF Viewer`, `Chromium PDF Viewer`,
`Microsoft Edge PDF Viewer`, `WebKit built-in PDF`) are constructed, each
individually reassigned the real `Plugin.prototype`, and the containing
array reassigned `PluginArray.prototype` including
array reassigned `PluginArray.prototype` - including
`Symbol.toStringTag`, so both a property count check and a
`Object.prototype.toString.call(...)` type check pass. `navigator.mimeTypes`
is patched the same way for the corresponding `application/pdf` MIME type.
@@ -71,7 +71,7 @@ already reports plausible values here in most cases.
A stock headless Chromium renders WebGL through SwiftShader (a software
rasterizer), which reports `UNMASKED_VENDOR_WEBGL` (constant `37445`) as
`Google Inc. (Google)` and `UNMASKED_RENDERER_WEBGL` (constant `37446`)
containing the literal string `SwiftShader` an unambiguous headless
containing the literal string `SwiftShader` - an unambiguous headless
signal checked by essentially every fingerprinting script. Both
constants are intercepted and rewritten to report a real discrete GPU
(`Google Inc. (NVIDIA)` / an ANGLE-wrapped `NVIDIA GeForce RTX 3060`
@@ -80,7 +80,7 @@ string); every other parameter is passed through unmodified.
### `HTMLIFrameElement.prototype.contentWindow`
A known secondary check reads `window.chrome` from inside a freshly
created, same-origin iframe rather than the top-level document since
created, same-origin iframe rather than the top-level document - since
each document gets its own JS globals, an iframe's `window.chrome` can
be absent even when the top-level one was patched. The `contentWindow`
getter is wrapped so that, whenever the returned window is missing
@@ -94,7 +94,7 @@ Every function or getter installed by the patches above is wrapped in a
real native function produces (`function name() { [native code] }`).
Without this, a page can distinguish a patched native method from a real
one by calling `.toString()` on it and observing actual JavaScript source
instead of the native-code marker a check used by several stealth-
instead of the native-code marker - a check used by several stealth-
detection scripts specifically to unmask other stealth patches.
## Verification
@@ -115,7 +115,7 @@ the browser's own TLS stack rather than Python's.
## Known limitations
- The `context.request` fast path negotiates HTTP/1.1, not HTTP/2 a
- The `context.request` fast path negotiates HTTP/1.1, not HTTP/2 - a
real Chrome page load always uses HTTP/2 against an HTTP/2-capable
server. A system that cross-checks the TLS fingerprint against the
negotiated protocol can detect this specific inconsistency. `render()`