mirror of
https://github.com/oven-sh/bun
synced 2026-02-12 20:09:04 +00:00
## 🐛 Problem Fixes #21907 - CSS parser was crashing with "integer part of floating point value out of bounds" when processing extremely large floating-point values like `3.40282e38px` (commonly generated by TailwindCSS `.rounded-full` class). ### Root Cause Analysis **This revealed a broader systemic issue**: The CSS parser was ported from Rust, which has different float→integer conversion semantics than Zig's `@intFromFloat`. **Zig behavior**: `@intFromFloat` panics on out-of-range values **Rust behavior**: `as` operator follows safe conversion rules: - Finite values within range: truncate toward zero - NaN: becomes 0 - Positive infinity: becomes target max value - Negative infinity: becomes target min value - Out-of-range finite values: clamp to target range The crash occurred throughout the CSS codebase wherever `@intFromFloat` was used, not just in the original failing location. ## 🔧 Comprehensive Solution ### 1. New Generic `bun.intFromFloat` Function Created a reusable function in `src/bun.zig` that implements Rust-compatible conversion semantics: ```zig pub fn intFromFloat(comptime Int: type, value: anytype) Int { // Handle NaN -> 0 if (std.math.isNan(value)) return 0; // Handle infinities -> min/max bounds if (std.math.isPositiveInf(value)) return std.math.maxInt(Int); if (std.math.isNegativeInf(value)) return std.math.minInt(Int); // Handle out-of-range values -> clamp to bounds const min_float = @as(Float, @floatFromInt(std.math.minInt(Int))); const max_float = @as(Float, @floatFromInt(std.math.maxInt(Int))); if (value > max_float) return std.math.maxInt(Int); if (value < min_float) return std.math.minInt(Int); // Safe conversion for in-range values return @as(Int, @intFromFloat(value)); } ``` ### 2. Systematic Replacement Across CSS Codebase Replaced **all 18 instances** of `@intFromFloat` in `src/css/` with `bun.intFromFloat`: | File | Conversions | Purpose | |------|-------------|---------| | `css_parser.zig` | 2 × `i32` | CSS dimension serialization | | `css_internals.zig` | 9 × `u32` | Browser target version parsing | | `values/color.zig` | 4 × `u8` | Color component conversion | | `values/color_js.zig` | 1 × `i64→u8` | Alpha channel processing | | `values/percentage.zig` | 1 × `i32` | Percentage value handling | | `properties/custom.zig` | 1 × `i32` | Color helper function | ### 3. Comprehensive Test Coverage - **New test suite**: `test/internal/int_from_float.test.ts` with inline snapshots - **Enhanced regression test**: `test/regression/issue/21907.test.ts` covering all conversion types - **Real-world testing**: Validates actual CSS processing with edge cases ## 📊 esbuild Compatibility Analysis Compared output with esbuild to ensure compatibility: **Test CSS:** ```css .test { border-radius: 3.40282e38px; } .colors { color: rgb(300, -50, 1000); } .boundaries { width: 2147483648px; } ``` **Key Differences:** 1. **Scientific notation format:** - esbuild: `3.40282e38` (no explicit + sign) - Bun: `3.40282e+38` (explicit + sign) - ✅ Both are mathematically equivalent and valid CSS 2. **Optimization strategy:** - esbuild: Preserves original literal values - Bun: Normalizes extremely large values + consolidates selectors - ✅ Bun's more aggressive optimization results in smaller output ### ❓ Question for Review **@zackradisic** - Is it acceptable for Bun to diverge from esbuild in this optimization behavior? - **Pro**: More aggressive optimization (smaller output, consistent formatting) - **Con**: Different output format than esbuild - **Impact**: Both outputs are functionally identical in browsers Should we: 1. ✅ Keep current behavior (more aggressive optimization) 2. 🔄 Match esbuild exactly (preserve literal notation) 3. 🎛️ Add flag to control this behavior ## ✅ Testing & Validation - [x] **Original crash case**: Fixed - no more panics with large floating-point values - [x] **All conversion types**: Tested i32, u32, u8, i64 conversions with edge cases - [x] **Browser compatibility**: Verified targets parsing works with extreme values - [x] **Color processing**: Confirmed RGB/RGBA values properly clamped to 0-255 range - [x] **Performance**: No regression - conversions are equally fast - [x] **Real-world**: TailwindCSS projects with `.rounded-full` work without crashes - [x] **Inline snapshots**: Capture exact expected output for future regression detection ## 🎯 Impact ### Before (Broken) ```bash $ bun build styles.css ============================================================ panic: integer part of floating point value out of bounds ``` ### After (Working) ```bash $ bun build styles.css Bundled 1 module in 93ms styles.css 121 bytes (asset) ``` - ✅ **Fixes crashes** when using TailwindCSS `.rounded-full` class on Windows - ✅ **Maintains backward compatibility** for existing projects - ✅ **Improves robustness** across all CSS float→int conversions - ✅ **Better optimization** with consistent value normalization 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude Bot <claude-bot@bun.sh> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
337 lines
12 KiB
Zig
337 lines
12 KiB
Zig
const TestKind = enum {
|
|
normal,
|
|
minify,
|
|
prefix,
|
|
};
|
|
|
|
const TestCategory = enum {
|
|
/// arg is browsers
|
|
normal,
|
|
/// arg is parser options
|
|
parser_options,
|
|
};
|
|
|
|
pub fn minifyErrorTestWithOptions(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue {
|
|
return testingImpl(globalThis, callframe, .minify, .parser_options);
|
|
}
|
|
|
|
pub fn minifyTestWithOptions(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue {
|
|
return testingImpl(globalThis, callframe, .minify, .parser_options);
|
|
}
|
|
|
|
pub fn prefixTestWithOptions(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue {
|
|
return testingImpl(globalThis, callframe, .prefix, .parser_options);
|
|
}
|
|
|
|
pub fn testWithOptions(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue {
|
|
return testingImpl(globalThis, callframe, .normal, .parser_options);
|
|
}
|
|
|
|
pub fn minifyTest(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue {
|
|
return testingImpl(globalThis, callframe, .minify, .normal);
|
|
}
|
|
|
|
pub fn prefixTest(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue {
|
|
return testingImpl(globalThis, callframe, .prefix, .normal);
|
|
}
|
|
|
|
pub fn _test(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue {
|
|
return testingImpl(globalThis, callframe, .normal, .normal);
|
|
}
|
|
|
|
pub fn testingImpl(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame, comptime test_kind: TestKind, comptime test_category: TestCategory) bun.JSError!jsc.JSValue {
|
|
var arena = bun.ArenaAllocator.init(bun.default_allocator);
|
|
defer arena.deinit();
|
|
const alloc = arena.allocator();
|
|
|
|
const arguments_ = callframe.arguments_old(3);
|
|
var arguments = jsc.CallFrame.ArgumentsSlice.init(globalThis.bunVM(), arguments_.slice());
|
|
const source_arg: jsc.JSValue = arguments.nextEat() orelse {
|
|
return globalThis.throw("minifyTestWithOptions: expected 2 arguments, got 0", .{});
|
|
};
|
|
if (!source_arg.isString()) {
|
|
return globalThis.throw("minifyTestWithOptions: expected source to be a string", .{});
|
|
}
|
|
const source_bunstr = try source_arg.toBunString(globalThis);
|
|
defer source_bunstr.deref();
|
|
const source = source_bunstr.toUTF8(bun.default_allocator);
|
|
defer source.deinit();
|
|
|
|
const expected_arg = arguments.nextEat() orelse {
|
|
return globalThis.throw("minifyTestWithOptions: expected 2 arguments, got 1", .{});
|
|
};
|
|
if (!expected_arg.isString()) {
|
|
return globalThis.throw("minifyTestWithOptions: expected `expected` arg to be a string", .{});
|
|
}
|
|
const expected_bunstr = try expected_arg.toBunString(globalThis);
|
|
defer expected_bunstr.deref();
|
|
const expected = expected_bunstr.toUTF8(bun.default_allocator);
|
|
defer expected.deinit();
|
|
|
|
const browser_options_arg = arguments.nextEat();
|
|
|
|
var log = bun.logger.Log.init(alloc);
|
|
defer log.deinit();
|
|
|
|
var browsers: ?bun.css.targets.Browsers = null;
|
|
const parser_options = parser_options: {
|
|
var opts = bun.css.ParserOptions.default(alloc, &log);
|
|
// if (test_kind == .prefix) break :parser_options opts;
|
|
|
|
switch (test_category) {
|
|
.normal => {
|
|
if (browser_options_arg) |optargs| {
|
|
if (optargs.isObject()) {
|
|
browsers = try targetsFromJS(globalThis, optargs);
|
|
}
|
|
}
|
|
},
|
|
.parser_options => {
|
|
if (browser_options_arg) |optargs| {
|
|
if (optargs.isObject()) {
|
|
try parserOptionsFromJS(globalThis, alloc, &opts, optargs);
|
|
}
|
|
}
|
|
},
|
|
}
|
|
|
|
break :parser_options opts;
|
|
};
|
|
|
|
var import_records = bun.BabyList(bun.ImportRecord){};
|
|
switch (bun.css.StyleSheet(bun.css.DefaultAtRule).parse(
|
|
alloc,
|
|
source.slice(),
|
|
parser_options,
|
|
&import_records,
|
|
bun.bundle_v2.Index.invalid,
|
|
)) {
|
|
.result => |ret| {
|
|
var stylesheet, var extra = ret;
|
|
var minify_options: bun.css.MinifyOptions = bun.css.MinifyOptions.default();
|
|
minify_options.targets.browsers = browsers;
|
|
_ = stylesheet.minify(alloc, minify_options, &extra).assert();
|
|
|
|
const symbols = bun.ast.Symbol.Map{};
|
|
var local_names = bun.css.LocalsResultsMap{};
|
|
const result = switch (stylesheet.toCss(
|
|
alloc,
|
|
bun.css.PrinterOptions{
|
|
.minify = switch (test_kind) {
|
|
.minify => true,
|
|
.normal => false,
|
|
.prefix => false,
|
|
},
|
|
.targets = .{
|
|
.browsers = minify_options.targets.browsers,
|
|
},
|
|
},
|
|
.initOutsideOfBundler(&import_records),
|
|
&local_names,
|
|
&symbols,
|
|
)) {
|
|
.result => |result| result,
|
|
.err => |err| {
|
|
return err.toJSString(alloc, globalThis);
|
|
},
|
|
};
|
|
|
|
return bun.String.fromBytes(result.code).toJS(globalThis);
|
|
},
|
|
.err => |err| {
|
|
if (log.hasErrors()) {
|
|
return log.toJS(globalThis, bun.default_allocator, "parsing failed:");
|
|
}
|
|
return globalThis.throw("parsing failed: {}", .{err.kind});
|
|
},
|
|
}
|
|
}
|
|
|
|
fn parserOptionsFromJS(globalThis: *jsc.JSGlobalObject, allocator: Allocator, opts: *bun.css.ParserOptions, jsobj: JSValue) bun.JSError!void {
|
|
_ = allocator; // autofix
|
|
if (try jsobj.getTruthy(globalThis, "flags")) |val| {
|
|
if (val.isArray()) {
|
|
var iter = try val.arrayIterator(globalThis);
|
|
while (try iter.next()) |item| {
|
|
const bunstr = try item.toBunString(globalThis);
|
|
defer bunstr.deref();
|
|
const str = bunstr.toUTF8(bun.default_allocator);
|
|
defer str.deinit();
|
|
if (std.mem.eql(u8, str.slice(), "DEEP_SELECTOR_COMBINATOR")) {
|
|
opts.flags.deep_selector_combinator = true;
|
|
} else {
|
|
return globalThis.throw("invalid flag: {s}", .{str.slice()});
|
|
}
|
|
}
|
|
} else {
|
|
return globalThis.throw("flags must be an array", .{});
|
|
}
|
|
}
|
|
|
|
// if (try jsobj.getTruthy(globalThis, "css_modules")) |val| {
|
|
// opts.css_modules = bun.css.css_modules.Config{
|
|
|
|
// };
|
|
// if (val.isObject()) {
|
|
// if (try val.getTruthy(globalThis, "pure")) |pure_val| {
|
|
// opts.css_modules.pure = pure_val.toBoolean();
|
|
// }
|
|
// }
|
|
// }
|
|
}
|
|
|
|
fn targetsFromJS(globalThis: *jsc.JSGlobalObject, jsobj: JSValue) bun.JSError!bun.css.targets.Browsers {
|
|
var targets = bun.css.targets.Browsers{};
|
|
|
|
if (try jsobj.getTruthy(globalThis, "android")) |val| {
|
|
if (val.isInt32()) {
|
|
if (val.getNumber()) |value| {
|
|
targets.android = bun.intFromFloat(u32, value);
|
|
}
|
|
}
|
|
}
|
|
if (try jsobj.getTruthy(globalThis, "chrome")) |val| {
|
|
if (val.isInt32()) {
|
|
if (val.getNumber()) |value| {
|
|
targets.chrome = bun.intFromFloat(u32, value);
|
|
}
|
|
}
|
|
}
|
|
if (try jsobj.getTruthy(globalThis, "edge")) |val| {
|
|
if (val.isInt32()) {
|
|
if (val.getNumber()) |value| {
|
|
targets.edge = bun.intFromFloat(u32, value);
|
|
}
|
|
}
|
|
}
|
|
if (try jsobj.getTruthy(globalThis, "firefox")) |val| {
|
|
if (val.isInt32()) {
|
|
if (val.getNumber()) |value| {
|
|
targets.firefox = bun.intFromFloat(u32, value);
|
|
}
|
|
}
|
|
}
|
|
if (try jsobj.getTruthy(globalThis, "ie")) |val| {
|
|
if (val.isInt32()) {
|
|
if (val.getNumber()) |value| {
|
|
targets.ie = bun.intFromFloat(u32, value);
|
|
}
|
|
}
|
|
}
|
|
if (try jsobj.getTruthy(globalThis, "ios_saf")) |val| {
|
|
if (val.isInt32()) {
|
|
if (val.getNumber()) |value| {
|
|
targets.ios_saf = bun.intFromFloat(u32, value);
|
|
}
|
|
}
|
|
}
|
|
if (try jsobj.getTruthy(globalThis, "opera")) |val| {
|
|
if (val.isInt32()) {
|
|
if (val.getNumber()) |value| {
|
|
targets.opera = bun.intFromFloat(u32, value);
|
|
}
|
|
}
|
|
}
|
|
if (try jsobj.getTruthy(globalThis, "safari")) |val| {
|
|
if (val.isInt32()) {
|
|
if (val.getNumber()) |value| {
|
|
targets.safari = bun.intFromFloat(u32, value);
|
|
}
|
|
}
|
|
}
|
|
if (try jsobj.getTruthy(globalThis, "samsung")) |val| {
|
|
if (val.isInt32()) {
|
|
if (val.getNumber()) |value| {
|
|
targets.samsung = bun.intFromFloat(u32, value);
|
|
}
|
|
}
|
|
}
|
|
|
|
return targets;
|
|
}
|
|
|
|
pub fn attrTest(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue {
|
|
var arena = bun.ArenaAllocator.init(bun.default_allocator);
|
|
defer arena.deinit();
|
|
const alloc = arena.allocator();
|
|
|
|
const arguments_ = callframe.arguments_old(4);
|
|
var arguments = jsc.CallFrame.ArgumentsSlice.init(globalThis.bunVM(), arguments_.slice());
|
|
const source_arg: jsc.JSValue = arguments.nextEat() orelse {
|
|
return globalThis.throw("attrTest: expected 3 arguments, got 0", .{});
|
|
};
|
|
if (!source_arg.isString()) {
|
|
return globalThis.throw("attrTest: expected source to be a string", .{});
|
|
}
|
|
const source_bunstr = try source_arg.toBunString(globalThis);
|
|
defer source_bunstr.deref();
|
|
const source = source_bunstr.toUTF8(bun.default_allocator);
|
|
defer source.deinit();
|
|
|
|
const expected_arg = arguments.nextEat() orelse {
|
|
return globalThis.throw("attrTest: expected 3 arguments, got 1", .{});
|
|
};
|
|
if (!expected_arg.isString()) {
|
|
return globalThis.throw("attrTest: expected `expected` arg to be a string", .{});
|
|
}
|
|
const expected_bunstr = try expected_arg.toBunString(globalThis);
|
|
defer expected_bunstr.deref();
|
|
const expected = expected_bunstr.toUTF8(bun.default_allocator);
|
|
defer expected.deinit();
|
|
|
|
const minify_arg: jsc.JSValue = arguments.nextEat() orelse {
|
|
return globalThis.throw("attrTest: expected 3 arguments, got 2", .{});
|
|
};
|
|
const minify = minify_arg.isBoolean() and minify_arg.toBoolean();
|
|
|
|
var targets: bun.css.targets.Targets = .{};
|
|
if (arguments.nextEat()) |arg| {
|
|
if (arg.isObject()) {
|
|
targets.browsers = try targetsFromJS(globalThis, arg);
|
|
}
|
|
}
|
|
|
|
var log = bun.logger.Log.init(alloc);
|
|
defer log.deinit();
|
|
|
|
const parser_options = bun.css.ParserOptions.default(alloc, &log);
|
|
|
|
var import_records = bun.BabyList(bun.ImportRecord){};
|
|
switch (bun.css.StyleAttribute.parse(alloc, source.slice(), parser_options, &import_records, bun.bundle_v2.Index.invalid)) {
|
|
.result => |stylesheet_| {
|
|
var stylesheet = stylesheet_;
|
|
var minify_options: bun.css.MinifyOptions = bun.css.MinifyOptions.default();
|
|
minify_options.targets = targets;
|
|
stylesheet.minify(alloc, minify_options);
|
|
|
|
const result = stylesheet.toCss(
|
|
alloc,
|
|
bun.css.PrinterOptions{
|
|
.minify = minify,
|
|
.targets = targets,
|
|
},
|
|
.initOutsideOfBundler(&import_records),
|
|
) catch |e| {
|
|
bun.handleErrorReturnTrace(e, @errorReturnTrace());
|
|
return .js_undefined;
|
|
};
|
|
|
|
return bun.String.fromBytes(result.code).toJS(globalThis);
|
|
},
|
|
.err => |err| {
|
|
if (log.hasAny()) {
|
|
return log.toJS(globalThis, bun.default_allocator, "parsing failed:");
|
|
}
|
|
return globalThis.throw("parsing failed: {}", .{err.kind});
|
|
},
|
|
}
|
|
}
|
|
|
|
const bun = @import("bun");
|
|
const std = @import("std");
|
|
const Allocator = std.mem.Allocator;
|
|
|
|
const jsc = bun.jsc;
|
|
const JSGlobalObject = bun.jsc.JSGlobalObject;
|
|
const JSValue = bun.jsc.JSValue;
|