#783·camoufox

Windows: generic font (sans-serif) resolution races at startup under font.system.whitelist - default-font rendering nondeterministic across launches

Author: xxx111oooCreated Sep 25, 2026Updated Sep 25, 2026

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):

python
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.js dumps show font.system.whitelist always 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; generic sans-serif/serif/monospace are 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.