diff --git a/pythonlib/camoufox/async_api.py b/pythonlib/camoufox/async_api.py index 09c5bd6..129f207 100644 --- a/pythonlib/camoufox/async_api.py +++ b/pythonlib/camoufox/async_api.py @@ -105,7 +105,8 @@ async def AsyncNewBrowser( virtual_display = None if not from_options: - kwargs.setdefault('pin_cpu_cores', True) + # Opt-in; see the note in sync_api.launch_options_or_default. + kwargs.setdefault('pin_cpu_cores', False) from_options = await asyncio.get_event_loop().run_in_executor( None, partial(launch_options, headless=headless, debug=debug, **kwargs), diff --git a/pythonlib/camoufox/fingerprints.py b/pythonlib/camoufox/fingerprints.py index 4a360ca..9427c1e 100644 --- a/pythonlib/camoufox/fingerprints.py +++ b/pythonlib/camoufox/fingerprints.py @@ -847,16 +847,22 @@ def host_cpu_count() -> Optional[int]: # Linux 1/65; 32: Linux 1/65 -- all in -v150). Leaving any of them out snapped # genuine machines with that count down to the next entry for no reason. # -# 2 is recorded too -- and is common, 6/30 macOS presets (20%) -- but is -# deliberately EXCLUDED (user, 2026-09-15): 2 is what Firefox reports under -# resistFingerprinting, and the goal is to look like a DEFAULT Firefox, which -# RFP is not. So a draw of 2 snaps up to the table floor of 4. +# 2 was excluded on 2026-09-15 on the grounds that it is what Firefox reports +# under resistFingerprinting. That is no longer true and has not been for years: +# RuntimeService::ClampedHardwareConcurrency (dom/workers/RuntimeService.cpp, +# checked in the 152 tree on 2026-09-17) hardcodes 4 under RFP, and 8 on macOS. +# Both of those are IN this table, so excluding 2 never protected against an +# "is this RFP" check -- it only cost fidelity, on 6/30 macOS presets (20%) and +# 4.2% of Linux draws, i.e. every genuinely dual-core machine. Restored. +# +# (Sundial's cjResistance helper still tested hardwareConcurrency === 2 for the +# same stale reason; corrected the same day.) # # A host outside this table would hand its own oddity to the fingerprint: a # 64-thread build box reports 32, anything under 4 threads reports 4. Odd # counts (5, 7, 9, 11, 13, 15) never appear in the corpus -- they are # browserforge Bayesian synthesis -- so they keep getting snapped down. -PLAUSIBLE_CORE_COUNTS = (4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 28, 32) +PLAUSIBLE_CORE_COUNTS = (2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 28, 32) def fix_hardware_concurrency(config: Dict[str, Any], can_pin: Optional[bool] = None) -> None: @@ -889,8 +895,9 @@ def fix_hardware_concurrency(config: Dict[str, Any], can_pin: Optional[bool] = N # The fingerprint's own value survives when the browser can be pinned to # that many cores (cpu_affinity: Linux, Windows): reported and measurable # then agree by construction, and the identity keeps its diversity. A draw - # the host cannot honour (more cores than it has), or a host that cannot - # pin (macOS), falls back to the snapped host count. + # the host cannot honour (more cores than it has), a host that cannot pin + # (macOS), or pin_cpu_cores left off -- the default since 2026-09-17 -- + # falls back to the snapped host count, which is equally coherent. from .cpu_affinity import supported as _can_pin # can_pin=False: the caller launches the browser itself and nothing will @@ -906,17 +913,13 @@ def fix_hardware_concurrency(config: Dict[str, Any], can_pin: Optional[bool] = N if pinnable and isinstance(drawn, int) and drawn >= 1: # The fingerprint's value is kept for diversity, but it still has to be # a count a real desktop ships with. Accepting any 1..host let - # browserforge's low/odd draws through: over 400 linux draws, 8.0% were - # < 4 cores and 4.2% were exactly 2 -- and hardwareConcurrency == 2 is - # the value Firefox reports under resistFingerprinting, so CreepJS-style - # heuristics label the browser "Firefox resistFingerprinting" (this is - # what intermittently failed sundial's "Privacy mode verdict"). Odd - # counts (5, 7, 9, 11, 13, 15) survived the same way. Snap the draw DOWN - # into the table instead, capped by the host so pinning can honour it. + # browserforge's synthetic odd draws through (5, 7, 9, 11, 13, 15 -- + # counts the corpus never records). Snap the draw DOWN into the table + # instead, capped by the host so pinning can honour it. target = min(drawn, cap) allowed = [c for c in PLAUSIBLE_CORE_COUNTS if c <= target] - # The floor is the table's even on a 1-3 core host: min(4, cap) - # reported 1, 2 or 3 there, and 2 is the resistFingerprinting value. + # The floor is the table's even on a 1-core host: a draw of 1 reports 2, + # the lowest count the corpus actually records. config['navigator.hardwareConcurrency'] = ( allowed[-1] if allowed else PLAUSIBLE_CORE_COUNTS[0] ) @@ -1367,11 +1370,13 @@ def sample_webgl_for_screen( and widening it here to flatter the GPU would push a headful window back off the monitor it is drawn on (#499). - The first draw settles hardware-vs-software at the pool's natural rate and - is never resampled once it lands on a rasterizer. Rejecting only hardware - draws would renormalise the survivors onto llvmpipe / WARP / SwiftShader: - on a small screen that turns a 1.5% software rate into a 40% one, trading - a weak incoherence for the strongest VM/headless tell there is. + Software rasterisers are the one exception to keeping the pool's rate: a + draw that lands on llvmpipe / WARP / SwiftShader is resampled, so they + never present as the GPU (see below). That does cost fidelity -- the corpus + records them at ~1.5%, because real users do run without working drivers -- + but "no consumer machine reports llvmpipe" is a live, standard check on a + string every fingerprint script already reads, so the trade is worth it. + Reviewed against the JS-detectability bar on 2026-09-17 and kept. Falls back to that first draw when the pool holds nothing coherent, so an unusual screen degrades to today's behaviour rather than raising. diff --git a/pythonlib/camoufox/sync_api.py b/pythonlib/camoufox/sync_api.py index 96a02ee..3c6af4b 100644 --- a/pythonlib/camoufox/sync_api.py +++ b/pythonlib/camoufox/sync_api.py @@ -107,7 +107,13 @@ def NewBrowser( virtual_display = None if not from_options: - kwargs.setdefault('pin_cpu_cores', True) + # Opt-in (2026-09-17). Pinning keeps the identity's core count by + # constraining the browser to that many cores; it costs real CPU, needs + # a launch lock, and does nothing on macOS. What it defends against is a + # page timing N parallel workers, which is expensive and noisy on a busy + # machine. Off, the host's own snapped count is reported, so reported + # and measurable still agree -- the identity just loses that one draw. + kwargs.setdefault('pin_cpu_cores', False) from_options = launch_options(headless=headless, debug=debug, **kwargs) # Playwright's default viewport deadlocks Juggler when the window is spoofed diff --git a/pythonlib/camoufox/utils.py b/pythonlib/camoufox/utils.py index 88d6bb1..42a0425 100644 --- a/pythonlib/camoufox/utils.py +++ b/pythonlib/camoufox/utils.py @@ -812,10 +812,12 @@ def launch_options( virtual_display (Optional[str]): Virtual display number. Ex: ':99'. This is handled by Camoufox & AsyncCamoufox. pin_cpu_cores (Optional[bool]): - The browser will be pinned to navigator.hardwareConcurrency cores - (Linux/Windows), so the fingerprint's core count can be kept. Set by - Camoufox & AsyncCamoufox, which apply the pin; without it the host's - own (snapped) core count is reported, since nothing pins the browser. + Pin the browser to navigator.hardwareConcurrency cores + (Linux/Windows) so the fingerprint's own core count can be kept: + a page timing N parallel workers then measures the number it was + told. OFF by default -- it costs real CPU and serializes concurrent + launches. Without it the host's own (snapped) count is reported, + which is equally coherent, just less diverse. webgl_config (Optional[Tuple[str, str]]): Use a specific WebGL vendor/renderer pair. Passed as a tuple of (vendor, renderer). **launch_options (Dict[str, Any]): diff --git a/pythonlib/tests/test_fingerprint_fixes.py b/pythonlib/tests/test_fingerprint_fixes.py index 5817bed..1cfb143 100644 --- a/pythonlib/tests/test_fingerprint_fixes.py +++ b/pythonlib/tests/test_fingerprint_fixes.py @@ -298,33 +298,31 @@ class TestFixHardwareConcurrency: assert c["navigator.hardwareConcurrency"] == drawn def test_snaps_implausible_draws_down_into_the_table(self, monkeypatch): - # browserforge draws counts no desktop ships with: over 400 linux draws - # 8.0% were < 4 cores and 4.2% were exactly 2. hardwareConcurrency == 2 - # is what Firefox reports under resistFingerprinting, so CreepJS-style - # heuristics label the browser "Firefox resistFingerprinting"; odd counts - # (5, 7, 9, ...) are equally synthetic. They snap DOWN into + # browserforge draws counts no desktop ships with -- odd ones (5, 7, 9, + # ...) are Bayesian synthesis, not machines. They snap DOWN into # PLAUSIBLE_CORE_COUNTS, with the table floor for anything below it. + # 2 IS a real count (20% of the macOS presets) and stays: Firefox's + # resistFingerprinting value is 4, or 8 on macOS, not 2. from camoufox import cpu_affinity, fingerprints as fp monkeypatch.setattr(cpu_affinity, "supported", lambda: True) monkeypatch.setattr(fp, "host_cpu_count", lambda: 16) - for drawn, expected in ((1, 4), (2, 4), (3, 4), (5, 4), (7, 6), (9, 8), + for drawn, expected in ((1, 2), (2, 2), (3, 2), (5, 4), (7, 6), (9, 8), (11, 10), (13, 12), (15, 14), (32, 16)): c = {"navigator.hardwareConcurrency": drawn} fp.fix_hardware_concurrency(c) assert c["navigator.hardwareConcurrency"] == expected, drawn # never above what the host can be pinned to monkeypatch.setattr(fp, "host_cpu_count", lambda: 4) - c = {"navigator.hardwareConcurrency": 2} + c = {"navigator.hardwareConcurrency": 8} fp.fix_hardware_concurrency(c) assert c["navigator.hardwareConcurrency"] == 4 def test_snaps_host_parallelism_when_it_cannot_pin(self, monkeypatch): # The host count, snapped DOWN into the - # counts real machines ship with; the tails report 32 / 4. Used when the + # counts real machines ship with; the tails report 32 / 2. Used when the # draw exceeds the host or the host cannot pin (macOS). - # 18/22/24/28/32 are in the table (recorded on real devices); 2 is NOT, - # although it is recorded, because 2 is the resistFingerprinting value. + # 2/18/22/24/28/32 are all in the table, all recorded on real devices. from camoufox import cpu_affinity, fingerprints as fp monkeypatch.setattr(cpu_affinity, "supported", lambda: False) @@ -338,7 +336,9 @@ class TestFixHardwareConcurrency: (22, 22), (7, 6), (5, 4), - (2, 4), + (3, 2), + (2, 2), + (1, 2), (9, 8), ): monkeypatch.setattr(fp, "host_cpu_count", lambda host=host: host) diff --git a/pythonlib/tests/test_identity_salt.py b/pythonlib/tests/test_identity_salt.py index fb5104e..6e79187 100644 --- a/pythonlib/tests/test_identity_salt.py +++ b/pythonlib/tests/test_identity_salt.py @@ -100,13 +100,18 @@ class TestVoicesFollowLocale: class TestCoreCountFloor: def test_small_pinnable_host_reports_table_floor(self, monkeypatch): + # A 1-3 core host reports 2, the table's floor and the lowest count the + # corpus records. It used to report 4, which no 2-core machine can back + # up: 4 cannot be pinned on a 3-core host, so the page measured 3 while + # being told 4. At 2 the pin succeeds on a 2- or 3-core host, and the + # 1-core tail (told 2, measures 1) is closer than 4 was. monkeypatch.setattr(cpu_affinity, "supported", lambda: True) for host_cores in (1, 2, 3): monkeypatch.setattr(fp, "host_cpu_count", lambda n=host_cores: n) for drawn_cores in (1, 2, 3, 8): c = {"navigator.hardwareConcurrency": drawn_cores} fp.fix_hardware_concurrency(c) - assert c["navigator.hardwareConcurrency"] == 4, (host_cores, drawn_cores) + assert c["navigator.hardwareConcurrency"] == 2, (host_cores, drawn_cores) def test_unpinned_launch_reports_host(self, monkeypatch): monkeypatch.setattr(cpu_affinity, "supported", lambda: True)