Skip to content

Truncate before range-checking JSG integers behind an autogate - #8076

Merged
piscisaureus merged 3 commits into
mainfrom
piscisaureus/jsg-integer-enforce-range
Oct 28, 2026
Merged

piscisaureus merged 3 commits into
mainfrom
piscisaureus/jsg-integer-enforce-range

Conversation

@piscisaureus

@piscisaureus piscisaureus commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

JSG converts a JavaScript number to an integer of up to 32 bits by
calling ToNumber, range-checking the double, and then truncating. That
order matches neither plain Web IDL (long wraps) nor Web IDL's
[EnforceRange] (ConvertToInt: throw on NaN/±Infinity, truncate, then
range-check). V8's fast API kEnforceRangeBit integer arguments follow
[EnforceRange]'s order (TryTruncateFloat64ToInt32/Uint32, falling
back to the slow callback when that fails), so declaring JSG's integer
arguments that way today would make an optimized call differ from the
slow path on a few inputs. Aligning the slow path first lets us adopt
kEnforceRangeBit with identical results on both paths.

The new JSG_INTEGER_ENFORCE_RANGE autogate makes the 8/16/32-bit
integer conversions truncate a finite value toward zero before the
range check, in both C++ JSG (jsg/value.h) and Rust JSG
(from_number in wrappable.rs, shared by the integer FromJS impls
and the fast API argument conversion). Rust reads the gate through a
one-function shim over workerd::util::Autogate, so the two cannot
disagree and the check disappears with the gate; both sides read it
only for a non-integral value, so an integral argument costs nothing
extra on either path.

With the autogate on, the only inputs that change are fractions just
past a range limit:

Input Type Today With autogate
2 ** 31 - 0.5 int / i32 TypeError 2147483647
-(2 ** 31) - 0.5 int / i32 TypeError -2147483648
4294967295.5 uint32_t/u32 TypeError 4294967295
-0.5 u32, u16, u8 TypeError 0
255.5 uint8_t / u8 TypeError 255
127.5, -128.5 int8_t / i8 TypeError 127, -128
32767.5 int16_t/i16 TypeError 32767

Unchanged in both states:

  • NaN and ±Infinity: 0 for int/i32, a TypeError for the other types.
  • Values out of range after truncation: the same TypeErrors with the
    same messages as today.
  • The IsInt32/IsUint32 fast checks.
  • int64_t/uint64_t (i64/u64), which are BigInt-typed: V8 does not
    allow kEnforceRangeBit on a BigInt-represented int64 argument.
  • Fast API argument declarations: Rust keeps passing integers as
    double, and C++'s are as they were.

Compatibility: an autogate does not preserve existing Workers'
behavior where it is enabled. The only observable difference is that a
call which threw a TypeError for such a fraction succeeds with the
truncated value; nothing that succeeds today changes. That risk is
accepted for the gated rollout; if it shows problems, the gate is
reverted and the change moves behind a compatibility flag instead.

Tests: jsg/value-test.c++ and src/rust/jsg-test set the gate off
and on explicitly (new jsg_test::set_autogates), so both paths run
in every test variant; the Rust tests cover FromJS and, through a
#[jsg_method], the fast callback, and both sides check that the
BigInt-typed conversions ignore the gate. Each new test fails when the
truncation is removed or made unconditional. jsg/README.md and the
Rust JSG docs describe the conversion rule in both states.

Next steps

  • Once the autogate is fully rolled out: declare integer fast API
    arguments with kEnforceRangeBit in Rust (FastApiArg with
    Raw = i32/u32) and in C++, then remove the autogate and the old
    path. With the slow path converting in the same order, the fast
    path's results are identical, and NaN, ±Infinity and out-of-range
    values fall back to the slow callback.
  • C++ JSG's fast path already passes int/int32_t and uint32_t
    arguments as C integers (v8::CFunction::Make with no CTypeInfo
    flags; narrower types arrive as v8::Local and unwrap normally), so
    under the V8_FAST_API autogate an optimized call wraps them with
    ToInt32/ToUint32 where the slow path throws. Verified with
    fast-api-test.c++'s StaticMethodContainer: on the fast callback
    t.value = -1 reads back 4294967295, 2 ** 32 + 7 gives 7, NaN
    and -0.5 give 0, and add(2 ** 31, 1) gives -2147483647, while
    the slow path throws a TypeError for -1. kEnforceRangeBit on
    those arguments is the fix and is the same step as above. Production
    methods that take the fast path with such an argument (JSG_METHOD
    registrations in src/workerd/api): FileSystemModule::truncate,
    close, write and read; ExecProcess::resize;
    Container::signal; ReadableStreamBYOBRequest::respond;
    Performance::setResourceTimingBufferSize; BufferUtil::fillImpl,
    swap and write; CryptoImpl::checkPrimeSync;
    ProcessModule::exitImpl; CompressionStream::write/writeSync,
    ZlibStream::initialize and params, ZlibUtil::crc32Sync and
    zlibWithCallback;
    PyodideMetadataReader::setCpuLimitNearlyExceededCallback;
    SqlStorage::setMaxPageCountForTest (experimental). uint64_t
    arguments: ReadOnlyBuffer::read,
    PyodideMetadataReader::read and readMemorySnapshot,
    ArtifactBundler::readMemorySnapshot.

Possible further alignment (not done here)

  • [EnforceRange] throws a TypeError for NaN and ±Infinity, while
    int/i32 reads them as 0 (as a plain Web IDL long does). Changing
    that adds new throws to existing calls, so it would need its own
    gate or compatibility flag, and kEnforceRangeBit does not need it:
    V8 falls back to the slow callback for those values.

@piscisaureus
piscisaureus requested review from a team as code owners October 28, 2026 21:36
@clanky-the-clanker

clanky-the-clanker Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

✅ Approved — No findings. The gated truncate-before-range-check matches between C++ and Rust JSG and is tested in both gate states. The guards set up by the Rust test helper now refuse to nest.

The decision is advisory: this comment never approves, blocks or requests changes on the pull request.


✅ All checks passed

🔒 Security: ✅ LGTM

No security concerns. The only behaviour change is that a narrow set of fractional inputs converts instead of throwing, and only while the autogate is on.


📖 AGENTS.md: ✅ LGTM

The one-line change to src/rust/AGENTS.md is accurate and fits the file's conventions.


📋 Code Quality: ✅ LGTM

  • set_autogates now refuses to create a second guard while one is alive. A failed attempt leaves the flag set, which is correct because the first guard still owns the configuration. Dropping the guard clears the configuration and the flag.
  • The two new harness.rs tests cover both the refusal and creating a new guard after the first one is dropped.
  • The C++ tests in value-test.c++ reset the autogates with KJ_DEFER, run with the gate off and then on, and cover values just past each range limit, NaN and ±Infinity, and 64-bit types that ignore the gate.

⚡ Performance: ✅ LGTM

The gate is read only for non-integral values, so integral arguments cost nothing extra.


📝 Documentation: ✅ LGTM

The JSG README, the Rust JSG README and the autogate header comment correctly describe the conversion with the gate on and with it off.


Reviewed commit: af7fc88d

@ask-bonk

ask-bonk Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Since last review: 2 resolved, 0 still open, 0 new.
LGTM!


Reviewed commit: af7fc88d · github run

Comment thread src/workerd/jsg/value.h Resolved
Comment thread src/rust/jsg-test/lib.rs Resolved
JSG converts a JavaScript number to an integer of up to 32 bits with
ToNumber, a range check on the double, and a truncating cast. Web IDL's
[EnforceRange] and V8's kEnforceRangeBit fast API arguments check the
range after truncation instead. The two orders disagree only on a
fraction just past a range limit: `2 ** 31 - 0.5` as an int and `-0.5`
as a uint32_t are a TypeError in JSG, and 2147483647 and 0 under
[EnforceRange]. Converting in [EnforceRange]'s order is what lets JSG
declare its integer fast API arguments with kEnforceRangeBit later
with results identical to the slow path's.

With the new JSG_INTEGER_ENFORCE_RANGE autogate, the int8_t, uint8_t,
int16_t, uint16_t, int and uint32_t unwraps truncate a finite value
toward zero before the range check, so those fractions convert. NaN
and the infinities convert as before (0 for int, a TypeError for the
other types), values out of range after truncation throw the same
errors as before, and the IsInt32/IsUint32 fast checks are unchanged.
Without the gate the conversion is as it was. int64_t and uint64_t are
left alone: V8 does not allow kEnforceRangeBit on a BigInt-represented
int64 argument.

Compatibility: the change is behind an autogate, which does not
preserve the behavior of existing Workers where it is enabled. The
only observable difference is that a call that passed a fraction just
past a range limit, and so threw a TypeError, succeeds with the
truncated value; no call that succeeds today changes its result or
starts throwing. That risk is accepted for the gated rollout; if the
rollout shows problems, the gate is reverted and the change moves
behind a compatibility flag instead.

The tests set the gate off and on explicitly, ignoring
WORKERD_ALL_AUTOGATES, so both paths are checked in every test variant,
and check that the BigInt-typed conversions ignore the gate.
jsg/README.md describes the conversion rule in both states.
`jsg_test::set_autogates(&["name", ...])` configures exactly the named
autogates (kebab-case, without the `workerd-autogate-` prefix),
ignoring WORKERD_ALL_AUTOGATES, for as long as the returned guard
lives; dropping the guard deinitializes the configuration, so the gates
follow WORKERD_ALL_AUTOGATES again. Autogates are process-wide and the
tests run serially, so a test holds the guard for its duration.
`from_number`, which the integer `FromJS` impls and the fast API
argument conversions share, truncates a finite value toward zero before
the range check when the JSG_INTEGER_ENFORCE_RANGE autogate is enabled,
as C++ JSG's integer unwraps do, so `2 ** 31 - 0.5` is an i32 and
`-0.5` a u32 with the gate and a TypeError without it. Non-finite and
out-of-range values convert as before in both states, and i64 and u64
are unchanged, as in C++.

Rust reads the gate through a one-function shim over
`workerd::util::Autogate`, `integer_enforce_range_autogate_enabled`,
on every conversion as C++'s `Autogate::isEnabled` is, so the two
cannot disagree and the check disappears with the gate.

Compatibility: as for C++ JSG, the autogate does not preserve existing
behavior where it is enabled. The only difference is that a fraction
just past a range limit converts instead of throwing a TypeError; that
risk is accepted for the gated rollout, with a compatibility flag as
the fallback if the rollout shows problems.

The docs in wrappable.rs, fast_api.rs and README.md describe both
states. The tests cover each integer width through `FromJS` and,
through a `#[jsg_method]`, the fast callback, in both states.
@piscisaureus
piscisaureus force-pushed the piscisaureus/jsg-integer-enforce-range branch from ef90d5d to af7fc88 Compare October 28, 2026 22:05
@piscisaureus
piscisaureus enabled auto-merge (squash) October 28, 2026 22:09
@piscisaureus
piscisaureus merged commit a1c65f0 into main Oct 28, 2026
53 of 57 checks passed
@piscisaureus
piscisaureus deleted the piscisaureus/jsg-integer-enforce-range branch October 28, 2026 22:55
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.

2 participants