fix(python): warn whenever an identity falls back to a substitute value

Several draws swallowed their failure and used something else, so an
identity could ship with values the rest of it was not drawn to match and
nobody would hear about it:

- from_preset(): a failed font or voice draw used the preset's recorded
  list, or nothing, on any exception.
- generate_context_fingerprint(): a failed font, voice or WebGL draw was
  `except Exception: pass`, leaving the browser's launch-time values.
- _load_font_groups() / _load_font_bases(): an unreadable file became {},
  i.e. no font additions or no OS-version base.
- launch_options(): a failed font draw used every font in fonts.json, a
  failed voice draw used no voices, and a preset GPU missing from
  webgl_data.db was silently swapped for a drawn one (36 of the 397
  bundled presets).

Each site now catches only the errors its data can raise (OSError and
ValueError for an unreadable or corrupt file, KeyError for a manifest with
no entry for the OS, sqlite3.Error for the WebGL database) and emits a
FallbackWarning. The text names what failed and what the identity uses
instead, then gives a block to paste into an issue (camoufox, browser, OS
and Python versions, the error, and the identity's user agent or GPU),
asking the user to report it on GitHub. It shares LeakWarning's
caller-frame attribution and its template lives in warnings.yml.

The broad excepts had also been hiding a broken fixture:
test_launch_environment's font and voice stubs did not accept `seed`, so
every draw there raised and was swallowed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jake Writer
2026-09-25 20:34:17 -06:00
co-authored by Claude Opus 5.5
parent 9a42b6a17f
commit 165e68f831
7 changed files with 243 additions and 57 deletions
+64 -17
View File
@@ -1,13 +1,37 @@
import inspect
import platform
import warnings
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path
from typing import Optional
from typing import Optional, Type
from camoufox.pkgman import load_yaml
WARNINGS_DATA = load_yaml('warnings.yml')
def _warn_from_caller(message: str, category: Type[Warning]) -> None:
"""Attribute the warning to the first frame outside this package, so it
points at the user's call rather than at camoufox internals."""
current_module = Path(__file__).parent
frame = inspect.currentframe()
while frame:
if not Path(frame.f_code.co_filename).is_relative_to(current_module):
break
frame = frame.f_back
if frame:
warnings.warn_explicit(
message,
category=category,
filename=frame.f_code.co_filename,
lineno=frame.f_lineno,
)
return
warnings.warn(message, category=category)
class LeakWarning(RuntimeWarning):
"""
Raised when a the user has a setting enabled that can cause detection.
@@ -23,22 +47,45 @@ class LeakWarning(RuntimeWarning):
return
if i_know_what_im_doing is not None:
warning += '\nIf this is intentional, pass `i_know_what_im_doing=True`.'
_warn_from_caller(warning, LeakWarning)
# Get caller information
current_module = Path(__file__).parent
frame = inspect.currentframe()
while frame:
if not Path(frame.f_code.co_filename).is_relative_to(current_module):
break
frame = frame.f_back
if frame:
warnings.warn_explicit(
warning,
category=LeakWarning,
filename=frame.f_code.co_filename,
lineno=frame.f_lineno,
)
return
def _browser_version() -> str:
from camoufox.exceptions import CamoufoxNotInstalled
from camoufox.pkgman import installed_verstr
warnings.warn(warning, category=LeakWarning)
try:
return installed_verstr()
except CamoufoxNotInstalled:
return 'not installed'
class FallbackWarning(RuntimeWarning):
"""
Raised when part of an identity could not be drawn and a substitute was used.
"""
@staticmethod
def warn(what: str, instead: str, error: Exception, identity: Optional[str] = None) -> None:
"""
Warns that `what` failed with `error` and the identity uses `instead`,
with a block of versions and the error for the user to paste into an issue.
"""
try:
camoufox_version = version('camoufox')
except PackageNotFoundError:
camoufox_version = 'source checkout'
lines = [
f'camoufox: {camoufox_version}',
f'browser: {_browser_version()}',
f'os: {platform.platform()}',
f'python: {platform.python_version()}',
f'error: {type(error).__name__}: {error}',
]
if identity:
lines.append(f'identity: {identity}')
report = '\n'.join(f' {line}' for line in lines)
_warn_from_caller(
WARNINGS_DATA['fallback'].format(what=what, instead=instead, report=report),
FallbackWarning,
)
+46 -16
View File
@@ -3,12 +3,14 @@ import json
import os
import re
import secrets
import sqlite3
import unicodedata
from dataclasses import asdict, dataclass, is_dataclass
from pathlib import Path
from random import Random, choice, randint, randrange
from typing import Any, Dict, FrozenSet, List, Optional, Set, Tuple
from camoufox._warnings import FallbackWarning
from camoufox.pkgman import load_yaml
from camoufox.webgl import sample_webgl
@@ -399,7 +401,10 @@ def _load_font_groups() -> Dict[str, List[Dict[str, Any]]]:
try:
with open(path, 'rb') as f:
_FONT_GROUPS_CACHE = json.loads(f.read())
except (OSError, ValueError):
except (OSError, ValueError) as e:
FallbackWarning.warn(
'Reading font-groups.json', 'an OS-version base with no font additions', e
)
_FONT_GROUPS_CACHE = {}
return _FONT_GROUPS_CACHE
@@ -422,7 +427,10 @@ def _load_font_bases() -> Dict[str, List[Dict[str, Any]]]:
try:
with open(path, 'rb') as f:
_FONT_BASES_CACHE = json.loads(f.read())
except (OSError, ValueError):
except (OSError, ValueError) as e:
FallbackWarning.warn(
'Reading font-bases.json', 'only the always-present core fonts as its OS base', e
)
_FONT_BASES_CACHE = {}
return _FONT_BASES_CACHE
@@ -1660,10 +1668,16 @@ def from_preset(preset: Dict, ff_version: Optional[str] = None, salt: Optional[i
target_os = 'linux'
else:
target_os = 'macos'
preset_key = f"{config.get('navigator.userAgent')} / {config.get('webGl:renderer')}"
try:
config['fonts'] = _generate_random_font_subset(target_os, seed=identity_seed(config, salt))
except Exception:
# Fallback to preset fonts if font generation fails
except (OSError, ValueError) as e:
FallbackWarning.warn(
'Drawing the font list',
"the preset's recorded fonts" if preset.get('fonts') else "the browser's own fonts",
e,
preset_key,
)
if preset.get('fonts'):
fonts = list(preset['fonts'])
_ensure_marker_fonts(fonts, {
@@ -1675,7 +1689,13 @@ def from_preset(preset: Dict, ff_version: Optional[str] = None, salt: Optional[i
# Generate a unique random voice subset from the OS voice list
try:
config['voices'] = _generate_random_voice_subset(target_os, seed=identity_seed(config, salt))
except Exception:
except (OSError, ValueError, KeyError) as e:
FallbackWarning.warn(
'Drawing the speech voices',
"the preset's recorded voices" if preset.get('speechVoices') else "the browser's own voices",
e,
preset_key,
)
if preset.get('speechVoices'):
config['voices'] = _normalize_preset_voices(
preset['speechVoices'], target_os
@@ -1825,15 +1845,21 @@ def generate_context_fingerprint(
if 'fonts' not in config:
try:
config['fonts'] = _generate_random_font_subset(os_name, seed=identity_seed(config, _salt))
except Exception:
pass
except (OSError, ValueError) as e:
FallbackWarning.warn(
'Drawing the font list', "the browser's launch-time fonts", e,
config.get('navigator.userAgent'),
)
# Add voices (fpgen.yml does not map these yet)
if 'voices' not in config:
try:
config['voices'] = _generate_random_voice_subset(os_name, seed=identity_seed(config, _salt))
except Exception:
pass
except (OSError, ValueError, KeyError) as e:
FallbackWarning.warn(
'Drawing the speech voices', "the browser's launch-time voices", e,
config.get('navigator.userAgent'),
)
# Derive oscpu if the fingerprint didn't provide it
if 'navigator.oscpu' not in config:
@@ -1857,19 +1883,23 @@ def generate_context_fingerprint(
_target_os = 'lin'
else:
_target_os = 'mac'
# Same coherence treatment launch_options applies (#729): lift
# netbook geometry, then keep the GPU consistent with whatever
# screen this identity ended up with. This path has no real
# display to reconcile against, so the floor is unconditional.
raise_screen_to_modern_floor(config)
try:
# Same coherence treatment launch_options applies (#729): lift
# netbook geometry, then keep the GPU consistent with whatever
# screen this identity ended up with. This path has no real
# display to reconcile against, so the floor is unconditional.
raise_screen_to_modern_floor(config)
webgl_fp = sample_webgl_for_screen(
_target_os, config.get('screen.width'), config.get('screen.height')
)
except (ValueError, sqlite3.Error) as e:
FallbackWarning.warn(
'Drawing the WebGL GPU', "the browser's launch-time GPU", e,
config.get('navigator.userAgent'),
)
else:
webgl_fp.pop('webGl2Enabled', None)
config.update(webgl_fp)
except Exception:
pass
# Build source dicts from the fingerprint config for init_values
nav = {
+23 -20
View File
@@ -40,7 +40,7 @@ from .pkgman import (
launch_path,
)
from .virtdisplay import VirtualDisplay
from ._warnings import LeakWarning
from ._warnings import FallbackWarning, LeakWarning
from .webgl import sample_webgl
ListOrString: TypeAlias = Union[Tuple[str, ...], List[str], str]
@@ -1146,7 +1146,11 @@ def launch_options(
# OS base is claimed
native=(target_os in ('mac', 'win') and _host_os_key() == target_os),
)
except Exception:
except (OSError, ValueError) as e:
FallbackWarning.warn(
'Drawing the font list', f"every font fonts.json lists for {target_os}", e,
config.get('navigator.userAgent'),
)
update_fonts(config, target_os)
# Draw the identity's media devices (counts + OS-style labels/groups from
@@ -1374,10 +1378,13 @@ def launch_options(
config['voices'] = _generate_random_voice_subset(
os_name_v, voice_locale, seed=identity_seed(config, _identity_salt)
)
except Exception:
except (OSError, ValueError, KeyError) as e:
# An empty list still blocks the host's voices (see below), so a
# generation failure degrades to "no voices" rather than "all of
# the host's".
FallbackWarning.warn(
'Drawing the speech voices', 'no speech voices', e, config.get('navigator.userAgent')
)
config['voices'] = []
# Pin the block explicitly instead of relying on a non-empty list to imply
@@ -1423,21 +1430,12 @@ def launch_options(
# Preset already set vendor/renderer — sample matching WebGL params
try:
webgl_fp = sample_webgl(target_os, config['webGl:vendor'], config['webGl:renderer'], seed=identity_seed(config, _identity_salt))
except ValueError:
# The pair is not in webgl_data.db, which holds 31 GPUs. 39 of the
# 435 bundled presets name one it does not have -- including rows
# that cannot be the OS they are filed under, e.g. a Windows
# preset claiming "ANGLE (Unknown, Adreno (TM) 650 ...)", a phone
# GPU. Raising here made launch_options() fail outright for ~9% of
# presets, and a caller passing their own preset dict had no way
# to know which pairs are supported.
#
# There is no way to keep the named GPU: the parameters, extension
# list and shader precisions all have to come from a real recorded
# device, and there is none for an unknown renderer. So draw a GPU
# that fits the screen and let it replace the pair -- the identity
# loses the preset's GPU string but stays internally coherent,
# which is the property that matters to a page reading both.
except ValueError as e:
# The pair is not in webgl_data.db, which holds 31 GPUs; 36 of the
# 397 bundled presets name one it does not have. The parameters,
# extension list and shader precisions must all come from one
# recorded device, and there is none for this renderer, so the
# identity takes a GPU drawn to fit the screen instead.
webgl_fp = sample_webgl_for_screen(
target_os, config.get('screen.width'), config.get('screen.height'),
seed=identity_seed(config, _identity_salt),
@@ -1447,8 +1445,13 @@ def launch_options(
# preset's renderer string with another device's parameters,
# extensions and shader precisions behind it -- a mismatch louder
# than the unknown GPU we are replacing.
config.pop('webGl:vendor', None)
config.pop('webGl:renderer', None)
preset_gpu = f"{config.pop('webGl:vendor', None)} / {config.pop('webGl:renderer', None)}"
FallbackWarning.warn(
f"Finding the preset's GPU ({preset_gpu}) in webgl_data.db",
f"a GPU drawn to fit the screen ({webgl_fp['webGl:renderer']})",
e,
config.get('navigator.userAgent'),
)
else:
# Synthetic path: keep the GPU coherent with the screen BrowserForge
# already picked. Sampling the two independently yields pairs no
+7
View File
@@ -61,3 +61,10 @@ max_touch_points: >-
`(pointer: coarse)` false and `ontouchstart` absent, exactly as a real one does.
The rest of your fingerprint is not adjusted to suit, so a device that claims a digitizer
but reports a screen size no touchscreen laptop ships with is still inconsistent.
fallback: |-
{what} failed, so this identity uses {instead} instead.
A substitute is not drawn to match the rest of the identity, and a page may be able to tell.
Please report this at https://github.com/daijro/camoufox/issues/new and include:
{report}
+96
View File
@@ -0,0 +1,96 @@
"""Every place an identity falls back to a substitute value says so.
A substitute is a value the rest of the identity was not drawn to match, which
a page can see. Each site warns with a report block the user can paste into a
GitHub issue, so the failure reaches us instead of shipping silently.
"""
import sqlite3
import pytest
from test_identity_salt import launch
from camoufox import fingerprints as fp
from camoufox import utils
from camoufox._warnings import FallbackWarning
REPORT = r"Please report this at https://github\.com/daijro/camoufox/issues/new"
def _fail(error):
def raiser(*_args, **_kwargs):
raise error
return raiser
def _preset():
preset = fp.load_presets("152")["presets"]["windows"][0]
return {**preset, "fonts": ["Arial"]}
def _report(record):
(warning,) = [w for w in record if w.category is FallbackWarning]
text = str(warning.message)
assert "camoufox:" in text and "python:" in text and "os:" in text
return text
def test_preset_font_draw(monkeypatch):
monkeypatch.setattr(fp, "_generate_random_font_subset", _fail(OSError("fonts.json missing")))
with pytest.warns(FallbackWarning, match=REPORT) as record:
config = fp.from_preset(_preset(), "152")
assert "OSError: fonts.json missing" in _report(record)
assert "Arial" in config["fonts"]
def test_preset_voice_draw(monkeypatch):
monkeypatch.setattr(fp, "_generate_random_voice_subset", _fail(ValueError("bad manifest")))
with pytest.warns(FallbackWarning, match=REPORT) as record:
fp.from_preset(_preset(), "152")
assert "ValueError: bad manifest" in _report(record)
@pytest.mark.parametrize(
"target, error, key",
[
("_generate_random_font_subset", OSError("fonts.json missing"), "fonts"),
("_generate_random_voice_subset", ValueError("bad manifest"), "voices"),
("sample_webgl_for_screen", sqlite3.OperationalError("no such table"), "webGl:renderer"),
],
)
def test_context_draws(monkeypatch, target, error, key):
monkeypatch.setattr(fp, target, _fail(error))
with pytest.warns(FallbackWarning, match=REPORT) as record:
context = fp.generate_context_fingerprint(os="linux")
assert f"{type(error).__name__}: {error}" in _report(record)
assert key not in context["config"]
@pytest.mark.parametrize(
"loader, cache", [("_load_font_groups", "_FONT_GROUPS_CACHE"), ("_load_font_bases", "_FONT_BASES_CACHE")]
)
def test_font_data_loaders(monkeypatch, tmp_path, loader, cache):
monkeypatch.setattr(fp, cache, None)
monkeypatch.setattr(fp, "__file__", str(tmp_path / "fingerprints.py"))
with pytest.warns(FallbackWarning, match=REPORT) as record:
assert getattr(fp, loader)() == {}
assert "FileNotFoundError" in _report(record)
def test_launch_font_draw(monkeypatch):
monkeypatch.setattr(utils, "_generate_random_font_subset", _fail(OSError("font-bases.json missing")))
with pytest.warns(FallbackWarning, match=REPORT):
config = launch()
assert config["fonts"]
def test_preset_gpu_not_in_webgl_data():
# Windows reports every GPU through ANGLE, so only an ANGLE string reaches the lookup.
GPU = "ANGLE (Acme, Acme GPU 9000 Direct3D11 vs_5_0 ps_5_0)"
preset = fp.load_presets("152")["presets"]["windows"][0]
preset = {**preset, "webgl": {"unmaskedVendor": "Google Inc. (Acme)", "unmaskedRenderer": GPU}}
with pytest.warns(FallbackWarning, match=REPORT) as record:
config = launch(os="windows", fingerprint_preset=preset)
assert GPU in _report(record)
assert config["webGl:renderer"] != GPU
+2 -2
View File
@@ -14,8 +14,8 @@ def isolated_launch_dependencies(monkeypatch):
monkeypatch.setattr(utils, "generate_fingerprint", lambda *args, **kwargs: object())
monkeypatch.setattr(utils, "from_fpgen", lambda *args, **kwargs: {})
monkeypatch.setattr(utils, "get_screen_cons", lambda *args, **kwargs: None)
monkeypatch.setattr(utils, "_generate_random_font_subset", lambda *args: [])
monkeypatch.setattr(utils, "_generate_random_voice_subset", lambda *args: [])
monkeypatch.setattr(utils, "_generate_random_font_subset", lambda *args, **kwargs: [])
monkeypatch.setattr(utils, "_generate_random_voice_subset", lambda *args, **kwargs: [])
monkeypatch.setattr(utils, "fix_navigator_arch", lambda *args: None)
monkeypatch.setattr(utils, "fix_screen_no_taskbar", lambda *args: None)
monkeypatch.setattr(utils, "clamp_window_dimensions", lambda *args: None)
+5 -2
View File
@@ -152,11 +152,14 @@ def test_caller_can_override_the_block_flag():
def test_voice_generation_failure_fails_closed(monkeypatch):
import camoufox.utils as utils
from camoufox._warnings import FallbackWarning
def boom(*_args, **_kwargs):
raise RuntimeError("voice-manifests.json unreadable")
raise OSError("voice-manifests.json unreadable")
monkeypatch.setattr(utils, "_generate_random_voice_subset", boom)
cfg = _launch_config(os="macos")
with pytest.warns(FallbackWarning, match="github.com/daijro/camoufox/issues/new"):
cfg = _launch_config(os="macos")
# An empty list plus the block flag means "no voices" -- never "all of the
# host's".
assert cfg["voices"] == []