Skip to content

fix(js): pin the ICU default locale to navigator.language - #742

Open
ntdatt812 wants to merge 2 commits into
h4ckf0r0day:mainfrom
ntdatt812:fix/734-intl-locale
Open

fix(js): pin the ICU default locale to navigator.language#742
ntdatt812 wants to merge 2 commits into
h4ckf0r0day:mainfrom
ntdatt812:fix/734-intl-locale

Conversation

@ntdatt812

@ntdatt812 ntdatt812 commented Aug 29, 2026

Copy link
Copy Markdown

Fixes #734.

What changed

Three surfaces name a locale, and only two of them were pinned:

  • navigator.language is "en-US" and navigator.languages is ["en-US", "en"], both hardcoded in js/bootstrap.js.
  • obscura-net sends Accept-Language: en-US,en;q=0.9.
  • Intl resolves its default through ICU, and nothing under crates/ ever set one. grep -rn "set_default_locale" crates/ was empty.

So the third value was a property of whichever V8 build you happened to be running, not of the browser identity Obscura presents. The report measured en-AU from Intl against en-US from navigator.language on a Windows release build. A page that reads two of the three sees a browser disagreeing with itself, which is the kind of internal inconsistency identity checks look for, so this sits in the stealth priority the README states.

The disagreement being one-sided is what decides the fix: two surfaces are already pinned, one is inherited, so agreeing means pinning the third rather than loosening the other two. That also keeps what scripts read consistent with what actually goes out on the wire.

One constant and one call, in crates/obscura-js/src/runtime.rs:

  • DEFAULT_LOCALE names the value and, in its doc comment, the two other places that have to stay equal to it.
  • pin_default_locale() calls v8::icu::set_default_locale inside a Once.

It is called from with_base_url_and_proxy before JsRuntime::new, inside the existing ISOLATE_CREATE_LOCK block. Both parts matter: ICU is consulted when a page first constructs an Intl object, so an isolate built ahead of the call would answer from the host default, and the lock keeps the write from racing an isolate being constructed on another connection thread. That is the only JsRuntime::new site in the crate, so CLI and CDP both go through it.

A note on the constant, which does not need an answer before merging

DEFAULT_LOCALE duplicates a string that also lives in bootstrap.js and in obscura-net. I kept it that way because threading one constant across a Rust crate boundary and a JS bundle is a larger change than this bug warrants, and the doc comment records the coupling so the next person to change one has a pointer to the others. If you would rather have a single source, tell me where you want it to live and I will send that separately.

Validation

The interesting part of this one is that the obvious test does not work.

A test that just asserts the three surfaces agree passes on main, unfixed, because ICU on a Linux runner already defaults to en-US. The reported default was a Windows build's. A host default is not reproducible across machines, so asserting agreement proves nothing about whether the engine pins anything.

test_intl_default_locale_is_pinned_not_inherited_from_the_host therefore forces a different default first and asserts the engine still names its own. That is safe because nextest runs each test in its own process, so the global never reaches a sibling test.

Red on this branch with the pin_default_locale() call commented out, which is the exact shape of the reported bug, three inherited values next to two pinned ones:

assertion `left == right` failed
  left: ["fr-FR", "fr-FR", "fr-FR", "en-US", "en-US"]
 right: ["en-US", "en-US", "en-US", "en-US", "en-US"]

Green with the call restored.

@yinnho raised a V8 subtlety on the issue that the test now encodes: V8 resolves the ICU default locale once per isolate and caches it at the first Intl use, so a pin landing after a page has formatted anything silently does nothing. Their literal suggestion, format then change the configured language then format again, does not translate directly because Obscura has no runtime language switch. The half that does translate is that the pin has to survive the host default moving underneath a warmed isolate, so the test now formats, moves the host default a second time, and formats again in the same isolate.

That second block is load-bearing, and what it reports without the pin is a direct measurement of the caching they described. Host default forced to fr-FR, isolate built and warmed, host default then moved to de-DE:

  left: ["fr-FR", "1 234,5"]
 right: ["en-US", "1,234.5"]

The warmed isolate answers fr-FR, not de-DE, and formats with French narrow-no-break-space grouping. It never saw the second change. That is the cache, and it is why pin_default_locale runs before JsRuntime::new rather than anywhere later. With the pin in place both surfaces read en-US and the number formats as 1,234.5.

Since this changes shared JS-runtime code, I ran the whole four-configuration sequence from CONTRIBUTING rather than only obscura-js, in a rust:latest container so the result matches CI rather than my Windows host:

Step Result
cargo build --release -p obscura-cli --bins --features render ok
cargo nextest run --release --features render --no-fail-fast 1507 passed, 4 skipped
cargo build --release -p obscura-cli --bins --no-default-features ok
cargo nextest run --release -p obscura --no-fail-fast 23 passed
cargo nextest run --release --workspace --exclude obscura --exclude obscura-render --no-default-features --no-fail-fast 758 passed, 3 skipped
cargo check --release -p obscura-render --no-default-features ok

I have not run cargo fmt over the tree, per CONTRIBUTING; the added lines are rustfmt-clean on their own.

Rendering

Not applicable. No layout, paint, screenshot, screencast or PDF code is touched.

Performance

The call is inside a std::sync::Once, on the isolate-construction path, already under ISOLATE_CREATE_LOCK. It runs once per process and adds nothing per request, per page, or per script. There is no change to any hot path.

Checklist

  • The change is focused and does not remove existing behavior without justification.
  • Tests cover the failure or feature.
  • Existing tests pass, including render and no-render configurations when affected.
  • I checked for CPU, latency, and memory regressions.
  • Public API or user-facing behavior changes are documented.

Intl resolved its default through ICU, which nothing in the engine set, so
it reported whatever locale the V8 build carried. navigator.language,
navigator.languages and the Accept-Language default were all pinned to
en-US already, so a page reading Intl next to either of the others saw a
browser disagreeing with itself. The report measured en-AU against en-US
on a Windows release build.

Pin ICU once, before the first isolate exists and under the isolate
creation lock, from a single DEFAULT_LOCALE constant that names why the
three surfaces have to agree.

The host default is not reproducible across machines: on Linux ICU already
answers en-US, so a test that only asserts the surfaces agree passes with
or without the fix. The regression test forces a different default first
and asserts the engine still names its own, which is safe because nextest
runs each test in its own process.

Fixes h4ckf0r0day#734.
V8 resolves the ICU default locale once per isolate and caches it at the
first Intl use, so a pin that lands after a page has formatted anything
silently does nothing. The test now formats, moves the host default a
second time underneath the warmed isolate, and formats again, asserting
both the reported locale and en-US number grouping.

Raised by yinnho on h4ckf0r0day#734.
@ntdatt812

Copy link
Copy Markdown
Author

Obstacle course, for the CONTRIBUTING pre-PR item I could not tick from a unit test. Companion repo at the ref your CI pins (6ebac82), same host, same invocation, base and candidate back to back:

revision correctness median latency
main (c138019) 32/33 3016.7ms
this branch 32/33 3016.3ms

No delta. The single failure is the same stage on both, and it fails on untouched main:

observer-intersection   modern-web   FAIL   expected 'io:50', got ''

So it is a property of this host rather than of the change, and I have not chased it further since it is outside the diff. It is deterministic rather than flaky here: re-running just that stage with --runs 3 --warmup 1 reproduces it exactly. Mentioning it only in case a 32/33 on a plain rust:latest Debian container is news to you; on your ubuntu-22.04 runner it may well be 33/33.

Everything else in this PR is unchanged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Intl default locale is en-AU while navigator.language and Accept-Language are en-US

1 participant