diff --git a/src/bun.zig b/src/bun.zig index 75f32dd43c..ab170b993d 100644 --- a/src/bun.zig +++ b/src/bun.zig @@ -86,6 +86,52 @@ pub inline fn clampFloat(_self: anytype, min: @TypeOf(_self), max: @TypeOf(_self return self; } +/// Converts a floating-point value to an integer following Rust semantics. +/// This provides safe conversion that mimics Rust's `as` operator behavior, +/// unlike Zig's `@intFromFloat` which panics on out-of-range values. +/// +/// Conversion rules: +/// - If finite and within target integer range: truncates toward zero +/// - If NaN: returns 0 +/// - If out-of-range (including infinities): clamp to target min/max bounds +pub fn intFromFloat(comptime Int: type, value: anytype) Int { + const Float = @TypeOf(value); + comptime { + // Simple type check - let the compiler do the heavy lifting + if (!(Float == f32 or Float == f64)) { + @compileError("intFromFloat: value must be f32 or f64"); + } + } + + // Handle NaN + if (std.math.isNan(value)) { + return 0; + } + + // Handle out-of-range values (including infinities) + const min_int = std.math.minInt(Int); + const max_int = std.math.maxInt(Int); + + // Check the truncated value directly against integer bounds + const truncated = @trunc(value); + + // Use f64 for comparison to avoid precision issues + if (truncated > @as(f64, @floatFromInt(max_int))) { + return max_int; + } + if (truncated < @as(f64, @floatFromInt(min_int))) { + return min_int; + } + + // Additional safety check: ensure we can safely convert + if (truncated != truncated) { // Check for NaN in truncated value + return 0; + } + + // Safe to convert - truncate toward zero + return @as(Int, @intFromFloat(truncated)); +} + /// We cannot use a threadlocal memory allocator for FileSystem-related things /// FileSystem is a singleton. pub const fs_allocator = default_allocator; diff --git a/src/css/css_internals.zig b/src/css/css_internals.zig index 169f86ff19..74f4db425f 100644 --- a/src/css/css_internals.zig +++ b/src/css/css_internals.zig @@ -186,63 +186,63 @@ fn targetsFromJS(globalThis: *jsc.JSGlobalObject, jsobj: JSValue) bun.JSError!bu if (try jsobj.getTruthy(globalThis, "android")) |val| { if (val.isInt32()) { if (val.getNumber()) |value| { - targets.android = @intFromFloat(value); + targets.android = bun.intFromFloat(u32, value); } } } if (try jsobj.getTruthy(globalThis, "chrome")) |val| { if (val.isInt32()) { if (val.getNumber()) |value| { - targets.chrome = @intFromFloat(value); + targets.chrome = bun.intFromFloat(u32, value); } } } if (try jsobj.getTruthy(globalThis, "edge")) |val| { if (val.isInt32()) { if (val.getNumber()) |value| { - targets.edge = @intFromFloat(value); + targets.edge = bun.intFromFloat(u32, value); } } } if (try jsobj.getTruthy(globalThis, "firefox")) |val| { if (val.isInt32()) { if (val.getNumber()) |value| { - targets.firefox = @intFromFloat(value); + targets.firefox = bun.intFromFloat(u32, value); } } } if (try jsobj.getTruthy(globalThis, "ie")) |val| { if (val.isInt32()) { if (val.getNumber()) |value| { - targets.ie = @intFromFloat(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 = @intFromFloat(value); + targets.ios_saf = bun.intFromFloat(u32, value); } } } if (try jsobj.getTruthy(globalThis, "opera")) |val| { if (val.isInt32()) { if (val.getNumber()) |value| { - targets.opera = @intFromFloat(value); + targets.opera = bun.intFromFloat(u32, value); } } } if (try jsobj.getTruthy(globalThis, "safari")) |val| { if (val.isInt32()) { if (val.getNumber()) |value| { - targets.safari = @intFromFloat(value); + targets.safari = bun.intFromFloat(u32, value); } } } if (try jsobj.getTruthy(globalThis, "samsung")) |val| { if (val.isInt32()) { if (val.getNumber()) |value| { - targets.samsung = @intFromFloat(value); + targets.samsung = bun.intFromFloat(u32, value); } } } diff --git a/src/css/css_parser.zig b/src/css/css_parser.zig index 53b75645cc..38bcbc3916 100644 --- a/src/css/css_parser.zig +++ b/src/css/css_parser.zig @@ -5134,21 +5134,10 @@ const Tokenizer = struct { } } - const int_value: ?i32 = brk: { - const i32_max = comptime std.math.maxInt(i32); - const i32_min = comptime std.math.minInt(i32); - if (is_integer) { - if (value >= @as(f64, @floatFromInt(i32_max))) { - break :brk i32_max; - } else if (value <= @as(f64, @floatFromInt(i32_min))) { - break :brk i32_min; - } else { - break :brk @intFromFloat(value); - } - } - - break :brk null; - }; + const int_value: ?i32 = if (is_integer) + bun.intFromFloat(i32, value) + else + null; if (!this.isEof() and this.nextByteUnchecked() == '%') { this.advance(1); @@ -6785,7 +6774,11 @@ pub const serializer = struct { } pub fn serializeDimension(value: f32, unit: []const u8, comptime W: type, dest: *Printer(W)) PrintErr!void { - const int_value: ?i32 = if (fract(value) == 0.0) @intFromFloat(value) else null; + // Check if the value is an integer - use Rust-compatible conversion + const int_value: ?i32 = if (fract(value) == 0.0) + bun.intFromFloat(i32, value) + else + null; const token = Token{ .dimension = .{ .num = .{ .has_sign = value < 0.0, diff --git a/src/css/properties/custom.zig b/src/css/properties/custom.zig index 010f9655e4..19e8f446f4 100644 --- a/src/css/properties/custom.zig +++ b/src/css/properties/custom.zig @@ -794,7 +794,7 @@ pub const UnresolvedColor = union(enum) { ) PrintErr!void { const Helper = struct { pub fn conv(c: f32) i32 { - return @intFromFloat(bun.clamp(@round(c * 255.0), 0.0, 255.0)); + return bun.intFromFloat(i32, bun.clamp(@round(c * 255.0), 0.0, 255.0)); } }; diff --git a/src/css/values/color.zig b/src/css/values/color.zig index 2ba0b6ddbf..2526df5267 100644 --- a/src/css/values/color.zig +++ b/src/css/values/color.zig @@ -144,7 +144,7 @@ pub const CssColor = union(enum) { // Try first with two decimal places, then with three. var rounded_alpha = @round(color.alphaF32() * 100.0) / 100.0; - const clamped: u8 = @intFromFloat(@min( + const clamped: u8 = bun.intFromFloat(u8, @min( @max( @round(rounded_alpha * 255.0), 0.0, @@ -1150,9 +1150,9 @@ fn parseRgb(input: *css.Parser, parser: *ComponentParser) Result(CssColor) { if (is_legacy) return .{ .result = .{ .rgba = RGBA.new( - @intFromFloat(r), - @intFromFloat(g), - @intFromFloat(b), + bun.intFromFloat(u8, r), + bun.intFromFloat(u8, g), + bun.intFromFloat(u8, b), alpha, ), }, @@ -1428,7 +1428,7 @@ fn clamp_unit_f32(val: f32) u8 { } fn clamp_floor_256_f32(val: f32) u8 { - return @intFromFloat(@min(255.0, @max(0.0, @round(val)))); + return bun.intFromFloat(u8, @min(255.0, @max(0.0, @round(val)))); // val.round().max(0.).min(255.) as u8 } diff --git a/src/css/values/color_js.zig b/src/css/values/color_js.zig index ff6b7ec16a..99c9a287ef 100644 --- a/src/css/values/color_js.zig +++ b/src/css/values/color_js.zig @@ -198,7 +198,7 @@ pub fn jsFunctionColor(globalThis: *jsc.JSGlobalObject, callFrame: *jsc.CallFram const a: ?u8 = if (try args[0].getTruthy(globalThis, "a")) |a_value| brk2: { if (a_value.isNumber()) { - break :brk2 @intCast(@mod(@as(i64, @intFromFloat(a_value.asNumber() * 255.0)), 256)); + break :brk2 @intCast(@mod(@as(i64, bun.intFromFloat(i64, a_value.asNumber() * 255.0)), 256)); } break :brk2 null; } else null; diff --git a/src/css/values/percentage.zig b/src/css/values/percentage.zig index 3190b9a8b4..3fbf43cabe 100644 --- a/src/css/values/percentage.zig +++ b/src/css/values/percentage.zig @@ -27,7 +27,7 @@ pub const Percentage = struct { pub fn toCss(this: *const @This(), comptime W: type, dest: *Printer(W)) PrintErr!void { const x = this.v * 100.0; const int_value: ?i32 = if ((x - @trunc(x)) == 0.0) - @intFromFloat(this.v) + bun.intFromFloat(i32, this.v) else null; diff --git a/test/internal/int_from_float.test.ts b/test/internal/int_from_float.test.ts new file mode 100644 index 0000000000..f3ff691b0d --- /dev/null +++ b/test/internal/int_from_float.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, test } from "bun:test"; +import { tempDirWithFiles } from "harness"; + +/** + * Tests for bun.intFromFloat function + * + * This function implements Rust-like semantics for float-to-integer conversion: + * - If finite and within target integer range: truncates toward zero + * - If NaN: returns 0 + * - If positive infinity: returns target max value + * - If negative infinity: returns target min value + * - If finite but larger than target max: returns target max value + * - If finite but smaller than target min: returns target min value + */ + +// Helper function to normalize CSS output for snapshots +function normalizeCSSOutput(output: string): string { + return output + .replace(/\/\*.*?\*\//g, "/* [path] */") // Replace comment paths + .trim(); +} + +describe("bun.intFromFloat function", () => { + test("handles normal finite values within range", async () => { + // Test CSS dimension serialization which uses intFromFloat(i32, value) + const dir = tempDirWithFiles("int-from-float-normal", { + "input.css": ".test { width: 42px; height: -10px; margin: 0px; }", + }); + + const result = await Bun.build({ + entrypoints: [`${dir}/input.css`], + outdir: dir, + }); + + expect(result.success).toBe(true); + expect(result.logs).toHaveLength(0); + + const output = await result.outputs[0].text(); + expect(normalizeCSSOutput(output)).toMatchInlineSnapshot(` + "/* [path] */ + .test { + width: 42px; + height: -10px; + margin: 0; + }" + `); + }); + + test("handles extremely large values (original crash case)", async () => { + // This was the original failing case - large values should not crash + const dir = tempDirWithFiles("int-from-float-large", { + "input.css": ` +.test-large { border-radius: 3.40282e38px; } +.test-negative-large { border-radius: -3.40282e38px; } +`, + }); + + const result = await Bun.build({ + entrypoints: [`${dir}/input.css`], + outdir: dir, + }); + + expect(result.success).toBe(true); + expect(result.logs).toHaveLength(0); + + const output = await result.outputs[0].text(); + expect(normalizeCSSOutput(output)).toMatchInlineSnapshot(` + "/* [path] */ + .test-large { + border-radius: 3.40282e+38px; + } + + .test-negative-large { + border-radius: -3.40282e+38px; + }" + `); + }); + + test("handles percentage values", async () => { + // Test percentage conversion which uses intFromFloat(i32, value) + const dir = tempDirWithFiles("int-from-float-percentage", { + "input.css": ` +.test-percent1 { width: 50%; } +.test-percent2 { width: 100.0%; } +.test-percent3 { width: 33.333%; } +`, + }); + + const result = await Bun.build({ + entrypoints: [`${dir}/input.css`], + outdir: dir, + }); + + expect(result.success).toBe(true); + expect(result.logs).toHaveLength(0); + + const output = await result.outputs[0].text(); + expect(normalizeCSSOutput(output)).toMatchInlineSnapshot(` + "/* [path] */ + .test-percent1 { + width: 50%; + } + + .test-percent2 { + width: 100%; + } + + .test-percent3 { + width: 33.333%; + }" + `); + }); + + test("fractional values that should not convert to int", async () => { + // Test that fractional values are properly handled + const dir = tempDirWithFiles("int-from-float-fractional", { + "input.css": ` +.test-frac { + width: 10.5px; + height: 3.14159px; + margin: 2.718px; +} +`, + }); + + const result = await Bun.build({ + entrypoints: [`${dir}/input.css`], + outdir: dir, + }); + + expect(result.success).toBe(true); + expect(result.logs).toHaveLength(0); + + const output = await result.outputs[0].text(); + expect(normalizeCSSOutput(output)).toMatchInlineSnapshot(` + "/* [path] */ + .test-frac { + width: 10.5px; + height: 3.14159px; + margin: 2.718px; + }" + `); + }); +}); diff --git a/test/regression/issue/21907.test.ts b/test/regression/issue/21907.test.ts new file mode 100644 index 0000000000..65eabff613 --- /dev/null +++ b/test/regression/issue/21907.test.ts @@ -0,0 +1,123 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDirWithFiles } from "harness"; + +test("CSS parser should handle extremely large floating-point values without crashing", async () => { + // Test for regression of issue #21907: "integer part of floating point value out of bounds" + // This was causing crashes on Windows when processing TailwindCSS with rounded-full class + + const dir = tempDirWithFiles("css-large-float-regression", { + "input.css": ` +/* Tests intFromFloat(i32, value) in serializeDimension */ +.test-rounded-full { + border-radius: 3.40282e38px; + width: 2147483648px; + height: -2147483649px; +} + +.test-negative { + border-radius: -3.40282e38px; +} + +.test-very-large { + border-radius: 999999999999999999999999999999999999999px; +} + +.test-large-integer { + border-radius: 340282366920938463463374607431768211456px; +} + +/* Tests intFromFloat(u8, value) in color conversion */ +.test-colors { + color: rgb(300, -50, 1000); + background: rgba(999.9, 0.1, -10.5, 1.5); +} + +/* Tests intFromFloat(i32, value) in percentage handling */ +.test-percentages { + width: 999999999999999999%; + height: -999999999999999999%; +} + +/* Tests edge cases around integer boundaries */ +.test-boundaries { + margin: 2147483647px; /* i32 max */ + padding: -2147483648px; /* i32 min */ + left: 4294967295px; /* u32 max */ +} + +/* Tests normal values */ +.test-normal { + width: 10px; + height: 20.5px; + margin: 0px; +} +`, + }); + + // This would previously crash with "integer part of floating point value out of bounds" + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "input.css", "--outdir", "out"], + env: bunEnv, + cwd: dir, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // Should not crash and should exit successfully + expect(exitCode).toBe(0); + expect(stderr).not.toContain("panic"); + expect(stderr).not.toContain("integer part of floating point value out of bounds"); + + // Verify the output CSS is properly processed with intFromFloat conversions + const outputContent = await Bun.file(`${dir}/out/input.css`).text(); + + // Helper function to normalize CSS output for snapshots + function normalizeCSSOutput(output: string): string { + return output + .replace(/\/\*.*?\*\//g, "/* [path] */") // Replace comment paths + .trim(); + } + + // Test the actual output with inline snapshot - this ensures all intFromFloat + // conversions work correctly and captures any changes in output format + expect(normalizeCSSOutput(outputContent)).toMatchInlineSnapshot(` + "/* [path] */ + .test-rounded-full { + border-radius: 3.40282e+38px; + width: 2147480000px; + height: -2147480000px; + } + + .test-negative { + border-radius: -3.40282e+38px; + } + + .test-very-large, .test-large-integer { + border-radius: 3.40282e38px; + } + + .test-colors { + color: #f0f; + background: red; + } + + .test-percentages { + width: 1000000000000000000%; + height: -1000000000000000000%; + } + + .test-boundaries { + margin: 2147480000px; + padding: -2147480000px; + left: 4294970000px; + } + + .test-normal { + width: 10px; + height: 20.5px; + margin: 0; + }" + `); +});