Windows: generic font (sans-serif) resolution races at startup under font.system.whitelist - default-font rendering nondeterministic across launches
Environment
- camoufox 0.5.6 (pip), official binary 152.0.4-beta.30 (Windows x64)
- Windows 11, host locale zh-CN (likely relevant — see below)
- Python 3.11, playwright 1.62.0
Summary
With a pinned font list (config={"fonts": [...]} → font.system.whitelist), the generic/default font resolution races at startup: measureText() on a fresh 2D context (default font = 10px sans-serif) flips between the whitelisted Arial and the zh-locale fallback (Microsoft YaHei UI) across fresh launches, with identical launch options and profile directory. Rate observed: ~20–30% of launches. Explicitly setting font = '10px Arial' is stable in every launch.
Consequence: default-font canvas/text rendering is not reproducible across launches (two stable raster variants for fillText on a default-font canvas), which surfaces as flaky results for anything comparing default-font canvas output across sessions.
Minimal repro
Run several times (fresh process per run; ~1 in 3–5 launches races on my machine):
import asyncio, sys, tempfile
from camoufox.async_api import AsyncNewBrowser
from camoufox.utils import launch_options
from playwright.async_api import async_playwright
FONT_LIST = ["Arial", "Calibri", "Segoe UI", "Tahoma", "Verdana",
"Times New Roman", "Georgia", "Courier New", "Trebuchet MS", "Impact"]
PROBE = """
() => {
const g = document.createElement('canvas').getContext('2d');
const def = g.measureText('prism').width;
g.font = '10px Arial';
const arial = g.measureText('prism').width;
return { def, arial };
}
"""
async def main(data_dir):
opts = launch_options(config={"fonts": FONT_LIST}, os=("windows",),
headless=False, i_know_what_im_doing=True,
user_data_dir=data_dir)
pw = await async_playwright().start()
try:
ctx = await AsyncNewBrowser(pw, persistent_context=True, **opts)
page = ctx.pages[0] if ctx.pages else await ctx.new_page()
r = await page.evaluate(PROBE)
ok = abs(r["def"] - r["arial"]) < 0.5
print(f"default={r['def']:.2f} arial={r['arial']:.2f} -> "
+ ("Arial (ok)" if ok else "FALLBACK (raced!)"))
await ctx.close()
finally:
await pw.stop()
data_dir = sys.argv[1] if len(sys.argv) > 1 else tempfile.mkdtemp(prefix="camoufox-font-race-")
asyncio.run(main(data_dir))Example output from two consecutive runs (same profile dir, same options):
default=26.87 arial=24.45 -> FALLBACK (raced!)
default=24.45 arial=24.45 -> Arial (ok)(26.87 = Microsoft YaHei UI metrics; 24.45 = Arial.)
Evidence gathered (32 instrumented launches)
- Not the whitelist content:
prefs.jsdumps showfont.system.whitelistalways equals the passed list (Arial present in every launch, including the raced ones). - Not the rasterizer: shapes-only canvases (no text) are byte-identical across all launches.
- Not window state: visibility/DPR/inner size recorded constant across raced and clean launches.
- Per-document / per-content-process: a mid-session
page.context.new_page()could resolve differently from the first page in the same session. - Explicit fonts are always stable:
'10px Arial'resolves identically in all launches, including raced ones; genericsans-serif/serif/monospaceare the racy ones.
My guess at the mechanism: a startup race between bundled-font registration, whitelist activation, and first font-group creation — but that's speculation from the outside.
Secondary observation (maybe intended, noting for completeness)
When the fonts config key is absent, launch_options re-randomizes the font subset on every call (random()/sample() in utils.py), so font.system.whitelist changes per launch. For persistent-context / identity-replay use cases this means the enumerable font list is a new "face" on every launch. We work around it by always passing an explicit fonts list — just flagging it as a footgun for that usage pattern.
Thanks for the great project — the rest of the identity surface (fingerprint replay, seeds, locale/timezone pinning) has been rock solid for us.
Source: daijro/camoufox