Skip to content

fix(js): keep the performance time origin out of the future - #673

Open
aech wants to merge 1 commit into
h4ckf0r0day:mainfrom
aech:fix/performance-timeorigin-not-in-future
Open

fix(js): keep the performance time origin out of the future#673
aech wants to merge 1 commit into
h4ckf0r0day:mainfrom
aech:fix/performance-timeorigin-not-in-future

Conversation

@aech

@aech aech commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

What changed

Fixes #672.

performance.timeOrigin carried a two-sided jitter of [-50, +49] ms, so about half of all navigations placed it ahead of the wall clock. performance.now() computes Date.now() - timeOrigin and went negative for up to 50 ms after every such navigation, and four derived surfaces went with it: the requestAnimationFrame timestamp, IntersectionObserverEntry.time, performance.timing.navigationStart and document.timeline.currentTime.

The jitter now skews into the past only:

const t0 = Date.now() - 1 - Math.floor(_fpRand(641) * 100);

The spread stays 100 ms, so the plausible deviation the jitter was added for in #218 is preserved. Headless Chromium 151 places its origin 68 to 147 ms in the past over six runs and never ahead of the clock, so the one-sided form is also the more faithful one.

The test builds a fresh runtime per draw. __obscura_init deletes itself at the end of its own run, so one realm only ever yields a single draw of the jitter, and a loop over one runtime silently samples the same value forty times.

Validation

cargo nextest run --release --features render -p obscura-js
  388 tests run: 388 passed, 0 skipped

cargo nextest run --release --features render --no-fail-fast
  1442 tests run: 1442 passed, 4 skipped

cargo build --release -p obscura-cli --bins --features render
cargo build --release -p obscura-cli --bins --features render,stealth
cargo build --release -p obscura-cli --bins --no-default-features
cargo build --release -p obscura-cli --bins --no-default-features --features stealth
  all four finished

OBSCURA_BIN=./target/release/obscura python3 obstacle-course/run.py --runs 1 --warmup 0
  32/33, unchanged against the base. The one failing stage is observer-intersection,
  reported separately as #671 and not touched by this change.

New test time_origin_never_lands_in_the_future fails before the change with
performance.timeOrigin is 40 ms ahead of Date.now() and passes after it. Over 40 draws the
unpatched build placed 20 origins in the future, so the test does not depend on luck.

Behavioral check on the production path, ten runs each:

for i in $(seq 1 10); do
  obscura fetch 'data:text/html,<script>window.s=performance.timeOrigin-Date.now();window.n=performance.now()</script>' \
    --quiet --wait 0 --eval "JSON.stringify({skew:window.s,now:window.n})"
done

Before: 5 of 10 runs returned a negative performance.now(). After: 0 of 10.

Re-tested with --stealth on a --features render,stealth binary, twelve runs, no origin in the future.

Rendering

Not applicable.

Performance

No expected impact. The change swaps one two-sided arithmetic expression for a one-sided one with the same operation count, executed once per navigation inside __obscura_init, outside any hot path.

Observed alongside: obstacle course median latency 3027.9 ms before and 3025.7 ms after, one run of 33 stages each. That is a smoke check, not an interleaved measurement, and it only shows there is no gross regression.

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.

The origin carried a two-sided jitter of [-50, +49] ms, so about half of all
navigations placed it ahead of the wall clock. performance.now() computes
Date.now() - timeOrigin and went negative for up to 50 ms, and so did the
requestAnimationFrame timestamp, IntersectionObserverEntry.time,
performance.timing.navigationStart and document.timeline.currentTime.

Skew into the past only. The spread stays 100 ms, so the plausible deviation
the jitter was added for is preserved. Headless Chromium places its origin 68
to 147 ms in the past and never ahead of the clock.

The test builds a fresh runtime per draw because __obscura_init deletes itself
after running, so one realm only ever yields a single draw of the jitter.

Fixes h4ckf0r0day#672
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.

performance.now() returns negative values for the first ~50 ms of a page, because the timeOrigin jitter can land in the future

1 participant