From 918b32d7e4397d1caa390239e5fb304c35162b46 Mon Sep 17 00:00:00 2001 From: zzal Date: Mon, 20 Jul 2026 07:55:27 -0400 Subject: [PATCH] =?UTF-8?q?refactor(swiflowui):=20funnel=20Double=E2=86=92?= =?UTF-8?q?Int=20crossings=20through=20one=20WasmSafeInt=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cssPixelInt and formatControlNumber independently guarded the same wasm32 32-bit-Int trap class with their own hand-rolled bounds. Both now funnel through WasmSafeInt.exact/pixelClamp, so a new call site can't reintroduce the trap with a slightly-wrong cutoff. The gate's range check (value >= Int32.min && value <= Int32.max) also rejects NaN/±∞ for free, so the Int(_:) behind it is unconditionally trap-free. Behavior refinement: formatControlNumber now integer-formats exactly Int32.min (-2147483648) — representable and trap-free — where the old magnitude<2^31 predicate fell it to the Double rendering. New WasmSafeIntTests pin both ends of the range plus the non-finite and clamp cases. Whole-package swift test: 1922 green. Co-Authored-By: Claude Fable 5 --- Sources/SwiflowUI/DataTable.swift | 14 ++---- Sources/SwiflowUI/FieldChrome.swift | 13 ++--- Sources/SwiflowUI/WasmSafeInt.swift | 37 ++++++++++++++ Tests/SwiflowUITests/WasmSafeIntTests.swift | 56 +++++++++++++++++++++ 4 files changed, 104 insertions(+), 16 deletions(-) create mode 100644 Sources/SwiflowUI/WasmSafeInt.swift create mode 100644 Tests/SwiflowUITests/WasmSafeIntTests.swift diff --git a/Sources/SwiflowUI/DataTable.swift b/Sources/SwiflowUI/DataTable.swift index b61879fa..c477a1c5 100644 --- a/Sources/SwiflowUI/DataTable.swift +++ b/Sources/SwiflowUI/DataTable.swift @@ -124,16 +124,10 @@ public func DataTable( } } -/// Clamp a pixel quantity to a trap-free `Int`. `Int(_:)` on a Double past -/// ±2^31 TRAPS on wasm32 (host Int is 64-bit and HIDES it — the class that -/// bit the query clock, PR #154), and raw Int multiplication overflows the -/// same bound. CSS lengths beyond ~2^31 px are meaningless anyway (browsers -/// cap element sizes far lower), so clamping identically on EVERY platform -/// keeps host tests honest about wasm behavior. Non-finite inputs → 0. -func cssPixelInt(_ value: Double) -> Int { - guard !value.isNaN else { return 0 } - return Int(max(0, min(value, 2_147_483_000))) // just under Int32.max; ±∞ clamp naturally -} +/// Clamp a pixel quantity to a trap-free `Int` — see `WasmSafeInt.pixelClamp` +/// for the wasm32 32-bit-`Int` rationale. Kept as a named alias because the +/// call sites read as pixel math. +func cssPixelInt(_ value: Double) -> Int { WasmSafeInt.pixelClamp(value) } // MARK: - Erasure (also the test seam) diff --git a/Sources/SwiflowUI/FieldChrome.swift b/Sources/SwiflowUI/FieldChrome.swift index df208d00..56da7f1a 100644 --- a/Sources/SwiflowUI/FieldChrome.swift +++ b/Sources/SwiflowUI/FieldChrome.swift @@ -116,13 +116,14 @@ func installFieldStyles() { installControlSheet(id: "sw-forms", formControlsShee /// Trims a trailing `.0` off a whole-number `Double` (`0` → `"0"`, not /// `"0.0"`), so e.g. `min: 0` emits `min="0"`. Falls back to `String(v)` for -/// fractional values or magnitudes past ±2^31: `Int(v)` beyond that TRAPS on -/// wasm32 (host Int is 64-bit and hides it), and the cutoff must be the same -/// on every platform so host tests see wasm behavior (cf. `cssPixelInt`). -/// Shared by `NumberField` (min/max/step) and `Slider` (range bounds/step) — -/// extracted here at the second consumer rather than duplicated. +/// fractional values or magnitudes past the signed 32-bit range: `Int(v)` +/// beyond that TRAPS on wasm32 (see `WasmSafeInt`), so the whole-number fast +/// path goes through `WasmSafeInt.exact` — identical on every platform, so +/// host tests see wasm behavior. Shared by `NumberField` (min/max/step) and +/// `Slider` (range bounds/step). func formatControlNumber(_ v: Double) -> String { - v == v.rounded() && v.magnitude < 2_147_483_648 ? String(Int(v)) : String(v) + if v == v.rounded(), let i = WasmSafeInt.exact(v) { return String(i) } + return String(v) } /// Deterministically slugs an arbitrary label into a lowercase, hyphenated diff --git a/Sources/SwiflowUI/WasmSafeInt.swift b/Sources/SwiflowUI/WasmSafeInt.swift new file mode 100644 index 00000000..3d3f2a94 --- /dev/null +++ b/Sources/SwiflowUI/WasmSafeInt.swift @@ -0,0 +1,37 @@ +// Sources/SwiflowUI/WasmSafeInt.swift +// +// One gate for every Double→Int crossing in SwiflowUI. On wasm32 `Int` is +// 32-bit, so `Int(_:)` on a Double outside signed 32-bit range — or NaN/±∞ — +// TRAPS at runtime. Host `Int` is 64-bit and hides it, so a value like an +// epoch-ms slider bound or a multi-billion-row scroll offset passes every +// host test and then kills the release browser. `cssPixelInt` and +// `formatControlNumber` both guard this class; they funnel through +// `WasmSafeInt` here so the trap can't sneak back in via a new call site with +// its own hand-rolled bound. +import Swiflow + +enum WasmSafeInt { + /// Signed 32-bit range, as `Double` bounds. `Int32.max`/`.min` are exactly + /// representable in `Double`, so these comparisons are exact. + static let min = -2_147_483_648.0 + static let max = 2_147_483_647.0 + + /// The value as `Int` iff it is finite and lands within signed 32-bit + /// range; else `nil`. The range comparison also rejects NaN (all + /// comparisons with NaN are false) and ±∞, so the `Int(_:)` below is + /// always trap-free — on wasm32 as well as the host. + static func exact(_ value: Double) -> Int? { + guard value >= min, value <= max else { return nil } + return Int(value) + } + + /// Clamp a non-negative pixel quantity to a trap-free `Int`. NaN and + /// values below 0 collapse to 0; values past the 32-bit ceiling clamp to + /// just under it (CSS lengths beyond ~2^31 px are meaningless — browsers + /// cap element sizes far lower — so clamping identically on EVERY platform + /// keeps host tests honest about wasm behavior). `+∞` clamps naturally. + static func pixelClamp(_ value: Double) -> Int { + guard !value.isNaN else { return 0 } + return Int(Swift.max(0, Swift.min(value, 2_147_483_000))) + } +} diff --git a/Tests/SwiflowUITests/WasmSafeIntTests.swift b/Tests/SwiflowUITests/WasmSafeIntTests.swift new file mode 100644 index 00000000..4056ecf1 --- /dev/null +++ b/Tests/SwiflowUITests/WasmSafeIntTests.swift @@ -0,0 +1,56 @@ +// Tests/SwiflowUITests/WasmSafeIntTests.swift +// +// The Double→Int gate both cssPixelInt and formatControlNumber funnel +// through. Boundaries are pinned here on the host, where Int is 64-bit — the +// point is that these results are IDENTICAL on wasm32's 32-bit Int (no trap), +// so the host suite is a faithful stand-in. +import Testing +@testable import SwiflowUI + +@Suite("WasmSafeInt") +struct WasmSafeIntTests { + + @Test("exact accepts the full signed-32-bit range, inclusive of both ends") + func exactInRange() { + #expect(WasmSafeInt.exact(0) == 0) + #expect(WasmSafeInt.exact(-5) == -5) + #expect(WasmSafeInt.exact(2_147_483_647) == 2_147_483_647) // Int32.max + #expect(WasmSafeInt.exact(-2_147_483_648) == -2_147_483_648) // Int32.min — representable, trap-free + } + + @Test("exact returns nil just past either end (where Int(_:) would trap on wasm32)") + func exactOutOfRange() { + #expect(WasmSafeInt.exact(2_147_483_648) == nil) // Int32.max + 1 + #expect(WasmSafeInt.exact(-2_147_483_649) == nil) // Int32.min − 1 + #expect(WasmSafeInt.exact(1e12) == nil) // epoch-ms scale + } + + @Test("exact rejects NaN and infinities via the range comparison") + func exactNonFinite() { + #expect(WasmSafeInt.exact(.nan) == nil) + #expect(WasmSafeInt.exact(.infinity) == nil) + #expect(WasmSafeInt.exact(-.infinity) == nil) + } + + @Test("exact truncates a fractional value toward zero (caller decides whether that's wanted)") + func exactTruncates() { + #expect(WasmSafeInt.exact(41.9) == 41) + #expect(WasmSafeInt.exact(-41.9) == -41) + } + + @Test("pixelClamp floors to a non-negative Int and clamps the 32-bit ceiling") + func pixelClamp() { + #expect(WasmSafeInt.pixelClamp(0) == 0) + #expect(WasmSafeInt.pixelClamp(41.9) == 41) // floor + #expect(WasmSafeInt.pixelClamp(-5) == 0) // pixels are ≥ 0 + #expect(WasmSafeInt.pixelClamp(1e12) == 2_147_483_000) // beyond 2^31 → clamp, never trap + #expect(WasmSafeInt.pixelClamp(.infinity) == 2_147_483_000) + #expect(WasmSafeInt.pixelClamp(.nan) == 0) + } + + @Test("formatControlNumber integer-formats Int32.min now that exact accepts it") + func formatBoundary() { + #expect(formatControlNumber(-2_147_483_648) == "-2147483648") + #expect(formatControlNumber(2_147_483_648) == String(2_147_483_648.0)) // still Double past the ceiling + } +}