Repository navigation
Truncate before range-checking JSG integers behind an autogate - #8076
Conversation
|
✅ 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: ✅ LGTMNo 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: ✅ LGTMThe one-line change to 📋 Code Quality: ✅ LGTM
⚡ Performance: ✅ LGTMThe gate is read only for non-integral values, so integral arguments cost nothing extra. 📝 Documentation: ✅ LGTMThe 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: |
|
Since last review: 2 resolved, 0 still open, 0 new. Reviewed commit: af7fc88d · github run |
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.
ef90d5d to
af7fc88
Compare
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 (
longwraps) nor Web IDL's[EnforceRange](ConvertToInt: throw on NaN/±Infinity, truncate, thenrange-check). V8's fast API
kEnforceRangeBitinteger arguments follow[EnforceRange]'s order (TryTruncateFloat64ToInt32/Uint32, fallingback 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
kEnforceRangeBitwith identical results on both paths.The new
JSG_INTEGER_ENFORCE_RANGEautogate makes the 8/16/32-bitinteger conversions truncate a finite value toward zero before the
range check, in both C++ JSG (
jsg/value.h) and Rust JSG(
from_numberinwrappable.rs, shared by the integerFromJSimplsand the fast API argument conversion). Rust reads the gate through a
one-function shim over
workerd::util::Autogate, so the two cannotdisagree 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:
2 ** 31 - 0.5int/ i32-(2 ** 31) - 0.5int/ i324294967295.5uint32_t/u32-0.5255.5uint8_t/ u8127.5,-128.5int8_t/ i832767.5int16_t/i16Unchanged in both states:
int/i32, a TypeError for the other types.same messages as today.
IsInt32/IsUint32fast checks.int64_t/uint64_t(i64/u64), which are BigInt-typed: V8 does notallow
kEnforceRangeBiton a BigInt-represented int64 argument.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++andsrc/rust/jsg-testset the gate offand on explicitly (new
jsg_test::set_autogates), so both paths runin every test variant; the Rust tests cover
FromJSand, through a#[jsg_method], the fast callback, and both sides check that theBigInt-typed conversions ignore the gate. Each new test fails when the
truncation is removed or made unconditional.
jsg/README.mdand theRust JSG docs describe the conversion rule in both states.
Next steps
arguments with
kEnforceRangeBitin Rust (FastApiArgwithRaw = i32/u32) and in C++, then remove the autogate and the oldpath. 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.
int/int32_tanduint32_targuments as C integers (
v8::CFunction::Makewith noCTypeInfoflags; narrower types arrive as
v8::Localand unwrap normally), sounder the
V8_FAST_APIautogate an optimized call wraps them withToInt32/ToUint32 where the slow path throws. Verified with
fast-api-test.c++'sStaticMethodContainer: on the fast callbackt.value = -1reads back 4294967295,2 ** 32 + 7gives 7,NaNand
-0.5give 0, andadd(2 ** 31, 1)gives -2147483647, whilethe slow path throws a TypeError for
-1.kEnforceRangeBitonthose 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,writeandread;ExecProcess::resize;Container::signal;ReadableStreamBYOBRequest::respond;Performance::setResourceTimingBufferSize;BufferUtil::fillImpl,swapandwrite;CryptoImpl::checkPrimeSync;ProcessModule::exitImpl;CompressionStream::write/writeSync,ZlibStream::initializeandparams,ZlibUtil::crc32SyncandzlibWithCallback;PyodideMetadataReader::setCpuLimitNearlyExceededCallback;SqlStorage::setMaxPageCountForTest(experimental).uint64_targuments:
ReadOnlyBuffer::read,PyodideMetadataReader::readandreadMemorySnapshot,ArtifactBundler::readMemorySnapshot.Possible further alignment (not done here)
[EnforceRange]throws a TypeError for NaN and ±Infinity, whileint/i32 reads them as 0 (as a plain Web IDLlongdoes). Changingthat adds new throws to existing calls, so it would need its own
gate or compatibility flag, and
kEnforceRangeBitdoes not need it:V8 falls back to the slow callback for those values.