utf8.decode traps under dart2wasm when a 4-byte UTF-8 character's final byte is
the first byte of a 1024-byte decode chunk. The VM and dart2js are unaffected.
Reproduction
import 'dart:convert';
import 'dart:typed_data';
void main() {
// U+1F6B2 occupies offsets 1021..1024, so its final byte is the first byte
// of the second 1024-byte chunk.
final bytes = Uint8List.fromList([
...List.filled(1021, 0x61),
0xF0, 0x9F, 0x9A, 0xB2,
...List.filled(1023, 0x61),
]);
print(utf8.decode(bytes).length); // expected: 2046
}
Running the wasm output needs a small host, since dart compile wasm emits a
module rather than a runnable script. run.mjs:
import { readFile } from 'node:fs/promises';
import { compile, instantiate, invoke } from './main.mjs';
const module = await compile(
await readFile(new URL('./main.wasm', import.meta.url)));
invoke(await instantiate(Promise.resolve(module), Promise.resolve({})));
$ dart run main.dart
2046
$ dart compile wasm main.dart -o main.wasm && node run.mjs
RuntimeError: array element access out of bounds
at wasm://wasm/00012f26:wasm-function[83]:0x49bc
With assertions enabled the trap is preceded by:
Assertion failed: .../lib/_internal/wasm/lib/convert_patch.dart:2644:18
size <= _characterArraySize
is not true
Cause
_Utf8Decoder decodes in 1024-byte chunks through a shared
_characterArray of _characterArraySize (1024) UTF-16 units
(sdk/lib/_internal/wasm/lib/convert_patch.dart).
In _convertChunkInternal, size starts as scan(bytes, start, end), which
counts units for sequences starting in this chunk. The carry-over block then
adds units for a character continued from the previous chunk without
re-checking capacity:
case X3:
case QO:
case QR:
flags |= flagNonLatin1;
if (end - start >= 3) size += 2;
break;
Every sequence lying wholly inside a chunk yields no more units than the bytes
it consumes, so only the carried-over character can push size past the
capacity, and only in one configuration: a 4-byte character contributing just
its final byte to this chunk yields 2 units for 1 byte. A full 1024-byte chunk
whose remaining 1023 bytes are ASCII then computes size == 1025, and
decode16 writes one unit past the end of _characterArray.
Constraints, each verified against dart compile wasm:
| Carried character |
Bytes in chunk |
Units |
Surplus |
Result |
| 4-byte |
1 |
2 |
+1 |
traps |
| 4-byte |
2 |
2 |
0 |
ok |
| 4-byte |
3 |
2 |
-1 |
ok |
| 3-byte |
1 |
1 |
0 |
ok |
| 2-byte |
1 |
1 |
0 |
ok |
The chunk's remaining 1023 bytes must also be entirely ASCII; any other
multi-byte character there returns a unit and brings the total back to 1024.
Impact
The failure mode is worse in release builds, where the bounds assertion is
compiled out. We hit this in a Flutter web app built with --wasm: the trap
occurred inside an async continuation decoding an HTTP response body, and the
awaiting Future then never completed and never errored. The page sat
loading indefinitely with no error surface and no recovery path, which made it
look like a network or backend hang rather than a decoder fault.
It is also intermittent in a way that resists diagnosis. The trigger is byte
alignment, not content: for the 133KB response that first exposed this, 26 of
the 1024 possible alignments trap, and no two adjacent ones do. Appending a
single record to the payload silently arms or disarms it.
Versions
- Dart SDK 3.11.5 (stable), macos_arm64
- Flutter 3.41.9
- Reproduced standalone with
dart compile wasm + Node v24.15.0 (no Flutter)
utf8.decodetraps under dart2wasm when a 4-byte UTF-8 character's final byte isthe first byte of a 1024-byte decode chunk. The VM and dart2js are unaffected.
Reproduction
Running the wasm output needs a small host, since
dart compile wasmemits amodule rather than a runnable script.
run.mjs:With assertions enabled the trap is preceded by:
Cause
_Utf8Decoderdecodes in 1024-byte chunks through a shared_characterArrayof_characterArraySize(1024) UTF-16 units(
sdk/lib/_internal/wasm/lib/convert_patch.dart).In
_convertChunkInternal,sizestarts asscan(bytes, start, end), whichcounts units for sequences starting in this chunk. The carry-over block then
adds units for a character continued from the previous chunk without
re-checking capacity:
Every sequence lying wholly inside a chunk yields no more units than the bytes
it consumes, so only the carried-over character can push
sizepast thecapacity, and only in one configuration: a 4-byte character contributing just
its final byte to this chunk yields 2 units for 1 byte. A full 1024-byte chunk
whose remaining 1023 bytes are ASCII then computes
size == 1025, anddecode16writes one unit past the end of_characterArray.Constraints, each verified against
dart compile wasm:The chunk's remaining 1023 bytes must also be entirely ASCII; any other
multi-byte character there returns a unit and brings the total back to 1024.
Impact
The failure mode is worse in release builds, where the bounds assertion is
compiled out. We hit this in a Flutter web app built with
--wasm: the trapoccurred inside an async continuation decoding an HTTP response body, and the
awaiting
Futurethen never completed and never errored. The page satloading indefinitely with no error surface and no recovery path, which made it
look like a network or backend hang rather than a decoder fault.
It is also intermittent in a way that resists diagnosis. The trigger is byte
alignment, not content: for the 133KB response that first exposed this, 26 of
the 1024 possible alignments trap, and no two adjacent ones do. Appending a
single record to the payload silently arms or disarms it.
Versions
dart compile wasm+ Node v24.15.0 (no Flutter)