Repository navigation
js_parser: fold [n], .charCodeAt, .length, < and unary + of an 8-bit string per UTF-16 code unit - #46915
js_parser: fold [n], .charCodeAt, .length, < and unary + of an 8-bit string per UTF-16 code unit#46915robobun wants to merge 13 commits into
[n], .charCodeAt, .length, < and unary + of an 8-bit string per UTF-16 code unit#46915Conversation
…F-16 code unit for 8-bit WTF-8 and ropes
The constant folds of "lit"[n], .charCodeAt(n), .length, <, <=, >, >= and
unary +, - and ~ read an 8-bit E.String one byte per character. A string
that does not come from the lexer (a --define or inlined env value, a macro
result, an inlined path, a regexp source) is 8-bit WTF-8 and can be
non-ASCII, so those folds gave a wrong constant.
The folds now go through readers on EString that decode WTF-8, walk rope
segments, and decline on bytes that are not WTF-8. The {k: v}.name fold
compares through EString::eql_bytes. flatten_rope joins the two halves of a
surrogate pair that meet at a seam. The React Compiler decodes an 8-bit
string when it joins it to a UTF-16 one.
…os, paths and regexps
|
Reproduced on main $ bun run --define 'U="é😀"' m.ts # [U[0], U[1], `${U}`.length, U.length, (U + "")[0]], then the `case "é":` taken for U[0]
["\^@","©",6,3,"\^@","default"]
$ bun build --define 'X="é"' --minify-syntax len.mjs # [(X + "x")[1], (X + "x").length, (X + "x")[0]]
["©",3,"\^@"]
$ PUB_L=é bun build --env 'PUB_*' --minify-syntax e.ts # process.env.PUB_L?.[0], `${process.env.PUB_L}`.length
["\^@",2]
$ bun build --define 'X="é"' --minify-syntax dead.mjs # if (X.length === 1) console.log("one"); else require("./missing.js");
error: Could not resolve: "./missing.js"With this branch ( PR: #46915 CI on |
|
Bytes that are not UTF-8 (a Latin-1
A fold rule for these bytes is built on the branch The two PRs conflict in two places, and both are mechanical: the |
…ing, and keep the fold out of the visitor's frame
- EString::char_at returns 8-bit WTF-8 for every code unit, so a fold of the
result goes through the same readers. Half of a pair that is one four-byte
sequence is three bytes in the arena.
- EString::js_order compares an 8-bit with a UTF-16 string by code unit.
checked_order_utf16 also decodes what both texts start with when it is not
ASCII.
- The "lit"[n] fold is a function of its own, and resolve_rope_if_needed is a
test and one call again: both were in the frame of visit_expr_in_out.
- The {k: v}.name fold matches an 8-bit key only. A UTF-16 key matched by
widening the bytes of the name.
- The React Compiler does not fold .length of bytes that are not WTF-8.
- Unit tests for the readers that Miri can run.
…ths, and a seeded differential
…r_in_out `"lit"[n]` and `[a, b][n]` fold in `fold_constant_index`. With only the string fold out of line, the frame of the recursive `visit_expr_in_out` was 584 B against 568 B on main: the stack objects were the same, and LLVM packed them 16 B worse. It is 568 B again. The function is 1,057 B shorter than on main, and it spills one field address on entry where it spilled six. `Data::e_number` had one caller, in that fold. `as_e_number` is the same function, so `e_number` is removed.
`bun build --app` of a module under a non-ASCII path is one test in import-meta-inline.test.ts. production.test.ts is as on main.
No code change.
|
Two questions need a maintainer. The first decides between this PR and #42019. The second decides what comes after this PR. 1. Which contract holds for a string that does not come from the lexer? This PR makes the folds read an 8-bit The other design stores UTF-16 at each producer, as #11994 and #14168 did for one producer each. #42019 does that for all producers, and #46680 and #46688 do it for a macro text body. For a
#42019 is a draft until this is decided. #47655 (opened on 1 Nov) is a third PR for the same fault, and it needs no answer to this question. At its head 2. Which text do bytes that are not UTF-8 have in a fold? An env value or a path can hold such bytes, for example a A rule has to agree with three other readers:
The review of #33866 asks that a fold and a string literal of the same bytes agree. So a fold rule follows the lexer, and #38262 decides what that is. The branch |
…sign target, more tests - `fold_constant_index` takes its target by reference, so the longest `a["b"]["b"]...` chain is main's again. - `order_utf16` is the code of main again. `checked_order_utf16` compares two ASCII texts as bytes and decodes other texts from the start. - `.length` of a string is not folded where it is assigned to, updated or deleted. - `push_units` of the React Compiler is the code of main again: #45172 owns that join. - The checked iterator is private to the string module, and the note on `init_re_encode_utf8` stays. - Tests: the bounds of a folded index, the order and key folds under `bun run`, long texts in the seeded differential, and the folds with no minify flag beside the other define folds. - bench/snippets/transpiler-define-folds.mjs times the folds by kind of text and length.
…ing-code-unit-readers # Conflicts: # src/jsc/RuntimeTranspilerCache.rs
`delete "abc"[0]` was printed as `delete "a"`, and `delete [x][0]` as `delete x`.
… and bitwise not of a define
Problem
--definestring folds per UTF-8 byte: withU="é😀",U[0]is"\0"and`${U}`.lengthis6. Debug builds hitassertion failed: strings::is_all_ascii(&literal).--defineand macro JSON. 1.4.x is wrong: Rewrite the JSON parser around a SIMD structural index #33032 stores these strings as 8-bit. Env values and paths are wrong on both.src/js_parser/visit/visit_expr.rs:1149).Fix
EStringreaders decode WTF-8 and walk rope segments. The[n],.charCodeAt,.length,<and unary+folds call them.test/bundler/bundler_minify.test.ts, 6 more files) fail on main. 10,000 rows match node (main: 8,865 differ). Self-reviewed: 94 concerns, 86 addressed (Notes).Background
E.Stringis 8-bit bytes or UTF-16 units. The lexer stores non-ASCII literals as UTF-16, so folds took 8-bit as ASCII.E.String.next) is a folded"a" + "b", kept as segments.+x,<and[n]do not fold there. js_parser: fix string folds that read non-ASCII 8-bit text by byte or rewrite a write or delete target #47655 leaves.lengthof a join,[n]and.charCodeAt(n)of such text unfolded.Downsides
"abcdefghij"[5], 100 to 144 per non-ASCII character. Many folds into one long constant are quadratic..text. The transpiler cache is stale once..env) no longer folds. A build that needs that constant fails, and the read prints main's wrong literal. Land Stop printing NUL for non-UTF-8 bytes: one WTF-8 step for the string printers and line counters #46269 first.Notes
Questions for a maintainer.
--defineand macro JSON strings are 8-bit since 1.4.0. This PR takes that as the contract for every 8-bitE.Stringthat the visitor folds, and writes it as the doc ofEString.data. Store non-ASCII define, env, macro and inlined path strings as UTF-16 so they fold like literals #42019 is the other design: UTF-16 at each producer. If UTF-16 at the producers is wanted, say so: Store non-ASCII define, env, macro and inlined path strings as UTF-16 so they fold like literals #42019 is then the PR, and this one closes. Make E.String always ascii or utf-8, never utf-16 #15163 (closed) was one storage for all strings..envvalue, a path)? This PR gives no rule: the readers do not fold such a string. A built rule waits on the branchrobobun/d5fcea44/estring-not-utf8-text(see "Bytes that are not UTF-8"). It reads them asTextDecoderdoes. The lexer and the printer of Stop printing NUL for non-UTF-8 bytes: one WTF-8 step for the string printers and line counters #46269 read a lone byte as Latin-1 today, and lexer: decode ill-formed UTF-8 in JS source as U+FFFD #38262 changes the lexer. The review of Preserve non-ASCII in regex .source and tagged template .raw #33866 asks that a fold and a literal of the same bytes agree, so that rule has to follow the lexer.How it was found. No issue and no user report. A fuzz run over
--definestrings found(X + "x")[1]. The other rows come from a read of each fold that indexesE.String.data.Releases. Two rows, each run on the release binary. The define row is
bun run --define 'U="é😀"'with[U[0], U[1], `${U}`.length, U.length, (U + "")[0]]. The env row isPUB_L=é bun build --env 'PUB_*' --minify-syntaxwith[process.env.PUB_L?.[0], `${process.env.PUB_L}`.length].["é","\ud83d",3,3,"é"]["\0",2]["\0","©",6,3,"\0"]["\0",2]["é","\ud83d",3,3,"é"]["é",1]#33032 is in 1.4.0. It stores
--defineand macro JSON strings as 8-bit. Env values and inlined paths were 8-bit before it.Where it reaches (all wrong on main 179664e, all equal to node here).
.charCodeAt(n)and unary+and-. They fold in a plainbun build --definetoo.minify_syntaxorinlining:<,<=,>,>=and unary~. The same folds always run in a TypeScript enum initializer, inimport()and in the arguments ofrequire,require.resolveand a macro call.minify_syntaxonly:[n],.lengthand the{k: v}.kkey compare.src/bundler/options.rs:2069), sobun run --define, a bunfig[define]and a macro result are wrong underbun runandbun testwith no flag.--coverageand--inspectdropminify_syntaxonly.--defineandBun.build({define}), bunfig[define],--env=inlineand--env=PUB_*from the process or a.envfile, the JSON and thedata:URL of a macroResponseorBlob, inlinedimport.meta.dir|file|pathandmodule.filename|id|path, a regexp source coerced by+,exports.replaceofBun.Transpiler. A macro text body is JSON-escaped ASCII (macros: inline a text Response or Blob as its text, not as its JSON-escaped form #46680 owns it).Folds that are new. Four reads are right on main because main leaves them for run time. They fold now, so that a build does not depend on whether a value is ASCII:
.lengthof a flat 8-bit string that is not ASCII,.charCodeAtof a code unit of 0x80 or more,<of an 8-bit string against a UTF-16 literal, and[n]or.charCodeAtpast the first segment of a join. In the seeded differential they are about a fifth of the cells that change.Tests. 26 new tests in 7 files. 25 fail on a release build without this change (main 179664e for 24 of them, the 1.4.3 canary for the newest one).
minify/DefineNonAsciiRequireJoinpins a join that main already gets right.test/bundler/bundler_minify.test.ts(15): CLI and API folds, 29 dead branches that each guardrequire("./missing-N.js"), order pins where code-unit order and byte order differ, inlined enum routes, a fold past a rope seam, the bounds of a folded index,.lengthwhere it is assigned to or deleted, an index underdelete, inlined paths, the folds with no minify flag (CLI and API), and a seeded differential against the engine (BUN_DEFINE_FOLD_SEED) with a value oracle, a count of the folds that are left, and 8 long texts.test/bundler/bundler_env.test.ts(3):--env=inlineand--env=PUB_*with a UTF-8 value, and a.envfile with a UTF-8 value and three values that are not UTF-8. A read of those gives what an unfolded read of the same value gives.test/bundler/transpiler/transpiler.test.js(1),runtime-transpiler.test.ts(2, one with bunfig[define]),macro-test.test.ts(1),react-compiler.test.ts(1:.lengthof an env value, UTF-8 and not),test/bake/dev/import-meta-inline.test.ts(3: the server graph, the client graph andbun build --app).src/ast/e.rsrun in thebun_astMiri job. The ones insrc/bun_corerun in no CI job. They pass when run by hand with simdutf linked in, and the JS tests above pin the same cases.bundler_string,bundler_merged_enum,template-literal,transpiler-cache, esbuilddefault,tsanddce,bundler_edgecase,bundler_loaderandinternal-sourcemap.Repro rows.
bun build --define 'X="é"' --minify-syntaxwith[(X + "x")[1], (X + "x").length, (X + "x")[0]]: was["©",3,"\0"], now["x",2,"é"]. Same for--env=PUBLIC_*, a.envfile,€,é😀and--minify.if (X.length === 1) … else require("./missing.js"),if (+X),if (X < "z"),macro(X[0]), …): 17 exit 0 with node's value here. Main: 11, two of them with a wrong value. Store non-ASCII define, env, macro and inlined path strings as UTF-16 so they fold like literals #42019: 9. 1.3.13 and 1.3.14: 7.(X[0] + "x").length === 2,X[0] > "z",macro(+X[0]), …): 18 of 18 here and on main. Main passes them on a wrong one-byteX[0]. Eight twins that see the value (X[0] + "x" === "éx",U[1] + U[2] === "😀", …): 8 of 8 here, 0 of 8 on main.--definestrings (T="aéb",H="😀",L="\uffee",N="\u00a01",A="abc"), underbun run --defineand underbun build --minify-syntax --define, each against the same expression in anew Function: 0 differ on a debug build of this head, 16 on 1.4.2. 8 statements with a write ordeletetarget ("abc".length = 1,delete "abc"[0],delete [x][0], …), run as they are and after a build: 0 differ here, 5 on 1.4.2.if (test) require("./missing.js")withT="é"andU="ü"from--define, each built with and without--minify-syntax: every build that works on 1.4.2 works here.+T,-T,~T + 1,U < T,T > UandT >= Uare among them. Three more build here with--minify-syntaxand fail on 1.4.2:L < H,("x" + T).length === 3andT.length === 2.Measurements. Release builds on linux x64 of main 179664e and of this diff on that commit (the branch before its merge of main). The measured build differs from the branch in these things: one doc comment, one iterator that was
puband is private to its module now, the later merge of main 3f3a9ff, and one more test of a flag at the index fold (thedeleteguard). No release build was made after them. Instructions areinstructions:ufromperf_event_open, the minimum of 5 runs ofbun build --no-bundle --minify-syntaxover 100,000 equal statements, net of a control file with no fold..text+3,328 B (size -A).visit_expr_in_outhassub $0x238,%rspand 16 calls to itself, as on main. The longestx["b"]["b"]…chain that builds has 13,177 links andx.b.b…has 13,174 (main 13,175 and 13,177, and a run differs from the next by a few links). The first version had 6,588. Only linux x64 is measured."abcdefghij"[5]+91,.charCodeAt(5)+49,.length+36,"abcdefghij" < "abcdefghik"+150,("abcde" + "fghij").length+105, unary++37,===+2,o["abcdefghij"]too.abcdefghij+5.X[4000]+600 (the bytes before the index are tested for ASCII),X.length-19,896,X < Ywhere the two differ in the last byte +1,142."héllo wörld"[7]+511. Index 1,000 of 1,642 characters ofé€: +103,346. The walk costs 100 instructions for each 2-byte code point before the index, 115 for each 3-byte one and 144 for each 4-byte one..lengthof that define: -279,274 (one simdutf count, where main prints the literal).X < Yof two such defines that differ at the end: +337,492.flatten_ropetests the first byte of each segment for half of a pair: +7.1 instructions per seam (50,000 joins of 18 segments against 50,000 joins of 2).typescript.js(9.1 MB, no non-ASCII constant): 975,756,793 instructions here, 976,075,182 on main, and the same output. The readers are entered 17 times there (.lengthof 17 literals) and 0 times inbun hello.jsandbun build hello.js(hardware breakpoints).order_utf16is the code of main. One call, single-stepped in gdb, has the same instruction count as on main for 9 pairs of keys.Bun.buildandminify.syntax, millions of instructions. An index fold is one scan of the text before the index, where main reads one byte. So N folds are quadratic. Constant time needs UTF-16 storage (question 1) or an index that the string keeps. Main is quadratic too where it does not fold a read of a non-ASCII define (.charCodeAt,.length,<against a literal): it prints the define at each read.bench/snippets/transpiler-define-folds.mjshas the same cases for a wall-clock run.X[n]X[n]X[n]X[n]X[n]X[N - 1]X.charCodeAt(n)X.charCodeAt(n)X.lengthX < YX < YX < YX < Krobobun/d5fcea44/estring-readers-block-counting. Its Latin rows are 523 / 1,445 forX[n]and 604 / 2,054 forX < Y, and its.textis +5,120 B. It is not in this PR (Self-review).X[i],X.charCodeAt(i),X.length,(X + Y).length,(X + Y)[j],<,<=,>,>=,+X,-X,~Y,X + Y === Z, template forms). The values are--definestrings made of 1- to 4-byte sequences, lone surrogates, pairs split over+, NBSP, BOM and U+2028 around digits, and astral characters against U+E000..U+FFFF. The--minify-syntaxbundle under node against node on real constants: 0 of 10,000 rows differ. Main: 8,865.size_of::<EString>()is 40 bytes on main and here. A const assert pins it.wtf8_code_unit_atand its result type,wtf8_checked_len_utf16,wtf8_pair_across,checked_order_utf16andchecked_order_wtf8_utf16in bun_core, andjs_order,code_unit_atandchar_atonEString.EString::orderis private now.Data::e_numberis removed (no caller is left).Bytes that are not UTF-8. An env value or a path can hold them. The readers do not fold such a string, at and after the first such byte. The read then sees the literal that the printer writes.
.envPUB_NAME=Caf\xE9-bar,--minify-syntax,[X[5], X.charCodeAt(5), (X + "/x").length, X < "D", X.length]:process.envat run time has["b",98,10,true,8], main prints["b",98,10,true,6], this PR alone prints["r",114,8,true,6], because the printer of main writes"Caf\x00ar"for those bytes.da78fc93b6). 300 Latin-1.envvalues, 7 reads each, cells that differ fromprocess.envunder--minify-syntax: main 700, this PR alone 1,514, Stop printing NUL for non-UTF-8 bytes: one WTF-8 step for the string printers and line counters #46269 alone 0. With no flag: main 1,351, this PR alone 1,476, Stop printing NUL for non-UTF-8 bytes: one WTF-8 step for the string printers and line counters #46269 alone 0. With Stop printing NUL for non-UTF-8 bytes: one WTF-8 step for the string printers and line counters #46269, a read that this PR leaves unfolded has the run-time value. Land Stop printing NUL for non-UTF-8 bytes: one WTF-8 step for the string printers and line counters #46269 first or in the same release. The two PRs conflict in thepub use wtf8_text::{..}list and in the cache version line, and both are mechanical..envvalues of random bytes, 16 reads each, cells that differ fromprocess.env: 1,868 of 4,800 under--minify-syntax(main 1,677, Stop printing NUL for non-UTF-8 bytes: one WTF-8 step for the string printers and line counters #46269 alone 1,136) and 1,861 with no flag (main 1,869, Stop printing NUL for non-UTF-8 bytes: one WTF-8 step for the string printers and line counters #46269 alone 553).if (X[5] === "b") … else require("./missing-1.js")) build on main, and three of them do not resolve here. The same holds for a raw--defineof the same bytes. Of four macro arguments (X[5],X.charCodeAt(5),(X + "/x").length,X < "D"), main builds 4 with--minify-syntaxand 2 with no flag, and this PR builds 1.process.env.X === "text"is a byte compare as on main and still folds.ED A0..BF xx) are WTF-8, so they fold as one code unit.process.envhas three U+FFFD for them.require(A + B + "/m.js")resolves on main and not here, if the env valuesAandBend and start withED A0 BDandED B8 80and a directory has those six bytes in its name. Seven other builds that resolve a module through bytes that are not UTF-8 exit 0, as on main.a\xC3and\xA9b) holds the UTF-8 ofaéb. The folds and the printed literal read that text, so.lengthis 3.process.envhasa\uFFFD\uFFFDb. Main folds.lengthto the byte count 4 and prints the same literal.robobun/d5fcea44/estring-not-utf8-texthas Stop printing NUL for non-UTF-8 bytes: one WTF-8 step for the string printers and line counters #46269 merged in and readers that read these bytes asTextDecoderdoes: one U+FFFD for each maximal subpart. On 3,600.envvalues of legacy text with 19 reads each that use a fold with an unfolded read of the same value, run by Node, 0 of 68,400 cells differ fromprocess.env(main: 27,422). The builds above pass there. It is not in this PR for three reasons. Nobody reported that case. When Bun runs the bundle, a fold (U+FFFD) and the printed literal (a lone byte, which Bun reads as Latin-1) of one value differ: 14,540 of those 68,400 cells. And the surrogate-shaped bytes above stay: 530 of 4,800 reads of random bytes.Other results of the same readers.
({"é": 1}).éfolds to1on main, because the key compare widened bytes. It is left for run time now, where it isundefined. The fold compares 8-bit keys only, so({"é": 1}).éstays unfolded as on main (js_parser: use EString::eql_bytes in the {k: v}.k fold, keepthisfor template tags #32297 folds it, and needs the template-tag guard of js_parser: keep(0, x)where a fold leaves a property access as a callee or a template tag #46818 for that).(H + L).lengthis 2 andH + L === "😀"is true.flatten_ropejoins the halves at the seam.[n]and.charCodeAt(n)past its first segment.delete, as the.lengthfold does:delete "abc"[0]anddelete [x][0]stay as written. Main printsdelete "a"anddelete x.+Nwith the defineN="\u00A01"is 1:Numbertrims NBSP, BOM and U+2028.X < "ü") folds:js_ordercompares 8-bit with UTF-16 by code unit. An ASCII literal against a non-ASCII literal ("a" < "é") folds too, as in esbuild..lengthof a non-ASCII env value is its number of UTF-16 code units. For a value that is not UTF-8 it is left for run time.data:URL of a macroResponsewith a non-ASCII content type:.lengthwas 52, is 51.Reader audit of
src/ast,src/js_parserandsrc/react_compiler..lengthand key compare infold.rs(its two local copies are deleted), andJsString::len_utf16of the React Compiler.scan_side_effects.rs:890(emptiness),js_parser_jsc/expr_jsc.rs:272(a capacity),expr.rs:2001(a clone), import paths and directives inparse/mod.rs, hashing infunction_identities.rs, the React Compiler lowering (text only).push_units, which react-compiler: decode a UTF-8 string when constant propagation joins it to a UTF-16 string #45172 owns with itsbun linttest (under--react-compiler, a join of the env valueCaféand a non-ASCII literal printsCaféon main and here), andjs_to_numberof the React Compiler, which another session has.Open PRs on the same code.
.lengthfold): this PR replaces both.84ac132d11). Its<family and unary folds read 8-bit UTF-8 text as this PR does: by UTF-16 code unit (strings::order_utf16) and with the white-space trim ofNumber(). It leaves three reads for run time where the 8-bit text is not ASCII up to the read:.lengthof a joined string,"s"[n]and.charCodeAt(n). This PR folds them per UTF-16 code unit..lengthof a flat non-ASCII string and<against a UTF-16 literal stay unfolded there, as on main, and fold here. Both PRs have the same write anddeletetarget checks for.length,[n]and[x][0].+("1" + "2")) stays open.thisfor template tags #32297: this PR has itsfold.rscompare for 8-bit keys. Its non-ASCII key fold and template-tag guard stay open, with js_parser: keep(0, x)where a fold leaves a property access as a callee or a template tag #46818 and js_parser: do not fold{ f: () => {} }.fto the bare function #42585.bun lintdiagnostics for its 4 test files and for 18 files of five rules are the same as on main here.flatten_ropeout of line, and this PR also moves the rope test inline (resolve_rope_if_needed).e_callout ofvisit_expr_in_outfor the same frame.Left as they are.
[n],charCodeAt, unary+and joins stay unfolded, as on main. Existing tests pin that. A macro's JS string result is UTF-16 too, so only.lengthand<fold on it.+on a rope declines (js_parser: fold +, - and ~ of a constant-folded string; stop folding non-ascii strings to NaN #39072).===compares bytes, as on main.src/sema,src/aot, new on main) has its own constant folds. They do not call these readers.EString.datastays pub, so a later fold can index bytes again. A source lint for that is a follow-up, and so are a cache version derived from the build and one storage forE.String(Make E.String always ascii or utf-8, never utf-16 #15163).Self-review. Three rounds, 94 concerns, 86 addressed.
EString.data, the cache version scheme, andjs_to_numberof the React Compiler (another session has it).fold_constant_indextakes its target by reference, so the longesta["b"]["b"]…chain is main's again.order_utf16is the code of main again. The cost lines are measured for text that is not ASCII, and the bench is in the repo. The index bounds and the folds underbun runhave rows. The React Compiler join stays with react-compiler: decode a UTF-8 string when constant propagation joins it to a UTF-16 string #45172. Round 2 also proposed block counting with simdutf for long text. It was built and is not in this PR: round 3 asked for a plain decode, andREVIEW.mdasks for the simplest mechanism..lengthis not folded where it is assigned to or deleted, the seeded differential has long texts so that CI runs the SIMD paths, this text starts with the regression and names the new folds, the note oninit_re_encode_utf8stays, and the tests are out of the heavybundler_edgecasefile. Five are not taken. Two ask for the React Compiler join and for one shared decoder for it and for macro arguments (react-compiler: decode a UTF-8 string when constant propagation joins it to a UTF-16 string #45172 owns the join). Two ask for folds on UTF-16 strings (they stay as on main). One asks for a test under--inspector--coverage(the bundler rows with no minify flag run the same folds). The verdict also asked for a tracker issue (none is filed) and forflatten_ropeas on main (the join of two surrogate halves is what makesH + L === "😀"true, as on 1.3.14).delete. That check is one condition beside the write check on the same line, so it had no round of its own.