SIGN IN SIGN UP
oven-sh / bun UNCLAIMED

Incredibly fast JavaScript runtime, bundler, test runner, and package manager – all in one

0 0 150 Rust

css: fix exponential backtracking in atan2() color parsing (#31558)

## What

Fixes an exponential-time hang in `Bun.color()` (and any CSS `calc()`
path) on deeply nested `atan2()` math functions, found by fuzzing.

## Repro

```js
// 4 KB of nested -(atan2(9 -(atan2(...))) inside hsl(); hangs well past 25s
Bun.color(<deeply nested atan2 CSS>, "css");
```

Reduced (via delta-debugging) to 110 bytes, the pathology is a chain of
`atan2()` nested through subtraction:

```js
Bun.color("hsl(sin(2\n-(atan2(9\n-(atan2(9\n-(atan2(atan2(9\n...", "css"); // seconds → minutes
```

Parse time grows ~5× per nesting level: depth 10 ≈ 0.6s, depth 11 ≈ 3s,
depth 13 ≈ 15s.

## Cause

`Calc::parse_atan2` (`src/css/values/calc.rs`) parses its arguments by
trying each supported value type in turn — `Length`, `Percentage`,
`Angle`, `Time`, then a `<number>` fallback — via `try_parse`, which
re-descends the **entire** argument subtree on each attempt. Because
`atan2()` calls can nest inside one another's arguments, a nested
`atan2()` is re-parsed once per ancestor type-probe, which is
exponential in the nesting depth (≈5^depth).

This is the same class of exponential backtracking fixed for
unclosed/invalid nested function values in #31243; that fix's
`unclosed_block_at_eof` guard only covers blocks left unclosed at EOF,
so it does not catch these balanced-but-nested `atan2()` chains.
`atan2()` is the only calc function that re-descends its arguments under
multiple value types.

## Fix — parse each argument once

A math function (`atan2()`, `sin()`, `pow()`, …) always resolves to an
`<angle>` or a `<number>`, never to a length/time/percentage. So
`parse_atan2` now parses each argument **once** as an
`<angle>`/`<number>` expression:

- On success, the argument contains no length/time/percentage leaf, so
the second argument is parsed the same way and reconciled. A type
mismatch is a genuine error that the dimension parses can't rescue.
- On failure, the dimension-typed parses are tried **only** if the
failure was at a real dimension leaf (e.g. `1px`). If instead a nested
`<angle>`/`<number>`-only function failed, no dimension parse can
succeed either, so re-descending it — the exponential step — is skipped.

"A nested `<angle>`/`<number>`-only function failed" is detected with a
small monotonic counter bumped at the existing `parse_value`
math-function guard, compared before/after the argument parse. It is
**not** a per-position failure cache — just an event count. Type-passing
functions (`calc()`, `min()`, `abs()`, …) are left to the dimension
parses exactly as before, so `atan2(calc(1px + 1px), 2px)` still folds.

## Verification

- New test `test/js/bun/css/atan2-backtracking-hang.test.ts` runs the
nested-`atan2()` repros plus behavior-preservation cases (each argument
type, `calc()` args, `atan2()` nested in `calc()`, invalid type
mismatches) in a subprocess with a 20s kill switch. Passes on this build
in well under a second; on an unfixed build the subprocess hangs and the
test times out (confirmed by reverting `src/` to `main` and rebuilding).
- The original 4 KB repro: **>12s → ~8ms**. A 300-deep nested chain
stays flat (linear).
- Behavior preserved: output is **identical to `main`** across all
valid/invalid `atan2()` cases (`atan2(9, 1)`, `atan2(1deg, 2deg)`,
`atan2(1px, 2px)`, `atan2(1s, 2s)`, `atan2(50%, 25%)`, `atan2(atan2(…),
atan2(…))`, `atan2(calc(1px + 1px), 2px)` → `45deg`, relative-color
channel args, and the mismatched-type rejections).
- `test/js/bun/css/color.test.ts` (915), `css.test.ts` (1087),
`nested-function-backtracking.test.ts` (5),
`angle-serialization-hang.test.ts` + `doesnt_crash.test.ts` (62) pass.
The pre-existing `fuzz ansi256` timeout (a 16.7M-iteration integer-path
loop unrelated to the CSS parser) and the pre-existing
`css-fuzz.test.ts` debug-build timeouts reproduce identically on `main`.

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
R
robobun committed
6162fb29e23466de788149353076836a48acad1c
Parent: e3481db
Committed by GitHub <noreply@github.com> on 5/29/2026, 6:51:20 PM