From be08d90ad245b7a1295c2e746804eacb23c3c15a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:47:48 +0100 Subject: [PATCH 01/17] Fix the three nightly failures in #6160 dash: GameSocketTest slept out the countdown and then sent the host's jump. Two runners who never jump are both out 6.4 s into Neon Causeway, the race ends, and a later jump is dropped, so the jump had about 6 s to land; a stalled CI container missed that and the guest waited for the server's 300 s idle close (reproduced by delaying the jump). The jump is now sent during the countdown, which the server stamps for tick INPUT_DELAY, a read timeout fails a missing frame in 30 s, and the race ending first is reported by name. OnlineRaceTest now holds the stamping of a mid-race jump on a driven clock, which the socket test no longer exercises. RaceRoom.intent's doc no longer says a countdown input is dropped. The Initializr template is regenerated. flutter-bench: the JavaScript code size moved +17.5% with #6163, whose emitter reads instance fields as o.name instead of through the o[_qN] alias table (valueEscape 20% faster). The gallery's wire bytes in the same run moved +0.03%. Rebaselined with that reason. nightly-watchdog: ios-accessibility-thread joined the nightly in #6128, merged after that night's slot, and was reported as a schedule that stopped firing. A suite with no scheduled run at all whose file reached master inside the window is now listed as awaiting its first run, which keeps the issue open; one that ran before and went quiet is still a problem. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/nightly-watchdog.yml | 39 +++++++++++++++--- .../examples/dash/game/RaceRoom.java | 3 +- .../examples/dash/GameSocketTest.java | 29 ++++++++++--- .../examples/dash/game/OnlineRaceTest.java | 22 ++++++++++ scripts/flutter-bench/baseline/pr/6172.json | 25 +++++++++++ .../src/main/resources/dash-modules.zip | Bin 861470 -> 863706 bytes 6 files changed, 106 insertions(+), 12 deletions(-) create mode 100644 scripts/flutter-bench/baseline/pr/6172.json diff --git a/.github/workflows/nightly-watchdog.yml b/.github/workflows/nightly-watchdog.yml index a551e66c014..5e6f36e93b3 100644 --- a/.github/workflows/nightly-watchdog.yml +++ b/.github/workflows/nightly-watchdog.yml @@ -13,7 +13,9 @@ name: Nightly watchdog # nightly is one cron line, with no list here to keep in sync. It then finds # the newest scheduled run of each on master and keeps ONE issue open, # assigned to NIGHTLY_ALERT_ASSIGNEE, listing every suite that failed or has -# not run in the last 30 hours. A run still going when checked is judged by +# not run in the last 30 hours -- except one that has never had a scheduled +# run and reached master inside that window, which is awaiting its first night. +# A run still going when checked is judged by # the previous completed one, so a suite that fails after this job has looked # is reported the next morning; and the issue closes only when every suite has # finished green, never on one still running. @@ -72,6 +74,7 @@ jobs: const GREEN = ['success', 'skipped', 'neutral']; const problems = []; const running = []; + const awaiting = []; const cancelled = []; for (const file of nightly) { let runs; @@ -86,6 +89,31 @@ jobs: } const newest = runs[0]; if (!newest || Date.now() - new Date(newest.created_at).getTime() > STALE_MS) { + // A suite that has never had a scheduled run, and whose file + // reached master inside the window, joined the nightly after + // the night's slot: it is awaiting its first run, not broken. + // Only a suite with NO scheduled run at all qualifies -- one + // that ran before and then went quiet is a schedule that + // stopped, however recently its file was edited -- and only + // for the window after its file's latest master commit, so a + // cron that never fires is reported once the file has gone a + // day unedited. It keeps the issue open like a running + // suite, under its own heading. + if (!newest) { + let changed = null; + try { + const { data: commits } = await github.rest.repos.listCommits({ + owner, repo, sha: 'master', path: `${dir}/${file}`, per_page: 1, + }); + changed = commits.length ? new Date(commits[0].commit.committer.date).getTime() : null; + } catch (e) { + changed = null; + } + if (changed !== null && Date.now() - changed <= STALE_MS) { + awaiting.push(`- \`${file}\`: no scheduled run yet; it reached master at ${new Date(changed).toISOString()}`); + continue; + } + } problems.push(`- \`${file}\`: no scheduled run in the last 30 hours (workflow disabled, or the schedule did not fire)`); continue; } @@ -111,13 +139,13 @@ jobs: await core.summary .addHeading('Nightly watchdog') - .addRaw(`${nightly.length} nightly workflows, ${problems.length} failing, ${running.length} still running.\n\n`) - .addRaw([...problems, ...running].join('\n') || 'All green.') + .addRaw(`${nightly.length} nightly workflows, ${problems.length} failing, ${running.length} still running, ${awaiting.length} awaiting a first run.\n\n`) + .addRaw([...problems, ...running, ...awaiting].join('\n') || 'All green.') .write(); if (problems.length === 0) { // Close only on a night where everything finished green. - if (issue && running.length === 0) { + if (issue && running.length === 0 && awaiting.length === 0) { await github.rest.issues.createComment({ owner, repo, issue_number: issue.number, body: `Recovered: every nightly workflow passed on the run checked at ${now}.` }); await github.rest.issues.update({ owner, repo, issue_number: issue.number, @@ -126,7 +154,7 @@ jobs: // Nothing failing any more, but not every suite has finished: // keep the issue open and stop it listing failures that are gone. await github.rest.issues.update({ owner, repo, issue_number: issue.number, body: [ - marker, `No failures as of ${now}; the issue closes once these finish green:`, '', ...running, + marker, `No failures as of ${now}; the issue closes once these finish green:`, '', ...running, ...awaiting, ].join('\n') }); } return; @@ -138,6 +166,7 @@ jobs: '', ...problems, ...(running.length ? ['', 'Still running when checked (judged by their previous completed run):', '', ...running] : []), + ...(awaiting.length ? ['', 'Awaiting their first scheduled run (new to the nightly):', '', ...awaiting] : []), ...(cancelled.length ? ['', 'Cancelled (not counted as failures):', '', ...cancelled] : []), '', 'They run on a pull request only when their own tree changes, so a change to shared code (core, ports, builders, backend) first meets them here. This issue closes itself after a fully green night.', diff --git a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/game/RaceRoom.java b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/game/RaceRoom.java index a7b8609aeb1..1a194fb4c2e 100644 --- a/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/game/RaceRoom.java +++ b/scripts/dash/backend/src/main/java/com/codenameone/examples/dash/game/RaceRoom.java @@ -87,7 +87,8 @@ int seatOf(String username) { } /// A player's input. Answers false when it is dropped: a flood, a runner - /// that is out, or a race that has not started. + /// that is out, or a race that is over. One sent during the countdown is + /// kept, stamped for tick INPUT_DELAY. boolean intent(int seat, int bits) { if (seat < 0 || run.over() || !run.runner(seat).running()) { return false; diff --git a/scripts/dash/backend/src/test/java/com/codenameone/examples/dash/GameSocketTest.java b/scripts/dash/backend/src/test/java/com/codenameone/examples/dash/GameSocketTest.java index b813a2d2eb1..483c385c603 100644 --- a/scripts/dash/backend/src/test/java/com/codenameone/examples/dash/GameSocketTest.java +++ b/scripts/dash/backend/src/test/java/com/codenameone/examples/dash/GameSocketTest.java @@ -80,9 +80,16 @@ void twoFriendsMeetInAPartyAndSeeTheSameInputs() throws Exception { assertEquals(0L, ((Number) startA.get("you")).longValue()); assertEquals(1L, ((Number) startB.get("you")).longValue()); - // After the countdown the host's jump reaches the guest, stamped - // for a tick to come. - Thread.sleep(GameProtocol.COUNTDOWN_MILLIS + 200); + // The host's jump reaches the guest, stamped for a tick to come. + // Sent during the countdown, which the server stamps for tick + // INPUT_DELAY like any other: two runners who never jump are both + // out about 6.4 s into Neon Causeway, the race then ends, and a + // jump sent after it is dropped. Sleeping out the countdown first + // left about 6 s for the jump to land; a CI container stalled + // past that once, and the guest saw the end and then nothing + // until the server's 300 s idle close. Sending now adds the + // countdown to that margin, and OnlineRaceTest holds the stamping + // of a jump made mid-race, on a clock of its own. writeText(a, "{\"type\":\"input\",\"b\":4}"); String stamped = firstInput(b); assertTrue(stamped.endsWith(",0,4"), stamped); @@ -129,10 +136,17 @@ private Map until(Tcp connection, String type) throws IOException { throw new IOException("no " + type + " message"); } - /// The first input a `ticks` message carries. + /// The first input a `ticks` message carries. The race ending first is a + /// failure here, not something to wait past: nothing follows the end. private String firstInput(Tcp connection) throws IOException { - for (int i = 0; i < 2000; i++) { - Map m = until(connection, "ticks"); + for (int i = 0; i < 4000; i++) { + Map m = next(connection); + if (GameProtocol.END.equals(m.get("type"))) { + throw new IOException("the race ended before an input arrived"); + } + if (!GameProtocol.TICKS.equals(m.get("type"))) { + continue; + } String c = (String) m.get("i"); if (c != null && c.length() > 0) { return c; @@ -143,6 +157,9 @@ private String firstInput(Tcp connection) throws IOException { private Tcp open(String target) throws IOException { Tcp connection = Tcp.connect("127.0.0.1", port, 5000); + // A frame that never comes fails in seconds rather than at the + // server's 300 s idle close: the stream is ticks every 50 ms. + connection.setReadTimeout(30000); byte[] request = ascii("GET " + target + " HTTP/1.1\r\nHost: 127.0.0.1\r\n" + "Upgrade: websocket\r\nConnection: Upgrade\r\n" + "Sec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n"); diff --git a/scripts/dash/backend/src/test/java/com/codenameone/examples/dash/game/OnlineRaceTest.java b/scripts/dash/backend/src/test/java/com/codenameone/examples/dash/game/OnlineRaceTest.java index 5875a34b31b..0c838a80c3d 100644 --- a/scripts/dash/backend/src/test/java/com/codenameone/examples/dash/game/OnlineRaceTest.java +++ b/scripts/dash/backend/src/test/java/com/codenameone/examples/dash/game/OnlineRaceTest.java @@ -139,6 +139,28 @@ void aQuickRaceFillsWithBotsAndNeverCostsTrophies() throws Exception { "{\"raceId\":\"" + id + "\",\"player\":\"1\",\"reason\":\"too good\"}"), who))); } + /// A jump a second into the race is stamped INPUT_DELAY ticks after the + /// tick it arrived on, not at the start: the socket test sends its jump + /// during the countdown, so this is where a running race's stamping is held. + @Test + void anInputMidRaceIsStampedForATickToCome() throws Exception { + String who = Api.player(accounts, "midrace"); + rooms.quick(profile(who), null, Content.CAUSEWAY); + String id = rooms.raceOf(who); + rooms.tick(System.currentTimeMillis() + GameProtocol.COUNTDOWN_MILLIS + 1000); + rooms.input(who, Input.JUMP); + rooms.fastForward(id); + Map stored = Api.json(mvc, Api.as(get("/api/races/" + id), who)); + Replay replay = Replay.decode((String) stored.get("replay")); + int first = -1; + for (int i = 0; i < replay.eventCount() && first < 0; i++) { + if (replay.event(i)[1] == 0) { + first = replay.event(i)[0]; + } + } + assertTrue(first > GameProtocol.INPUT_DELAY, "stamped for tick " + first); + } + @Test void anInputFloodIsCut() throws Exception { String who = Api.player(accounts, "masher"); diff --git a/scripts/flutter-bench/baseline/pr/6172.json b/scripts/flutter-bench/baseline/pr/6172.json new file mode 100644 index 00000000000..7fe5017ac7d --- /dev/null +++ b/scripts/flutter-bench/baseline/pr/6172.json @@ -0,0 +1,25 @@ +{ + "pr": 6172, + "rebaseline": { + "javascript": { + "gallery": { + "code_bytes": { + "from": { + "value": 14004378 + }, + "reason": "JS emitter optimizations (#6163) read instance fields as o.name instead of through the hoisted o[_qN] alias table, which V8 can scalar-replace (valueEscape 20% faster). The longer identifiers grow the raw script by 2,453,423 bytes (code and install move by the same amount); gzip absorbs the repetition: the gallery's own wire_bytes, gated at 2%, went 80,131,522 -> 80,155,363 (+0.03%) in the same run. Parse cost scales with the raw size; the opt-in field mangler (mangleInstanceFieldProps) would win it back but is off by default because host-poked fields are not yet enumerated.", + "runs": 1, + "value": 16457801 + }, + "install_bytes": { + "from": { + "value": 109504181 + }, + "reason": "JS emitter optimizations (#6163) read instance fields as o.name instead of through the hoisted o[_qN] alias table, which V8 can scalar-replace (valueEscape 20% faster). The longer identifiers grow the raw script by 2,453,423 bytes (code and install move by the same amount); gzip absorbs the repetition: the gallery's own wire_bytes, gated at 2%, went 80,131,522 -> 80,155,363 (+0.03%) in the same run. Parse cost scales with the raw size; the opt-in field mangler (mangleInstanceFieldProps) would win it back but is off by default because host-poked fields are not yet enumerated.", + "runs": 1, + "value": 111957604 + } + } + } + } +} diff --git a/scripts/initializr/common/src/main/resources/dash-modules.zip b/scripts/initializr/common/src/main/resources/dash-modules.zip index 422b017908852080a021616ac405d56d23f5419c..5aaa4982d51291d52de1ac12b93e5c34f49b158f 100644 GIT binary patch delta 5164 zcmZ`+33O9c8gAZ8UXu2{u9VUZE{g~O+R~l2lokYJlx3hCMo=I$uQWEzOOupXwla!> zvRGyLIRgq0PZ~M z^{Lcd?Q|+`5BgQ9Q$dHd^vUW<7qz+-ItA_S_or5SsdWnbKRYFpdZ^v$Rp|&tb<#oB zYLDWx*7VlYxvixN`@u;Sr_IwFkF`1=OK7Ehkti?HH8lKoR-WN(T+Y>N&kuJ4Vj(=Wr;tfU8=6 z;#^h_JJaf=sgJ^j&h7>%Z_((0aScXD+joeqp>G$y`;Ni0_# z+zW4!KM|&uVKTJjR9bBct+dIUK4_ ziyiw0bo+(&V7Vwgs>_6^7HJ?HDHUaL@Q8k(uwg0{>?n+g^*r7N*g=?Hw-w8TlUHuVjA=a@s2TeC%X;JNVfs8?w_>ei zIb)OSp=|tce6rGLr6(oe2M=;8rCz)0Oz|k*(TdepVy{$iB6?(`HD0_V2@bi4yn1(5 z$B+>AJPO4}r5qT!ml$CEHN0W|Hr}{67Mk8O=xdApv+bA9hnFeQ@`pOK%LJ1cd~0xR zd_d7w)5WUGcs8+Cx7$7q_o�tj0VI%#|A}tHp_Zq+oJv+T^hNKx--1c>!;KcC+@z zenAJQ3;+387hzYj3yZZ1lZ=z3DP|Z=$`yw#1*}`7*sjiDPFJ;exZO4y&4zi#uuD?0 z4N=|2B`CW@9i&!*b)47<8gg|;Xxbtr8L|DDPZ;1bZig?+jU6Dr3j6D7xiJN1R~eVW zs_T*oM&B_}$aNcIWZJzu}$M7mQHhHg=2W3;n5EaXG9tDOg#jw}-vw zgfu92n!3Ujk5Pg-lF?w~hJ~CLca zKd}hDR7MYgMXCBwn755Y!UIhtLC7p(^laTU*wsX)3*syWb6{7!UJt$lq)-_7To7~p z04Wk;UkGBHA}P~6?})u z=@$!gz9Y|x<#8JOFwBoLL@*H+@v}q&tW7kOa7ourkOc8DYJk4f@DEN+JBgH~i)K{} z%_qrV;ll^u>JKCz_B~)AFyaQLcR{)#xR`s=xxr_rFsKVz9Nft=#KNTSv3h-S8B+j7 zxds#LXUr30IpZ2@h=f^Z(c`!uFgfo};Pj>+$N(XCA_G3*BPaXLfi*v30df}vfCNdW zNv3WG{QWek7B=q*S}>d;xkBoNAZ8|G>f@m59EpLVv%~_8aZ)&M{iw5e^v&$R+EhU; zV4xQ?pCwtslS4Rg43Q#W@Ht}9Z3NdjQYuu8)E1VXC;39m81}OT8pBE*^U8FIu&qh(K??~N3g_~* z$&2u+y3<0kb(3K2MUnyKi=;?MYavOI1^1>6zt`#F76)yhi_zK1i`WmlL4sX9j9IV` zaG&o>EX9;d*Z~>!e!i$45BTsBnpVH58sIO?~< zUxNFKSMU1KhE^$tAGhoZroU$dY`#M5!hfULcQ(|98zcB~)m2>1@5AYjuaX?0sepqQ za{V?*ZQhD)PJe15x4rk0>@Q536~x^38l(6z<+&01q9?6`q>YTf9ky8T3Q#few^Xaz&D*ZgSHIrLC-8`It6 zXAE$7H1xeq@?iGQc!M5n$AN>G!lC^aXfq_n6s+#2L~gJ+#$*Q9FW5Xy$(%X#3&{`` zb>?93O?(5K?#4lF658&#iME#EoauH8&stZ@L7~qi!|S)OyIUabHV!*!#SV;qiLK_t zJM(e1t`#?rU&5K2tvFCmFJr(h3|+yUoVbnUxw49b=a-to;E7*x&re%8vv`Ba1Ya$h`*^>;|R(BV4{+TX=-)Lr0Ua&X_`% z|Aqya9BPK$I`dF=`$R%xtT{M$N=I1p8*vF@XZBqPqkbnD;`3e2yv%7OW(j&Gn&oAm z$&v8=?>Gs2y9cc;E0puZ(V1-->AQM)fUtBI2PxZ7f_5J)HK5JT zbBy*02W~)5NgiiDK}d+F9RGHNy7x&ud_qWYlHIXT6(je9FquSvWRxvJX#!{NMBxQI zn=$(nIa3~kI^K*x?VvcMQ-u!tIm*Fu;oLXrt}7ndJoC zBG?^} zGCCi2Mx&p8UIqq0ZVU?et(t)<;j)jDi&SEU-tExUgZP?bXYX=hLRK?oJig`_v!)vV zkTJ)~{RG|2AZE1(4eMgj&^S9lNApC)wMP{LYdP&`FXsvS=WtN|6l&L$+G^S)EBa|T z6^{+_b`1&RPPR>NV^H>tX0Ut)cVN-2gp=_YWKjoH*8D8rjX)3EJ7Cxu^EuNe9+#Ic zV4w)r$72qP7BNsI?0At|EKWf4Q*|6nTu6*yPL$JyPnHIl2rg)OB1V-CoW7VS=jwij zQyo$Bq$IgO7`T#0c=;g?$@m wR(8dy%=&4WJSMd@YEGIUxPT1?6;S~fBx1kEH;yx$^UgcJ|NZX$miyiN zhK9zt>I-q}N)@H-sB&eclsT-wn^UyVVvaHlC|Wotf7yT`Db|XI z!mkw8?Hxi_zHY(1*neeXcG&^e@*GW*4`h6#ao@P7M1zkO9}yoNKCae1C0vms+A9JF zf8Zanwu7+bd!7Q8A9(_cJWp`zM;^yik&t_XC%~-dTn&GUREaR7o*P+gG|8vyc`AF5 zZU+&{GE_cbddT%qnx)cn7u(-n#jq@AD=nP6%46B-4@mBR4IR9{M&%8s8gcS_4LpG9 zHaLtOYTy~{@@5C6V2jEfk{dCcqty=b+cl~nc@ZO9yvHu5v-mIVAVSSG9?$Z>vMXxP zoKyLeYtwaf{qM{2*hpA>MHK*tuj68c-;)x2gO5^_!0a1Xx1o))mI+Zexrw!0BZI%( z#F|7j6I^Ik^^@o8LEYk1j=G=7;7}9KWZQ2OoNVSIq&MSY58Ca@SQgP?TdaeE(Zkd( zr^S?AWbo}RKAQD^Mlkt*2su4{u;Z6kGaFtw&D{9n)wmWG{)CR7zU&jKK2iHp+^#KH zj?-Egb{oqvTU7gzTqeusbtJd7@X?CR5ObTCvK9|n%Y>fWJe_rU%PfjDGtifDQXYu(R0()jy<|$x`UN8152>34Y$5?rh{CTqxOVuSuQb? ztglsT;5YYqBJ14Zpw!>T^RsfdUGau#?KrF8tXSdbUehOE*wiR?GaB@41)4Ud_Md3 z9a%F%_PaQLRVTLNr!xe->+91w268)i0b4YW?7BO!hZhwRT<+u?%$->FvXyovi;Y`l z2M!avaJYLtDTljoI|H`aK??`B33_PjFAOq%}c#4<@;MmyLRRupDpgwo&0PJWiSVnT?u$ z;YrHc+cui~0x7%IM){949HyPet)1?vb!_Si5ptDMG9>q6bNF(CFM4?rOY$NJdx~+EwVY?J&X16Iu-vsL|bw|B_<4q1C~4S;@pJ zTdvb+0hgMBH?jP@JfNn8Bh5IuV!M5owp*sgPA7E*lX8M?`5gYj~H2%{nQOMb9Gm zStlj2%4HDhDiyQ#RU}?@Me*7;g03B+2Q+frmccunqM2DH8zh5eCm9%(b=WLN&WI+! zP7~qcrx=)g%wUcr1t=5$Y8yNuN$JXZYaeAohMQzmDw}9*N2w^l0XHd8>2ptn_D(Sh zJl!#~FcqY^<3=3gP~k6`;ACMG8swS6?A@RpffocWW>_fIP!HIL47KQ*_TG_d&Yo@}u=pVqC(DowxBR4dHXc6< z{!%(j^T(0KR2d3cz+3Wr1f=)F&Q2Xi=94C48Bg}Zq=;xjH?4W}Av(cvS zEbcWsCDxEfR`<+kK&7!b8AQ(~gN#Bv$e5Cmt;r{)Jy1$zqZSi7`-WmtBKl+hc$E~P532=P?UQKKxjgAe&8#H_q!KpBur`>7?EhG=b*or?U<@14P zYpNg!43|c*4ciG|7w$n#IL33ik`&b-j3>H^Afx)#7Okpr%IEM7GH92rqjnOc?#6F@ zUkk?7a>pMCi=cTRHreW0Jhy!t{$wz==)hexZCnHfzu*r9XCiP%eqBv4d Date: Thu, 5 Nov 2026 20:29:19 +0100 Subject: [PATCH 02/17] Fix the Flutter gallery transpile #6154 broke, and gate it on every transpiler PR #6154 merged a transpiler under which the Flutter gallery (the Flutter benchmark's application) no longer compiled, and nothing on its PR ran it: flutter-bench.yml runs on a PR only when its own tree changes. Every benchmark leg of every PR since then failed in javac. Fixed, each where it went wrong: - a library-private class's static tear-off (`_DialogDemoState::_route`) was taken for a bound one and handed the class name as a receiver; - a function-type witness nested in another reused its lambda parameter; - an explicit `` on a call whose parameter is `Comparable` was written as ``, which no Long or Double satisfies; - named arguments did not bind a generic function's type parameters, so `ease(begin: radius, end: 0)` emitted `0` as a long; - List.generate's generator had no E-typed return context; - `double + (c ? x : 0)` took the Number arms of a num slot; - the restorablePush stubs declared their route builder Object, and ScrollNotification's depth getter did not follow its siblings' naming. flutter-gallery-compile.yml runs the benchmark's own path up to javac (prepare.sh, then the generated project's compile) on every change to the transpiler, the runtimes or the Flutter build steps. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/flutter-gallery-compile.yml | 140 ++++++++++++++++++ .../dart/transpiler/codegen/JavaEmitter.java | 80 ++++++++-- .../flutter/widgets/ScrollNotification.java | 10 +- .../META-INF/dart/flutter_material.dart | 3 +- .../META-INF/dart/gallery_restoration.dart | 4 +- 5 files changed, 223 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/flutter-gallery-compile.yml diff --git a/.github/workflows/flutter-gallery-compile.yml b/.github/workflows/flutter-gallery-compile.yml new file mode 100644 index 00000000000..428fca30433 --- /dev/null +++ b/.github/workflows/flutter-gallery-compile.yml @@ -0,0 +1,140 @@ +name: Flutter gallery compiles + +# Transpiles the Flutter gallery -- the Flutter benchmark's application, and +# the largest real Dart program the transpiler sees -- and compiles the result, +# on every change to the transpiler, the runtimes or the Flutter build steps. +# +# The benchmark (flutter-bench.yml) does this too, but runs on a pull request +# only when its own tree changes. #6154 therefore merged a transpiler that +# turned the gallery into Java javac rejects (a private class's static +# tear-off, nested function-type witnesses, named-argument inference, numeric +# conditionals) with nothing on its pull request having looked, and the +# nightly and every later benchmark pull request were red. This job is the +# benchmark's own path up to javac -- prepare.sh, then the generated +# project's compile -- on one runner, measuring nothing. +# +# The unit and behavior tests (dart-flutter-tests.yml) check constructs one at +# a time and the acceptance apps (flutter-acceptance.yml) are small; neither +# reaches what this does. + +on: + pull_request: + paths: + - 'maven/dart-transpiler/**' + - 'maven/dart-runtime/**' + - 'maven/flutter-runtime/**' + - 'maven/build-engine/src/main/java/com/codename1/maven/FlutterProjectBuilder.java' + - 'maven/build-engine/src/main/java/com/codename1/maven/FlutterProjectImporter.java' + - 'maven/build-engine/src/main/java/com/codename1/maven/DartLineMapper.java' + - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/*Flutter*.java' + - 'scripts/flutter-bench/app/prepare.sh' + - 'scripts/flutter-bench/app/cn1/**' + - '.github/workflows/flutter-gallery-compile.yml' + push: + branches: + - master + paths: + - 'maven/dart-transpiler/**' + - 'maven/dart-runtime/**' + - 'maven/flutter-runtime/**' + - 'maven/build-engine/src/main/java/com/codename1/maven/FlutterProjectBuilder.java' + - 'maven/build-engine/src/main/java/com/codename1/maven/FlutterProjectImporter.java' + - 'maven/build-engine/src/main/java/com/codename1/maven/DartLineMapper.java' + - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/*Flutter*.java' + - 'scripts/flutter-bench/app/prepare.sh' + - 'scripts/flutter-bench/app/cn1/**' + - '.github/workflows/flutter-gallery-compile.yml' + workflow_dispatch: + +env: + # The benchmark's own pin (flutter-bench.yml): the gallery and the lockfile + # that holds its dependencies come from this SDK. Move the two together. + FLUTTER_REF: '3.35.4' + +permissions: + contents: read + packages: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + gallery: + runs-on: ubuntu-24.04 + container: ghcr.io/codenameone/codenameone/pr-ci-container:latest + timeout-minutes: 60 + defaults: + run: + shell: bash + env: + LANG: C.UTF-8 + LC_ALL: C.UTF-8 + steps: + - uses: actions/checkout@v6 + + - name: Link cn1-binaries + run: ln -s /opt/cn1-binaries ../cn1-binaries + + - name: Check the container's JDKs + run: | + for var in JAVA_HOME_8 JAVA_HOME_17; do + eval "path=\${$var:-}" + if [ -z "$path" ] || [ ! -x "$path/bin/javac" ]; then + echo "$var is unset or has no javac: '${path:-}'" >&2 + exit 1 + fi + done + + # The same two passes as flutter-bench.yml's framework job: what prepare.sh + # generates is a project from the shipping archetype, so it resolves the + # plugin, the archetype and core from this repository, and the runtimes, + # which build only on JDK 17. + - name: Build the framework on JDK 8 + env: + RETRY_ONLY_MATCHING: transient + run: | + cd maven + JAVA_HOME="$JAVA_HOME_8" bash ../scripts/ci/retry.sh mvn -B -q install -DskipTests \ + -Dmaven.javadoc.skip=true -Dmaven.source.skip=true \ + -Plocal-dev-javase \ + -Dmaven.repo.local="$GITHUB_WORKSPACE/m2" \ + -pl core,dart-transpiler,codenameone-maven-plugin,cn1app-archetype -am + + - name: Build the Flutter runtime on JDK 17 + env: + RETRY_ONLY_MATCHING: transient + run: | + cd maven + JAVA_HOME="$JAVA_HOME_17" bash ../scripts/ci/retry.sh mvn -B -q install -DskipTests \ + -Dmaven.javadoc.skip=true -Dmaven.source.skip=true \ + -Plocal-dev-javase \ + -Dmaven.repo.local="$GITHUB_WORKSPACE/m2" \ + -pl dart-runtime,flutter-runtime + + # A checkout, not a release archive: the gallery is dev/integration_tests, + # which the release archives leave out. + - name: Check out the Flutter SDK + run: | + git clone --depth 1 --branch "$FLUTTER_REF" \ + https://github.com/flutter/flutter.git "$RUNNER_TEMP/flutter" + echo "$RUNNER_TEMP/flutter/bin" >> "$GITHUB_PATH" + echo "FLUTTER_ROOT=$RUNNER_TEMP/flutter" >> "$GITHUB_ENV" + + - name: Prepare the gallery + run: | + flutter --version + JAVA_HOME="$JAVA_HOME_17" scripts/flutter-bench/app/prepare.sh \ + --work "$RUNNER_TEMP/fbench" \ + --maven-repo "$GITHUB_WORKSPACE/m2" + + # compile, not package: the transpile and javac are what this guards, and + # neither needs a platform build or a display. + - name: Transpile and compile the gallery + env: + RETRY_ONLY_MATCHING: transient + run: | + cd "$RUNNER_TEMP/fbench/cn1" + JAVA_HOME="$JAVA_HOME_17" bash "$GITHUB_WORKSPACE/scripts/ci/retry.sh" mvn -B compile \ + -Dmaven.repo.local="$GITHUB_WORKSPACE/m2" \ + -DskipTests -DskipComplianceCheck=true -pl common -am diff --git a/maven/dart-transpiler/src/main/java/com/codename1/dart/transpiler/codegen/JavaEmitter.java b/maven/dart-transpiler/src/main/java/com/codename1/dart/transpiler/codegen/JavaEmitter.java index d5374e376a3..8e7cc1cd2bf 100644 --- a/maven/dart-transpiler/src/main/java/com/codename1/dart/transpiler/codegen/JavaEmitter.java +++ b/maven/dart-transpiler/src/main/java/com/codename1/dart/transpiler/codegen/JavaEmitter.java @@ -11703,6 +11703,9 @@ private Out valueTearOffOf(Expr e, Out out, TypeRef expected, Ctx ctx) { boolean simple = SIMPLE_RECEIVER.matcher(recv).matches(); if (name.equals("new") || !name.matches("[\\w$]+") || simple && STATIC_TEAR_OFF.matcher(recv + "::" + name).matches() && ctx.lookup(recv) == null + // A library-private class (`_DialogDemoState::_route`) fails the capital-letter + // test above, and its name is no receiver: a static tear-off has none. + || simple && recv.startsWith("_") && ctx.lookup(recv) == null && programClass(recv, ctx) != null || !simple && (e instanceof Ident || COMPLEX_TYPE_RECEIVER.matcher(recv).matches() || recv.endsWith(".super") || recv.endsWith(".this"))) { return out; @@ -17406,11 +17409,23 @@ private String witnessFor(String n, Ctx ctx) { /// marking a required one), so the Type prints and compares as Dart's does. private String functionWitness(TypeRef t, boolean nullable, Ctx ctx) { String erased = erasedJavaType(t, ctx); - String test = functionTest("$twf", t, erased, ctx); - return test == null ? null : "dart.runtime.TypeWitness.function(" + erased + ".class, " + nullable - + ", ($twf) -> " + test + functionSigWitnesses(t, ctx) + ")"; + // One name per nesting level: a function type whose parameter is a function type + // nests its witness's lambda in this one's, and Java refuses a lambda parameter + // that shadows an enclosing one ("variable $twf is already defined"). + String fv = functionWitnessDepth == 0 ? "$twf" : "$twf" + functionWitnessDepth; + functionWitnessDepth++; + try { + String test = functionTest(fv, t, erased, ctx); + return test == null ? null : "dart.runtime.TypeWitness.function(" + erased + ".class, " + nullable + + ", (" + fv + ") -> " + test + functionSigWitnesses(t, ctx) + ")"; + } finally { + functionWitnessDepth--; + } } + /// How deeply functionWitness is nested in itself, for its lambda parameter's name. + private int functionWitnessDepth; + private String functionSigWitnesses(TypeRef t, Ctx ctx) { StringBuilder sb = new StringBuilder(", new dart.runtime.TypeWitness[] {"); sb.append(sigSlotWitness(t.funcReturn, ctx)); @@ -23620,6 +23635,10 @@ private String upcastIfNullDefault(String jt, TypeRef lt, Out right) { return right.code; } + /// The operators whose result is a double when either operand is a double. + private static final java.util.Set ARITH_DOUBLE_OPS = new HashSet( + java.util.Arrays.asList("+", "-", "*", "/")); + private Out emitBinary(Binary b, Ctx ctx) { if (b.left instanceof SuperExpr && (b.op.equals("==") || b.op.equals("!="))) { // `super == other` invokes the superclass's operator ==, which every class @@ -23726,7 +23745,17 @@ private Out emitBinary(Binary b, Ctx ctx) { } Out l = emitExpr(b.left, genericCtorValueSlot(b.left, ctx), ctx); ctx.pushWriter(ctx.currentIndent()); - Out r = emitExpr(b.right, genericCtorValueSlot(b.right, ctx), ctx); + // `w * h + (n > 0 ? gap : 0)`: a double plus an int-or-double conditional is a + // double whichever arm is taken, so the conditional is read as one -- its int arm + // promoted, as Java's ternary does. With no context it took the Number arms a num + // slot needs, and the sum fell to dynBinary, a Number no double parameter takes + // (the Flutter gallery's Shrine bottom sheet). + TypeRef rSlot = genericCtorValueSlot(b.right, ctx); + if (rSlot == null && stripParens(b.right) instanceof Conditional && ARITH_DOUBLE_OPS.contains(b.op) + && l.type != null && l.type.is("double") && !l.type.nullable) { + rSlot = TypeRef.DOUBLE; + } + Out r = emitExpr(b.right, rSlot, ctx); String rLifted = ctx.popWriter(); if (NUMERIC_BOUND_OPS.contains(b.op)) { // `T extends num` operand: Dart types `n * 2` by T's bound, so it is a num @@ -24644,6 +24673,22 @@ private List bindGenericParams(List tps, List params, Call unifyTypeVars(p.type, inferExprTypeQuiet(a, ctx), tps, m); } } + // Named arguments bind too, as the witnesses (methodWitnessCall) already read + // them: `ease({required T begin, required T end})` called with + // `begin: radius, end: 0` is ease, and its `0` is the double 0.0. Left + // unbound, T erased to dynamic and the literal was emitted as a long the + // double-typed call then rejected (the Flutter gallery's Shrine bottom sheet). + // After the positionals, so an argument written first still binds first. + for (NamedArg na : c.args.named) { + if (na.value instanceof Lambda || na.value instanceof ListLit) { + continue; + } + for (Param p : params) { + if (p.named && p.name.equals(na.name)) { + unifyTypeVars(p.type, inferExprTypeQuiet(na.value, ctx), tps, m); + } + } + } } List args = new ArrayList(); for (String tp : tps) { @@ -27759,12 +27804,19 @@ && typeVarsInScope(expected, ctx)) { if (c.typeArgs != null && !c.typeArgs.isEmpty() && c.typeArgs.size() == m.typeParams.size()) { StringBuilder tb = new StringBuilder("<"); for (int i = 0; i < c.typeArgs.size(); i++) { - if (i > 0) { - tb.append(", "); + String jt = javaType(c.typeArgs.get(i), true, ctx); + if ("Number".equals(jt)) { + // `_sort((d) => d.calories)` with `Comparable Function(D)`: + // Dart's int is a Comparable, Java's Long only a + // Comparable, so `` cannot type the argument (the + // Flutter gallery's data table). javac infers T instead; the + // witness still says num. + tb = null; + break; } - tb.append(javaType(c.typeArgs.get(i), true, ctx)); + tb.append(i > 0 ? ", " : "").append(jt); } - targs = tb.append('>').toString(); + targs = tb == null ? "" : tb.append('>').toString(); } List mps = m.params; if (cc.method(n) == null && !m.isStatic) { @@ -30490,7 +30542,17 @@ && primitiveListKind(TypeRef.of("List", ft)) == null) { return new Out(cls + witness + "filled(" + args + ")", listType); } // generate: the generator's index parameter is typed via the IndexedGenerator typedef - String gen = emitExpr(pos.get(1), new TypeRef("IndexedGenerator"), ctx).code; + // With a known element type the generator is an `E Function(int)`, as Dart types it: + // `List.generate(n, (i) => i.isEven ? 0 : gap)` returns 0.0, not a num the + // primitive list's Func1 cannot take (the Flutter gallery's Shrine). + TypeRef genType = new TypeRef("IndexedGenerator"); + if (elem != null && isConcreteType(elem) && !isDynamicType(elem) && typeVarsInScope(elem, ctx)) { + genType = new TypeRef("Function"); + genType.funcParams = new ArrayList(); + genType.funcParams.add(TypeRef.INT); + genType.funcReturn = elem; + } + String gen = emitExpr(pos.get(1), genType, ctx).code; String args = len + ", " + gen + (growable != null ? ", " + growable : ""); return new Out(cls + witness + "generate" + sfx + "(" + args + ")", listType); } diff --git a/maven/flutter-runtime/src/main/java/com/codename1/flutter/widgets/ScrollNotification.java b/maven/flutter-runtime/src/main/java/com/codename1/flutter/widgets/ScrollNotification.java index 1867e547f5c..9c898ffc2da 100644 --- a/maven/flutter-runtime/src/main/java/com/codename1/flutter/widgets/ScrollNotification.java +++ b/maven/flutter-runtime/src/main/java/com/codename1/flutter/widgets/ScrollNotification.java @@ -48,8 +48,12 @@ public void metrics(ScrollMetrics v) { this.metrics = v; } - /** The number of scrollables this notification has bubbled through. */ - public long get$depth() { + /** + * The number of scrollables this notification has bubbled through. Named as the + * stub's other getters are ({@code metrics()}): the stub declares {@code int get depth}, + * and a {@code case UserScrollNotification(depth: 0)} pattern reads it as depth(). + */ + public long depth() { return depth; } @@ -78,7 +82,7 @@ public void direction(ScrollDirection v) { * innermost first, stopping at the first one that returns true -- Flutter's * {@code Notification.dispatch}. * - *

{@link #get$depth()} counts the scroll views the notification has bubbled THROUGH, + *

{@link #depth()} counts the scroll views the notification has bubbled THROUGH, * not the listeners. That is the distinction the gallery relies on: Reply hides its * bottom bar on {@code UserScrollNotification(depth: 0)}, so that dragging the image * carousel inside a mail card -- a scroll view of its own, whose notifications reach diff --git a/maven/flutter-runtime/src/main/resources/META-INF/dart/flutter_material.dart b/maven/flutter-runtime/src/main/resources/META-INF/dart/flutter_material.dart index aa1f34aee8f..8daae729d8d 100644 --- a/maven/flutter-runtime/src/main/resources/META-INF/dart/flutter_material.dart +++ b/maven/flutter-runtime/src/main/resources/META-INF/dart/flutter_material.dart @@ -2916,7 +2916,8 @@ class Navigator extends Widget { external static Future push(BuildContext context, MaterialPageRoute route); external static void pop(BuildContext context, [Object? result]); external static NavigatorState of(BuildContext context, {bool? rootNavigator}); - external static String restorablePush(BuildContext context, dynamic routeBuilder, {dynamic arguments}); + // routeBuilder as the Func2 Navigator.restorablePush takes (see NavigatorState's). + external static String restorablePush(BuildContext context, Object? Function(BuildContext, Object?) routeBuilder, {dynamic arguments}); // Future, matching Flutter's own Navigator.maybePop(context, [result]) -- // this used to answer a synchronous bool, so `await Navigator.maybePop(context)` // did not compile against the stub. diff --git a/maven/flutter-runtime/src/main/resources/META-INF/dart/gallery_restoration.dart b/maven/flutter-runtime/src/main/resources/META-INF/dart/gallery_restoration.dart index 16e81af512d..824cde081fa 100644 --- a/maven/flutter-runtime/src/main/resources/META-INF/dart/gallery_restoration.dart +++ b/maven/flutter-runtime/src/main/resources/META-INF/dart/gallery_restoration.dart @@ -179,7 +179,9 @@ class FocusNode { // declared here. @JavaName('com.codename1.flutter.navigation.NavigatorState') abstract class NavigatorState { - external String restorablePush(Object routeBuilder, {Object? arguments}); + // The builder's real type, which NavigatorState.restorablePush takes as a Func2: a + // tear-off into an Object slot is cast to its own exact SAM, which javac then refuses. + external String restorablePush(Object? Function(BuildContext, Object?) routeBuilder, {Object? arguments}); external String restorablePushNamed(String routeName, {Object? arguments}); external void pop([Object? result]); // Imperative navigation used across new_gallery — Flutter's `NavigatorState` From 24acda3f9bdd88af09bd3eb8e3e87fbdf00bb83f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 5 Nov 2026 21:00:20 +0100 Subject: [PATCH 03/17] Rebaseline the JavaScript gallery's sizes for #6154's code generation The gallery compiles again with this PR, so this is its first measurement under #6154's transpiler: code and install grow 1,208,552 bytes while wire_bytes moves +0.22%. Co-Authored-By: Claude Opus 5.5 --- scripts/flutter-bench/baseline/pr/6172.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/scripts/flutter-bench/baseline/pr/6172.json b/scripts/flutter-bench/baseline/pr/6172.json index 7fe5017ac7d..2b41d436abc 100644 --- a/scripts/flutter-bench/baseline/pr/6172.json +++ b/scripts/flutter-bench/baseline/pr/6172.json @@ -5,19 +5,19 @@ "gallery": { "code_bytes": { "from": { - "value": 14004378 + "value": 16457801 }, - "reason": "JS emitter optimizations (#6163) read instance fields as o.name instead of through the hoisted o[_qN] alias table, which V8 can scalar-replace (valueEscape 20% faster). The longer identifiers grow the raw script by 2,453,423 bytes (code and install move by the same amount); gzip absorbs the repetition: the gallery's own wire_bytes, gated at 2%, went 80,131,522 -> 80,155,363 (+0.03%) in the same run. Parse cost scales with the raw size; the opt-in field mangler (mangleInstanceFieldProps) would win it back but is off by default because host-poked fields are not yet enumerated.", + "reason": "Flutter port gaps (#6154) reached master with a transpiler under which the gallery no longer compiled; this PR fixes that, so this is the first measurement of the gallery under #6154's code generation (async lowering, signed tear-offs, type witnesses). The raw script grows by 1,208,552 bytes (code and install move by the same amount) while the gallery's own wire_bytes, gated at 2%, went 80,155,363 -> 80,334,160 (+0.22%) in the same run: the growth is repetitive generated code gzip absorbs.", "runs": 1, - "value": 16457801 + "value": 17666353 }, "install_bytes": { "from": { - "value": 109504181 + "value": 111957604 }, - "reason": "JS emitter optimizations (#6163) read instance fields as o.name instead of through the hoisted o[_qN] alias table, which V8 can scalar-replace (valueEscape 20% faster). The longer identifiers grow the raw script by 2,453,423 bytes (code and install move by the same amount); gzip absorbs the repetition: the gallery's own wire_bytes, gated at 2%, went 80,131,522 -> 80,155,363 (+0.03%) in the same run. Parse cost scales with the raw size; the opt-in field mangler (mangleInstanceFieldProps) would win it back but is off by default because host-poked fields are not yet enumerated.", + "reason": "Flutter port gaps (#6154) reached master with a transpiler under which the gallery no longer compiled; this PR fixes that, so this is the first measurement of the gallery under #6154's code generation (async lowering, signed tear-offs, type witnesses). The raw script grows by 1,208,552 bytes (code and install move by the same amount) while the gallery's own wire_bytes, gated at 2%, went 80,155,363 -> 80,334,160 (+0.22%) in the same run: the growth is repetitive generated code gzip absorbs.", "runs": 1, - "value": 111957604 + "value": 113166156 } } } From adfab904bb38ea128f24348d750a72f61d871c03 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 5 Nov 2026 21:43:30 +0100 Subject: [PATCH 04/17] Stop the Dart runtime calling String.split, which ParparVM lacks #6154's FunctionType and TypeWitness split type-variable lists with String.split, which is in neither vm/JavaAPI nor Ports/CLDC11: the JVM legs pass and the native Linux and Windows translations fail to compile an undeclared java_lang_String_split. DartUri and ContentType had the same call. All four now split on a literal by hand. Also rebaselines the Android gallery's dex size for #6154's code generation; the APK itself moved +0.21%. Co-Authored-By: Claude Opus 5.5 --- .../src/main/java/dart/core/DartUri.java | 22 +++++++++++++++++-- .../src/main/java/dart/io/ContentType.java | 20 ++++++++++++++++- .../main/java/dart/runtime/FunctionType.java | 21 ++++++++++++++++-- .../main/java/dart/runtime/TypeWitness.java | 2 +- scripts/flutter-bench/baseline/pr/6172.json | 12 ++++++++++ 5 files changed, 71 insertions(+), 6 deletions(-) diff --git a/maven/dart-runtime/src/main/java/dart/core/DartUri.java b/maven/dart-runtime/src/main/java/dart/core/DartUri.java index f19dcb25dd1..c40d40cc1f0 100644 --- a/maven/dart-runtime/src/main/java/dart/core/DartUri.java +++ b/maven/dart-runtime/src/main/java/dart/core/DartUri.java @@ -916,7 +916,7 @@ public DartList pathSegments() { p = p.substring(1); } if (!p.isEmpty()) { - for (String seg : p.split("/", -1)) { + for (String seg : splitLiteral(p, "/")) { out.add(decodeComponent(seg)); } } @@ -934,7 +934,7 @@ public DartMap queryParameters() { public static DartMap splitQueryString(String query) { DartMap out = new DartMap<>(); if (!query.isEmpty()) { - for (String pair : query.split("&", -1)) { + for (String pair : splitLiteral(query, "&")) { if (pair.isEmpty()) { continue; } @@ -1424,4 +1424,22 @@ private static char[] foldRange(String t, char[] out, int from, int to) { } return out; } + + /// `s` split at every `sep`, keeping empty pieces (Java's `split(sep, -1)` for a + /// literal). `String.split` is not in ParparVM's class library (`vm/JavaAPI`) nor + /// in `Ports/CLDC11`: it compiles and runs on the JVM, and the native translation + /// then fails to compile on an undeclared `java_lang_String_split`. + private static String[] splitLiteral(String s, String sep) { + java.util.ArrayList out = new java.util.ArrayList<>(); + int start = 0; + int at = s.indexOf(sep); + while (at >= 0) { + out.add(s.substring(start, at)); + start = at + sep.length(); + at = s.indexOf(sep, start); + } + out.add(s.substring(start)); + return out.toArray(new String[out.size()]); + } + } diff --git a/maven/dart-runtime/src/main/java/dart/io/ContentType.java b/maven/dart-runtime/src/main/java/dart/io/ContentType.java index fb3a31dceb8..be594579001 100644 --- a/maven/dart-runtime/src/main/java/dart/io/ContentType.java +++ b/maven/dart-runtime/src/main/java/dart/io/ContentType.java @@ -65,7 +65,7 @@ public java.util.Map parameters() { /** Dart's {@code ContentType.parse}: {@code type/subtype; charset=x}. */ public static ContentType parse(String value) { - String[] parts = value.split(";"); + String[] parts = splitLiteral(value, ";"); String mime = parts[0].trim(); int slash = mime.indexOf('/'); String cs = null; @@ -120,4 +120,22 @@ public String toString() { } return sb.toString(); } + + /// `s` split at every `sep`, keeping empty pieces (Java's `split(sep, -1)` for a + /// literal). `String.split` is not in ParparVM's class library (`vm/JavaAPI`) nor + /// in `Ports/CLDC11`: it compiles and runs on the JVM, and the native translation + /// then fails to compile on an undeclared `java_lang_String_split`. + private static String[] splitLiteral(String s, String sep) { + java.util.ArrayList out = new java.util.ArrayList<>(); + int start = 0; + int at = s.indexOf(sep); + while (at >= 0) { + out.add(s.substring(start, at)); + start = at + sep.length(); + at = s.indexOf(sep, start); + } + out.add(s.substring(start)); + return out.toArray(new String[out.size()]); + } + } diff --git a/maven/dart-runtime/src/main/java/dart/runtime/FunctionType.java b/maven/dart-runtime/src/main/java/dart/runtime/FunctionType.java index 8f0c6a219c2..cd9313f3fca 100644 --- a/maven/dart-runtime/src/main/java/dart/runtime/FunctionType.java +++ b/maven/dart-runtime/src/main/java/dart/runtime/FunctionType.java @@ -55,7 +55,7 @@ public final class FunctionType { /// The slot is a Dart type spelling (`T`, `List`, `(V) => K`); every identifier in /// it that is one of the type parameters is renamed. private String canonical(String spelled) { - String[] params = typeVars[0].split(", "); + String[] params = splitLiteral(typeVars[0], ", "); StringBuilder sb = new StringBuilder(); int i = 0; while (i < spelled.length()) { @@ -112,7 +112,7 @@ private static String str(TypeWitness w) { public String toString() { StringBuilder sb = new StringBuilder(); if (typeVars != null && typeVars.length > 0) { - String[] params = typeVars[0].split(", "); + String[] params = splitLiteral(typeVars[0], ", "); sb.append('<'); for (int k = 0; k < params.length; k++) { sb.append(k > 0 ? ", " : "").append('Y').append(k); @@ -232,4 +232,21 @@ private static boolean sameType(TypeWitness a, TypeWitness b) { public int hashCode() { return toString().hashCode(); } + + /// `s` split at every `sep`, keeping empty pieces (Java's `split(sep, -1)` for a + /// literal). `String.split` is not in ParparVM's class library (`vm/JavaAPI`) nor + /// in `Ports/CLDC11`: it compiles and runs on the JVM, and the native translation + /// then fails to compile on an undeclared `java_lang_String_split`. + static String[] splitLiteral(String s, String sep) { + java.util.ArrayList out = new java.util.ArrayList<>(); + int start = 0; + int at = s.indexOf(sep); + while (at >= 0) { + out.add(s.substring(start, at)); + start = at + sep.length(); + at = s.indexOf(sep, start); + } + out.add(s.substring(start)); + return out.toArray(new String[out.size()]); + } } diff --git a/maven/dart-runtime/src/main/java/dart/runtime/TypeWitness.java b/maven/dart-runtime/src/main/java/dart/runtime/TypeWitness.java index 415c38a9a95..d5156420560 100644 --- a/maven/dart-runtime/src/main/java/dart/runtime/TypeWitness.java +++ b/maven/dart-runtime/src/main/java/dart/runtime/TypeWitness.java @@ -622,7 +622,7 @@ public static boolean isGenericFunction(Object f, int typeParamCount, int[] slot || sig.length != params.length + 1) { return false; } - String[] own = vars[0].split(", "); + String[] own = FunctionType.splitLiteral(vars[0], ", "); if (own.length != typeParamCount) { return false; } diff --git a/scripts/flutter-bench/baseline/pr/6172.json b/scripts/flutter-bench/baseline/pr/6172.json index 2b41d436abc..1a2d3d109bf 100644 --- a/scripts/flutter-bench/baseline/pr/6172.json +++ b/scripts/flutter-bench/baseline/pr/6172.json @@ -20,6 +20,18 @@ "value": 113166156 } } + }, + "android": { + "gallery": { + "code_bytes": { + "from": { + "value": 4965216 + }, + "reason": "Flutter port gaps (#6154) reached master with a transpiler under which the gallery no longer compiled; this PR fixes that, so this is the first measurement of the gallery's dex under #6154's code generation. Executable code grows 4,965,216 -> 5,447,152 bytes while the APK itself, gated at 2%, went 82,093,372 -> 82,262,888 (+0.21%) in the same run.", + "runs": 1, + "value": 5447152 + } + } } } } From c7e173f7bcca327d3e10847255ab6e4f9c8688fe Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:33:16 +0100 Subject: [PATCH 05/17] Rebaseline the Linux and Windows gallery sizes for #6154's code generation Both native galleries build again now that the Dart runtime no longer calls String.split; code grows about 11% on each while the gallery's wire size moves under 1%. Co-Authored-By: Claude Opus 5.5 --- scripts/flutter-bench/baseline/pr/6172.json | 32 +++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/scripts/flutter-bench/baseline/pr/6172.json b/scripts/flutter-bench/baseline/pr/6172.json index 1a2d3d109bf..d519a73c492 100644 --- a/scripts/flutter-bench/baseline/pr/6172.json +++ b/scripts/flutter-bench/baseline/pr/6172.json @@ -32,6 +32,38 @@ "value": 5447152 } } + }, + "linux": { + "gallery": { + "code_bytes": { + "from": { + "value": 16754402 + }, + "reason": "Flutter port gaps (#6154) reached master with a transpiler under which the gallery no longer compiled, and with a Dart runtime calling String.split, which the native translation lacks; this PR fixes both, so this is the first measurement of the Linux gallery under #6154's code generation. Code grows 16,754,402 -> 18,748,258 bytes and install 119,197,408 -> 121,747,040 by about the same amount, while the gallery's wire_bytes, gated at 2%, went 85,769,588 -> 86,583,233 (+0.95%) in the same run.", + "runs": 1, + "value": 18748258 + }, + "install_bytes": { + "from": { + "value": 119197408 + }, + "reason": "Flutter port gaps (#6154) reached master with a transpiler under which the gallery no longer compiled, and with a Dart runtime calling String.split, which the native translation lacks; this PR fixes both, so this is the first measurement of the Linux gallery under #6154's code generation. Code grows 16,754,402 -> 18,748,258 bytes and install 119,197,408 -> 121,747,040 by about the same amount, while the gallery's wire_bytes, gated at 2%, went 85,769,588 -> 86,583,233 (+0.95%) in the same run.", + "runs": 1, + "value": 121747040 + } + } + }, + "windows": { + "gallery": { + "code_bytes": { + "from": { + "value": 12399104 + }, + "reason": "Flutter port gaps (#6154) reached master with a transpiler under which the gallery no longer compiled, and with a Dart runtime calling String.split, which the native translation lacks; this PR fixes both, so this is the first measurement of the Windows gallery under #6154's code generation. Code grows 12,399,104 -> 13,748,224 bytes while install (115,558,400, +1.66%) and wire_bytes (82,434,526, +0.69%) stay inside their 2% tolerance in the same run.", + "runs": 1, + "value": 13748224 + } + } } } } From ff206d5ab46d78f746d6c30754fd5f36df107a25 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:10:00 +0100 Subject: [PATCH 06/17] Give the Flutter benchmark's Maven build a 4 GB heap The gallery now transpiles and compiles inside Maven's JVM under #6154's larger output, and the macOS runner's default quarter of 7 GB ran out in jar packaging on the iOS leg. Co-Authored-By: Claude Opus 5.5 --- scripts/flutter-bench/app/build_apps.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/flutter-bench/app/build_apps.sh b/scripts/flutter-bench/app/build_apps.sh index 1455dcd9471..eeee082e280 100755 --- a/scripts/flutter-bench/app/build_apps.sh +++ b/scripts/flutter-bench/app/build_apps.sh @@ -96,8 +96,13 @@ CN1_LOG="$WORK/cn1-build.log" # the archetype's settings file sets ios.themeMode and and.themeMode to modern. cn1_build() { # cn1_build [extra maven args...] local plat="$1" target="$2"; shift 2 + # The heap is set, not left to the JVM's quarter of the machine. Transpiling + # and compiling the gallery all happen inside Maven's own JVM, and a macOS + # runner's quarter (about 1.75 GB of 7) ran out in jar packaging once the + # gallery compiled under #6154's larger output, while the 16 GB Linux and + # Windows runners' default 4 GB was enough. # retry.sh for Central's transient 403/429 only; a build failure is not retried. - ( cd "$CN1" && RETRY_ONLY_MATCHING=transient $XVFB bash "$HERE/../../ci/retry.sh" \ + ( cd "$CN1" && MAVEN_OPTS="${MAVEN_OPTS:+$MAVEN_OPTS }-Xmx4g" RETRY_ONLY_MATCHING=transient $XVFB bash "$HERE/../../ci/retry.sh" \ mvn -B $MVN_REPO_ARG package -DskipTests \ -DskipComplianceCheck=true \ -Dcodename1.arg.nativeTheme=custom \ From 38e2762d1996093e66dbbe81fbab6932568ea3a1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 6 Nov 2026 00:21:14 +0100 Subject: [PATCH 07/17] Rebaseline the iOS and macOS gallery sizes for #6154's code generation Both now build with the 4 GB heap; code grows about 11% on each while wire size moves under 1.5%. Co-Authored-By: Claude Opus 5.5 --- scripts/flutter-bench/baseline/pr/6172.json | 32 +++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/scripts/flutter-bench/baseline/pr/6172.json b/scripts/flutter-bench/baseline/pr/6172.json index d519a73c492..946863909f0 100644 --- a/scripts/flutter-bench/baseline/pr/6172.json +++ b/scripts/flutter-bench/baseline/pr/6172.json @@ -64,6 +64,38 @@ "value": 13748224 } } + }, + "ios": { + "gallery": { + "code_bytes": { + "from": { + "value": 18307800 + }, + "reason": "Flutter port gaps (#6154) reached master with a transpiler under which the gallery no longer compiled; this PR fixes that (and gives the build the heap it then needs on a macOS runner), so this is the first measurement of the iOS gallery under #6154's code generation. Code grows 18,307,800 -> 20,376,264 bytes while install (113,756,645 -> 115,825,110, +1.82%) and wire_bytes (83,914,831 -> 84,538,531, +0.74%) stay inside their 2% tolerance in the same run.", + "runs": 1, + "value": 20376264 + } + } + }, + "macos": { + "gallery": { + "code_bytes": { + "from": { + "value": 35268200 + }, + "reason": "Flutter port gaps (#6154) reached master with a transpiler under which the gallery no longer compiled; this PR fixes that (and gives the build the heap it then needs on a macOS runner), so this is the first measurement of the macOS gallery under #6154's code generation. Code grows 35,268,200 -> 39,385,912 bytes and install 130,793,677 -> 134,911,389 by the same 4,117,712, while the gallery's wire_bytes, gated at 2%, went 89,829,730 -> 91,095,323 (+1.41%) in the same run.", + "runs": 1, + "value": 39385912 + }, + "install_bytes": { + "from": { + "value": 130793677 + }, + "reason": "Flutter port gaps (#6154) reached master with a transpiler under which the gallery no longer compiled; this PR fixes that (and gives the build the heap it then needs on a macOS runner), so this is the first measurement of the macOS gallery under #6154's code generation. Code grows 35,268,200 -> 39,385,912 bytes and install 130,793,677 -> 134,911,389 by the same 4,117,712, while the gallery's wire_bytes, gated at 2%, went 89,829,730 -> 91,095,323 (+1.41%) in the same run.", + "runs": 1, + "value": 134911389 + } + } } } } From b9f4fc6554468a8ac113dd2c2f0f10d73d6812af Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 6 Nov 2026 00:51:10 +0100 Subject: [PATCH 08/17] Dash e2e: one bot steers a whole stretch, never a loop of short windows CityRunTest, PartyTest, PromoVideoTest and driveToBusy called driveFor in a loop of 100-200 ms windows. Each call removed the bot's hook when it returned, so the game ticked unsteered between calls, and on a slow CI runner the city run crashed in one of those gaps before its first landmark ("still running at the landmark"). driveUntil keeps one bot on the controls until the condition holds, the run ends or time is up. Co-Authored-By: Claude Opus 5.5 --- .../codenameone/examples/dash/CityRunTest.java | 12 ++++-------- .../java/com/codenameone/examples/dash/E2e.java | 16 +++++++++++----- .../com/codenameone/examples/dash/PartyTest.java | 6 +----- .../examples/dash/PromoVideoTest.java | 6 +----- 4 files changed, 17 insertions(+), 23 deletions(-) diff --git a/scripts/dash/common/src/test/java/com/codenameone/examples/dash/CityRunTest.java b/scripts/dash/common/src/test/java/com/codenameone/examples/dash/CityRunTest.java index 8c29e549301..0edfcc45bad 100644 --- a/scripts/dash/common/src/test/java/com/codenameone/examples/dash/CityRunTest.java +++ b/scripts/dash/common/src/test/java/com/codenameone/examples/dash/CityRunTest.java @@ -69,9 +69,8 @@ protected boolean run() throws Exception { click("runCity"); waitForForm("run"); final RunView view = track(); - while (view.driver().run().tick() < 60 * 12 && !driveFor(view, 200)) { - // the bot steers for the first stretch - } + // the bot steers for the first stretch + driveUntil(view, () -> view.driver().run().tick() >= 60 * 12, 120000); assertTrue(has("minimap"), "the route's minimap"); assertTrue(has("street"), "the street the runner is on"); shot("city-run", view); @@ -86,11 +85,8 @@ protected boolean run() throws Exception { } } final int ahead = mark - 45; - long give = System.currentTimeMillis() + 60000; - while (view.driver().run().runner(view.driver().me()).z / Track.UNIT < ahead - && System.currentTimeMillis() < give && !driveFor(view, 100)) { - // the bot steers up to the landmark - } + // the bot steers up to the landmark + driveUntil(view, () -> view.driver().run().runner(view.driver().me()).z / Track.UNIT >= ahead, 60000); assertTrue(!view.driver().run().over(), "still running at the landmark"); shot("city-landmark", view); finish(view, 60 * 900); diff --git a/scripts/dash/common/src/test/java/com/codenameone/examples/dash/E2e.java b/scripts/dash/common/src/test/java/com/codenameone/examples/dash/E2e.java index b160536b9e8..7116e4dac0b 100644 --- a/scripts/dash/common/src/test/java/com/codenameone/examples/dash/E2e.java +++ b/scripts/dash/common/src/test/java/com/codenameone/examples/dash/E2e.java @@ -217,6 +217,15 @@ protected void drive(final RunView view, int timeoutMillis) { /// so a test waiting on the screen does not leave the runner to crash. /// Answers whether the run ended. protected boolean driveFor(final RunView view, int millis) { + return driveUntil(view, null, millis); + } + + /// Steers with the bot until `done` holds, the run ends or `millis` of real + /// time pass, and answers whether the run ended. One bot steers the whole + /// stretch: calling `driveFor` in a loop instead took the bot off between + /// calls, the game kept ticking with nobody at the controls, and on a slow + /// CI runner a city run crashed in one of those gaps before its landmark. + protected boolean driveUntil(final RunView view, Check done, int millis) { // A race applies an input when the server has stamped it, at least // INPUT_DELAY ticks on, so the bot looks that much further ahead. final Bot bot = new Bot(7, 90, view.driver() instanceof RaceDriver ? GameProtocol.INPUT_DELAY + 4 : 0); @@ -228,7 +237,7 @@ protected boolean driveFor(final RunView view, int millis) { })); try { long end = System.currentTimeMillis() + millis; - while (System.currentTimeMillis() < end) { + while (System.currentTimeMillis() < end && (done == null || !done.ok())) { final boolean[] over = new boolean[1]; onEdt(() -> over[0] = view.driver().run().over()); if (over[0]) { @@ -334,10 +343,7 @@ protected static boolean busyAhead(RunView view, int items) { /// Lets the bot run, for at most `millis`, until the track ahead is busy. protected void driveToBusy(RunView view, int items, int millis) { - long give = System.currentTimeMillis() + millis; - while (System.currentTimeMillis() < give && !busyAhead(view, items) && !driveFor(view, 100)) { - // the bot steers - } + driveUntil(view, () -> busyAhead(view, items), millis); } /// A shot of a run in progress: the bot keeps steering while the frame diff --git a/scripts/dash/common/src/test/java/com/codenameone/examples/dash/PartyTest.java b/scripts/dash/common/src/test/java/com/codenameone/examples/dash/PartyTest.java index 8ab0f78ab9d..b4552321d47 100644 --- a/scripts/dash/common/src/test/java/com/codenameone/examples/dash/PartyTest.java +++ b/scripts/dash/common/src/test/java/com/codenameone/examples/dash/PartyTest.java @@ -62,11 +62,7 @@ protected boolean run() throws Exception { // A race only moves as the server stamps ticks, so a stalled one // would spin here until the suite's timeout killed the test with // no word of why; give it half a minute and say where it stood. - long give = System.currentTimeMillis() + 30000; - while (view.driver().run().tick() < 60 * 6 && System.currentTimeMillis() < give - && !driveFor(view, 200)) { - // keep running - } + driveUntil(view, () -> view.driver().run().tick() >= 60 * 6, 30000); int tick = view.driver().run().tick(); assertTrue(tick >= 60 * 6 || view.driver().run().over(), "the race reached tick " + tick + " of " + 60 * 6 + " in 30 s"); diff --git a/scripts/dash/common/src/test/java/com/codenameone/examples/dash/PromoVideoTest.java b/scripts/dash/common/src/test/java/com/codenameone/examples/dash/PromoVideoTest.java index 73553593276..3deb613861c 100644 --- a/scripts/dash/common/src/test/java/com/codenameone/examples/dash/PromoVideoTest.java +++ b/scripts/dash/common/src/test/java/com/codenameone/examples/dash/PromoVideoTest.java @@ -259,11 +259,7 @@ private void caption(String text, long ms) { /// Lets the bot run until the runner is `metres` down the track. private void driveTo(RunView view, int metres) { - long give = System.currentTimeMillis() + 90000; - while (view.driver().run().runner(view.driver().me()).z / 1000 < metres - && System.currentTimeMillis() < give && !driveFor(view, 100)) { - // the bot steers - } + driveUntil(view, () -> view.driver().run().runner(view.driver().me()).z / 1000 >= metres, 90000); } /// Back to the home screen, the way the tab bar takes a player there. From d9d0c63da08c2ba98bcfd4cac2eab06f68bb053f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 6 Nov 2026 04:26:07 +0100 Subject: [PATCH 09/17] Emit only the closure-signature members the generated code calls #6154 gave the Flutter gallery 340 new classes, 320 of them in DartFnSigs: a bridge class and a static factory for every signed SAM whether or not a call site used it (105 bridges, 18 used), and an interface per signature even where one SAM was its only carrier. Each is a class in the native executable, which put Linux's code behind Flutter's. Only the members a generated file names are emitted now, and a signature with a single carrier is written into that SAM. Subclass const keys share ConstCanon.join instead of inlining the concatenation into every keyed class. Gallery: 904 -> 715 generated classes, 3.70 -> 3.45 MB of class files. Co-Authored-By: Claude Opus 5.5 --- .../main/java/dart/runtime/ConstCanon.java | 9 ++ .../dart/transpiler/codegen/JavaEmitter.java | 107 +++++++++++++----- .../golden/counter/expected/MyApp.java | 7 +- .../golden/counter/expected/MyHomePage.java | 7 +- .../golden/m2demo/expected/DemoApp.java | 7 +- .../golden/m2demo/expected/DemoPage.java | 7 +- 6 files changed, 93 insertions(+), 51 deletions(-) diff --git a/maven/dart-runtime/src/main/java/dart/runtime/ConstCanon.java b/maven/dart-runtime/src/main/java/dart/runtime/ConstCanon.java index ddd3d552be8..93fe7e5415e 100644 --- a/maven/dart-runtime/src/main/java/dart/runtime/ConstCanon.java +++ b/maven/dart-runtime/src/main/java/dart/runtime/ConstCanon.java @@ -43,6 +43,15 @@ public interface Keyed { Object[] $constKey(); } + /// A subclass's key: its superclass's, then its own fields. One shared method + /// rather than the concatenation inlined into every keyed class's `$constKey()`. + public static Object[] join(Object[] inherited, Object[] own) { + Object[] r = new Object[inherited.length + own.length]; + System.arraycopy(inherited, 0, r, 0, inherited.length); + System.arraycopy(own, 0, r, inherited.length, own.length); + return r; + } + public static T object(T v) { if (!(v instanceof Keyed)) { return v; diff --git a/maven/dart-transpiler/src/main/java/com/codename1/dart/transpiler/codegen/JavaEmitter.java b/maven/dart-transpiler/src/main/java/com/codename1/dart/transpiler/codegen/JavaEmitter.java index 8e7cc1cd2bf..a4ee867625f 100644 --- a/maven/dart-transpiler/src/main/java/com/codename1/dart/transpiler/codegen/JavaEmitter.java +++ b/maven/dart-transpiler/src/main/java/com/codename1/dart/transpiler/codegen/JavaEmitter.java @@ -2993,10 +2993,8 @@ && isPureInterface(implemented)) { } body.append(" public Object[] $constKey() {\n"); if (constKeyed(ksup) || ksup == null && stubConstKeyed(c.superclass)) { - body.append(" Object[] s = super.$constKey();\n Object[] o = new Object[] {") - .append(kb).append("};\n Object[] r = new Object[s.length + o.length];\n") - .append(" System.arraycopy(s, 0, r, 0, s.length);\n") - .append(" System.arraycopy(o, 0, r, s.length, o.length);\n return r;\n"); + body.append(" return dart.runtime.ConstCanon.join(super.$constKey(), new Object[] {") + .append(kb).append("});\n"); } else { body.append(" return new Object[] {").append(kb).append("};\n"); } @@ -15038,6 +15036,28 @@ private String functionSam(TypeRef t, Ctx ctx) { } /// The holder of every signed SAM interface signClosure used. + /// The members a closure signature's `SIG` gives the interface that carries it. + private static void appendSignatureBody(StringBuilder sb, String[] m) { + sb.append(" dart.runtime.TypeWitness[] SIG = {").append(m[1]) + .append("};\n\n @Override\n default dart.runtime.TypeWitness[] $sig() {\n") + .append(" return SIG;\n }\n"); + if (Boolean.parseBoolean(m[2])) { + sb.append("\n @Override\n default boolean $generic() {\n return true;\n }\n"); + } + if (!"-1".equals(m[3])) { + sb.append("\n @Override\n default int $required() {\n return ").append(m[3]) + .append(";\n }\n"); + } + if (m[4] != null) { + sb.append("\n String[] NAMED = {").append(m[4]).append("};\n\n @Override\n") + .append(" default String[] $named() {\n return NAMED;\n }\n"); + } + if (m.length > 5 && m[5] != null) { + sb.append("\n String[] TYPE_VARS = {").append(m[5]).append("};\n\n @Override\n") + .append(" default String[] $typeVars() {\n return TYPE_VARS;\n }\n"); + } + } + private GeneratedFile closureSignatureFile(List files) { StringBuilder sb = new StringBuilder(); sb.append("package ").append(pkg).append(";\n\n"); @@ -15045,29 +15065,52 @@ private GeneratedFile closureSignatureFile(List files) { sb.append("import dart.core.*;\nimport dart.runtime.Funcs;\n\n"); sb.append("/** Closure SAMs carrying their reified Dart signatures (dart.runtime.Signed). */\n"); sb.append("public final class DartFnSigs {\n private DartFnSigs() {\n }\n"); - for (String[] m : closureSignatures.values()) { - sb.append("\n public interface ").append(m[0]).append(" extends dart.runtime.Signed {\n") - .append(" dart.runtime.TypeWitness[] SIG = {").append(m[1]) - .append("};\n\n @Override\n default dart.runtime.TypeWitness[] $sig() {\n") - .append(" return SIG;\n }\n"); - if (Boolean.parseBoolean(m[2])) { - sb.append("\n @Override\n default boolean $generic() {\n return true;\n }\n"); + // Every member below is a class of its own, and the native executable carries + // each one's metadata and static initializer whether or not it is called; the + // gallery once got 105 bridges for 18 calls. Only what a generated file names is + // emitted (explicit type arguments, `DartFnSigs.s9(..)`, + // included), and a signature that one SAM carries alone is written into that SAM + // rather than into an interface of its own. + java.util.Set refs = new java.util.HashSet(); + java.util.regex.Matcher rm = java.util.regex.Pattern.compile("DartFnSigs\\.(?:<[^()]*?>\\s*)?([MSsbc][0-9]+)\\b").matcher(""); + for (GeneratedFile g : files) { + if (g != null && g.relativePath.endsWith(".java")) { + rm.reset(g.content); + while (rm.find()) { + refs.add(rm.group(1)); + } } - if (!"-1".equals(m[3])) { - sb.append("\n @Override\n default int $required() {\n return ").append(m[3]) - .append(";\n }\n"); + } + java.util.Map sigByName = new java.util.HashMap(); + for (String[] m : closureSignatures.values()) { + sigByName.put(m[0], m); + } + java.util.Map samsPerSig = new java.util.HashMap(); + java.util.List liveSams = new java.util.ArrayList(); + for (String[] e : signedSams.values()) { + String num = e[0].substring(1); + if (refs.contains(e[0]) || refs.contains("s" + num) || refs.contains("b" + num)) { + liveSams.add(e); + Integer k = samsPerSig.get(e[3]); + samsPerSig.put(e[3], k == null ? 1 : k + 1); } - if (m[4] != null) { - sb.append("\n String[] NAMED = {").append(m[4]).append("};\n\n @Override\n") - .append(" default String[] $named() {\n return NAMED;\n }\n"); + } + java.util.Set folded = new java.util.HashSet(); + for (String[] m : closureSignatures.values()) { + Integer k = samsPerSig.get(m[0]); + boolean named = refs.contains(m[0]); + if (!named && k != null && k == 1 && sigByName.containsKey(m[0])) { + folded.add(m[0]); + continue; } - if (m.length > 5 && m[5] != null) { - sb.append("\n String[] TYPE_VARS = {").append(m[5]).append("};\n\n @Override\n") - .append(" default String[] $typeVars() {\n return TYPE_VARS;\n }\n"); + if (!named && k == null) { + continue; } + sb.append("\n public interface ").append(m[0]).append(" extends dart.runtime.Signed {\n"); + appendSignatureBody(sb, m); sb.append(" }\n"); } - for (String[] e : signedSams.values()) { + for (String[] e : liveSams) { int n = Integer.parseInt(e[2]); StringBuilder tp = new StringBuilder(); for (int i = 0; i < n; i++) { @@ -15076,17 +15119,24 @@ private GeneratedFile closureSignatureFile(List files) { if (n > 0) { tp.append('>'); } + String num = e[0].substring(1); + boolean fold = folded.contains(e[3]); sb.append("\n public interface ").append(e[0]).append(tp).append(" extends ").append(e[1]).append(tp) - .append(", ").append(e[3]).append(" {\n }\n"); - sb.append("\n public static ").append(n > 0 ? tp + " " : "").append(e[0]).append(tp).append(" s") - .append(e[0].substring(1)).append("(").append(e[0]).append(tp).append(" f) {\n") - .append(" return f;\n }\n"); + .append(", ").append(fold ? "dart.runtime.Signed" : e[3]).append(" {\n"); + if (fold) { + appendSignatureBody(sb, sigByName.get(e[3])); + } + sb.append(" }\n"); + if (refs.contains("s" + num)) { + sb.append("\n public static ").append(n > 0 ? tp + " " : "").append(e[0]).append(tp).append(" s") + .append(num).append("(").append(e[0]).append(tp).append(" f) {\n") + .append(" return f;\n }\n"); + } java.util.regex.Matcher fm = FUNCS_SAM.matcher(e[1]); - if (fm.matches()) { + if (refs.contains("b" + num) && fm.matches()) { // The value of an instance-method tear-off held as this SAM (valueTearOff). boolean isVoid = fm.group(1) != null; int arity = Integer.parseInt(fm.group(2)); - String num = e[0].substring(1); StringBuilder ps = new StringBuilder(); StringBuilder as = new StringBuilder(); for (int i = 0; i < arity; i++) { @@ -15108,6 +15158,9 @@ private GeneratedFile closureSignatureFile(List files) { } } for (String[] w : callbackTearOffs.values()) { + if (!refs.contains("c" + w[1].substring(1))) { + continue; + } // The value of an instance-method tear-off held as a stub's callback interface. String[] pt = w[2].isEmpty() ? new String[0] : w[2].split(","); StringBuilder ps = new StringBuilder(); diff --git a/maven/dart-transpiler/src/test/resources/golden/counter/expected/MyApp.java b/maven/dart-transpiler/src/test/resources/golden/counter/expected/MyApp.java index 46564fed264..2267c8094c2 100644 --- a/maven/dart-transpiler/src/test/resources/golden/counter/expected/MyApp.java +++ b/maven/dart-transpiler/src/test/resources/golden/counter/expected/MyApp.java @@ -30,12 +30,7 @@ public Widget build(BuildContext context) { } public Object[] $constKey() { - Object[] s = super.$constKey(); - Object[] o = new Object[] {}; - Object[] r = new Object[s.length + o.length]; - System.arraycopy(s, 0, r, 0, s.length); - System.arraycopy(o, 0, r, s.length, o.length); - return r; + return dart.runtime.ConstCanon.join(super.$constKey(), new Object[] {}); } static final String $cn1DartLines = "main.dart\n22:12"; diff --git a/maven/dart-transpiler/src/test/resources/golden/counter/expected/MyHomePage.java b/maven/dart-transpiler/src/test/resources/golden/counter/expected/MyHomePage.java index 36da2c5cd1f..5dc15e1e0ce 100644 --- a/maven/dart-transpiler/src/test/resources/golden/counter/expected/MyHomePage.java +++ b/maven/dart-transpiler/src/test/resources/golden/counter/expected/MyHomePage.java @@ -24,12 +24,7 @@ public State createState() { } public Object[] $constKey() { - Object[] s = super.$constKey(); - Object[] o = new Object[] {this.title}; - Object[] r = new Object[s.length + o.length]; - System.arraycopy(s, 0, r, 0, s.length); - System.arraycopy(o, 0, r, s.length, o.length); - return r; + return dart.runtime.ConstCanon.join(super.$constKey(), new Object[] {this.title}); } static final String $cn1DartLines = "main.dart\n23:29"; diff --git a/maven/dart-transpiler/src/test/resources/golden/m2demo/expected/DemoApp.java b/maven/dart-transpiler/src/test/resources/golden/m2demo/expected/DemoApp.java index a2e9eead387..165a112ed55 100644 --- a/maven/dart-transpiler/src/test/resources/golden/m2demo/expected/DemoApp.java +++ b/maven/dart-transpiler/src/test/resources/golden/m2demo/expected/DemoApp.java @@ -30,12 +30,7 @@ public Widget build(BuildContext context) { } public Object[] $constKey() { - Object[] s = super.$constKey(); - Object[] o = new Object[] {}; - Object[] r = new Object[s.length + o.length]; - System.arraycopy(s, 0, r, 0, s.length); - System.arraycopy(o, 0, r, s.length, o.length); - return r; + return dart.runtime.ConstCanon.join(super.$constKey(), new Object[] {}); } static final String $cn1DartLines = "main.dart\n22:12"; diff --git a/maven/dart-transpiler/src/test/resources/golden/m2demo/expected/DemoPage.java b/maven/dart-transpiler/src/test/resources/golden/m2demo/expected/DemoPage.java index d8fe2353afd..28a0233382e 100644 --- a/maven/dart-transpiler/src/test/resources/golden/m2demo/expected/DemoPage.java +++ b/maven/dart-transpiler/src/test/resources/golden/m2demo/expected/DemoPage.java @@ -18,12 +18,7 @@ public State createState() { } public Object[] $constKey() { - Object[] s = super.$constKey(); - Object[] o = new Object[] {}; - Object[] r = new Object[s.length + o.length]; - System.arraycopy(s, 0, r, 0, s.length); - System.arraycopy(o, 0, r, s.length, o.length); - return r; + return dart.runtime.ConstCanon.join(super.$constKey(), new Object[] {}); } static final String $cn1DartLines = "main.dart\n17:27"; From de844b3ed704c1674907964e921ff8ae761e6917 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:32:40 +0100 Subject: [PATCH 10/17] Cut the native code #6154's transpiler output added to the gallery Measured on the gallery's Linux executable, built in a container with the same toolchain on both sides: 15.30 MB before #6154, 17.00 MB after it, 16.40 MB with this change. - A statically typed num.clamp went through Dyn.call, which made every builtin's dynamic dispatch reachable (178 KB for four clamps). It calls Dyn.numClamp directly, and invoking a callable object takes a path that reaches only transpiled objects (Dyn.objectCall), not List/Map/String. - Each class of a library with renamed private names registered the whole library's spellings from a static initializer of its own. They are registered once, from FlutterRegistry.invokeMain, one packed literal per library; the behavior harness enters through invokeMain as an app does. - An enum builds its Dart-name table only when a value was renamed for Java. - $constKey() is kept only for classes constructed as constants and their superclasses (64 of 278 in the gallery). Co-Authored-By: Claude Opus 5.5 --- .../src/main/java/dart/core/Symbol.java | 13 ++ .../src/main/java/dart/runtime/Dyn.java | 86 +++++++---- .../dart/transpiler/codegen/JavaEmitter.java | 141 ++++++++++++++---- .../codegen/JavaKeywordEscaper.java | 5 + .../dart/transpiler/harness/BehaviorTest.java | 4 +- 5 files changed, 188 insertions(+), 61 deletions(-) diff --git a/maven/dart-runtime/src/main/java/dart/core/Symbol.java b/maven/dart-runtime/src/main/java/dart/core/Symbol.java index a18eb35895b..ba827ce9431 100644 --- a/maven/dart-runtime/src/main/java/dart/core/Symbol.java +++ b/maven/dart-runtime/src/main/java/dart/core/Symbol.java @@ -88,6 +88,19 @@ public static Object spell(String... pairs) { return null; } + /// Registers the pairs of {@link #spell} packed into one string, every name followed + /// by a newline (no Dart identifier contains one). One literal instead of an array + /// of them: built natively, each string constant costs code of its own. + public static void spellPacked(String pairs) { + int at = 0; + while (at < pairs.length()) { + int a = pairs.indexOf('\n', at); + int b = pairs.indexOf('\n', a + 1); + SPELLINGS.put(pairs.substring(at, a), pairs.substring(a + 1, b)); + at = b + 1; + } + } + /// The name as Dart spells it: the one given with it, or the one registered for a /// renamed private name. The renamed name stays in {@link #name} as the library /// identity, so {@code #_x} of one library is not equal to {@code #_x} of another. diff --git a/maven/dart-runtime/src/main/java/dart/runtime/Dyn.java b/maven/dart-runtime/src/main/java/dart/runtime/Dyn.java index 26efa78ffd0..c85d68e09c5 100644 --- a/maven/dart-runtime/src/main/java/dart/runtime/Dyn.java +++ b/maven/dart-runtime/src/main/java/dart/runtime/Dyn.java @@ -373,22 +373,10 @@ public static Object call(Object o, String name, Object[] args, DartMap named) { + Object v = o.$dcall(name, args, named); + if (v != NO_MEMBER) { + return v; + } + // A field holding a function: `obj.callback(x)`. + Object f = o.$dget(name); + if (f != NO_MEMBER && f != null && isFunction(f)) { + return invoke(f, args); + } + if (!(args.length == 0 && name.equals("toString")) && !(args.length == 1 && name.equals("=="))) { + // No member: Dart hands the call to noSuchMethod before throwing. + Object r = o.noSuchMethod(dart.core.Invocation.method(name, args, named)); + if (r != NO_MEMBER) { + return r; + } + } + return NO_MEMBER; + } + /// The most positional arguments the built-in member {@code name} accepts on a Map, /// Iterable, String or num; the dispatchers below only check that required ones /// are present. Unknown names answer Integer.MAX_VALUE and reach NO_MEMBER as before. @@ -840,22 +853,8 @@ private static Object numberCall(Number n, String name, Object[] a) { } return Double.valueOf(d % o.doubleValue()); } - case "clamp": { - Number lo = num(arg(a, 0)); - Number hi = num(arg(a, 1)); - // num.clamp: ordered by compareTo (NaN greatest), and the operand that wins - // is answered as it is -- 1.5.clamp(2, 3) is the int 2. - // Compared exactly, as num.compareTo does: through doubleValue() an int - // past 2^53 rounds and 9007199254740993.clamp(0, 9007199254740992.0) - // answered the receiver instead of the upper bound. - if (numCompare(lo, hi) > 0) { - throw new dart.core.ArgumentError(DartRuntime.str(lo)); - } - if (numCompare(n, lo) < 0) { - return lo; - } - return numCompare(n, hi) > 0 ? hi : n; - } + case "clamp": + return numClamp(n, num(arg(a, 0)), num(arg(a, 1))); case "toString": return DartRuntime.str(n); default: @@ -863,6 +862,23 @@ private static Object numberCall(Number n, String name, Object[] a) { } } + /// Dart's `num.clamp`: ordered by compareTo (NaN greatest), and the operand that wins + /// is answered as it is -- 1.5.clamp(2, 3) is the int 2. Compared exactly, as + /// num.compareTo does: through doubleValue() an int past 2^53 rounds and + /// 9007199254740993.clamp(0, 9007199254740992.0) answered the receiver instead of + /// the upper bound. Public so a statically typed `clamp` calls it directly: reaching + /// it through `call` makes every builtin's dynamic dispatch reachable, about 180 KB + /// of a native executable for one clamp. + public static Number numClamp(Number n, Number lo, Number hi) { + if (numCompare(lo, hi) > 0) { + throw new dart.core.ArgumentError(DartRuntime.str(lo)); + } + if (numCompare(n, lo) < 0) { + return lo; + } + return numCompare(n, hi) > 0 ? hi : n; + } + /** * Dart's {@code num.compareTo}: exact for two ints and for an int against a * double, NaN greatest, and -0.0 below 0 (so {@code 0.compareTo(-0.0)} is 1). @@ -980,7 +996,11 @@ public static Object invokeNamed(Object f, Object[] positional, String[] names, for (int k = 0; k < names.length; k++) { named.put(names[k], values[k]); } - return call(f, "call", positional, named); + Object v = objectCall((DynamicObject) f, "call", positional, named); + if (v == NO_MEMBER) { + throw noSuch(f, "call()"); + } + return v; } if (!(f instanceof Signed) || ((Signed) f).$named() == null || ((Signed) f).$generic()) { throw noSuch(f, "call"); @@ -1051,7 +1071,11 @@ private static Object invokePlaced(Object f, Object[] a) { f = SignedFns.unwrap(f); if (f instanceof DynamicObject && !isFunction(f)) { // A callable object (`dynamic p = P(); p(21)`): Dart calls its call method. - return call(f, "call", a, null); + Object v = objectCall((DynamicObject) f, "call", a, null); + if (v == NO_MEMBER) { + throw noSuch(f, "call()"); + } + return v; } if (f instanceof Signed && ((Signed) f).$named() == null && !((Signed) f).$generic()) { // A dynamic call checks each argument against the closure's own parameter diff --git a/maven/dart-transpiler/src/main/java/com/codename1/dart/transpiler/codegen/JavaEmitter.java b/maven/dart-transpiler/src/main/java/com/codename1/dart/transpiler/codegen/JavaEmitter.java index a4ee867625f..275be0df46d 100644 --- a/maven/dart-transpiler/src/main/java/com/codename1/dart/transpiler/codegen/JavaEmitter.java +++ b/maven/dart-transpiler/src/main/java/com/codename1/dart/transpiler/codegen/JavaEmitter.java @@ -160,6 +160,70 @@ private List pruneDynamicCases(List files) { return pruned; } + /// Every class given a `$constKey()`: its Java name to {the program superclass whose + /// key it extends or null, whether a runtime superclass implements ConstCanon.Keyed}. + private final Map constKeyedClasses = new java.util.HashMap(); + + /// A class's `$constKey()` is read only when one of its constants is canonicalized, + /// `ConstCanon.object(new X(..))`, or a subclass's is (its key starts with the + /// superclass's). Every other one is dead code that a native build still compiles -- + /// the gallery had 278 of them for 64 classes ever constructed as constants, about + /// 160 KB of its Linux executable -- so those are dropped once every file is known. + private List dropUnusedConstKeys(List files) { + if (constKeyedClasses.isEmpty()) { + return files; + } + java.util.Set needed = new java.util.HashSet(); + java.util.regex.Matcher m = java.util.regex.Pattern + .compile("ConstCanon\\.object\\(new ([A-Za-z0-9_$.]+)").matcher(""); + for (GeneratedFile g : files) { + if (g == null || !g.relativePath.endsWith(".java")) { + continue; + } + m.reset(g.content); + while (m.find()) { + String cn = m.group(1).substring(m.group(1).lastIndexOf('.') + 1); + while (cn != null && constKeyedClasses.containsKey(cn) && needed.add(cn)) { + cn = constKeyedClasses.get(cn)[0]; + } + } + } + List out = new ArrayList(files.size()); + for (GeneratedFile g : files) { + String cn = g == null ? null : g.relativePath.substring(g.relativePath.lastIndexOf('/') + 1); + cn = cn != null && cn.endsWith(".java") ? cn.substring(0, cn.length() - 5) : null; + String[] info = cn == null ? null : constKeyedClasses.get(cn); + if (info == null || needed.contains(cn)) { + out.add(g); + continue; + } + String c = g.content; + int at = c.indexOf(" public Object[] $constKey() {\n"); + int end = at < 0 ? -1 : c.indexOf("\n }\n\n", at); + if (end < 0) { + out.add(g); + continue; + } + c = c.substring(0, at) + c.substring(end + "\n }\n\n".length()); + if (!Boolean.parseBoolean(info[1])) { + // Unless a runtime superclass implements it, the interface goes too: a + // program superclass that is Keyed loses its $constKey() by this same rule + // whenever this class does (a needed class keeps its superclasses'). + // Wherever it sits in the list: other interfaces are added after it. + String k = "dart.runtime.ConstCanon.Keyed"; + if (c.contains("implements " + k + ", ")) { + c = c.replaceFirst("implements " + java.util.regex.Pattern.quote(k) + ", ", "implements "); + } else if (c.contains(", " + k)) { + c = c.replaceFirst(", " + java.util.regex.Pattern.quote(k), ""); + } else { + c = c.replaceFirst(" implements " + java.util.regex.Pattern.quote(k), ""); + } + } + out.add(new GeneratedFile(g.relativePath, c)); + } + return out; + } + /// The Dart file each generated Java file was emitted from, by its relative path. private final Map dartFileOf = new java.util.HashMap(); @@ -168,7 +232,7 @@ public List emit() { pinMemberTypesToOwnLibraries(); computeBoxedReturnSets(); checkStubInterfaceReturnConflicts(); - List pruned = new ArrayList(pruneDynamicCases(emitAll())); + List pruned = dropUnusedConstKeys(new ArrayList(pruneDynamicCases(emitAll()))); if (!closureSignatures.isEmpty() || !signedSams.isEmpty() || !callbackTearOffs.isEmpty()) { pruned.add(closureSignatureFile(pruned)); } @@ -1491,8 +1555,30 @@ private GeneratedFile emitRegistry(String mainLib) { sb.append("/** Generated entry-point registry for transpiled Flutter code. */\n"); sb.append("public final class FlutterRegistry {\n"); sb.append(" private FlutterRegistry() {\n }\n\n"); + // The Dart spelling of every renamed private name ({@code _x$p2}), which only a + // Symbol's toString reads, registered once before any Dart code runs. Each class + // of a renaming library used to register its library's whole table from a static + // initializer of its own: in a native build that was a kilobyte or more per + // class, repeated across every class of the library. Each library's table is one + // packed string literal (Symbol.spellPacked), which costs far less natively + // than an array of separate constants. + List spellers = new ArrayList(); + for (Map.Entry> lib : new java.util.TreeMap>( + program.privateSpellings).entrySet()) { + if (lib.getValue() == null || lib.getValue().isEmpty()) { + continue; + } + StringBuilder a = new StringBuilder(); + for (Map.Entry e : lib.getValue().entrySet()) { + a.append(e.getKey()).append('\n').append(e.getValue()).append('\n'); + } + spellers.add("dart.core.Symbol.spellPacked(" + quote(a.toString()) + ")"); + } sb.append(" /** Invokes the Dart main() of the application's main library. */\n"); sb.append(" public static void invokeMain() {\n"); + for (String m : spellers) { + sb.append(" ").append(m).append(";\n"); + } sb.append(" ").append(mainLib).append(".main$();\n"); // The end of main() is the end of Dart's first synchronous turn: what it queued // (a controller's deliveries, scheduleMicrotask) runs now, on this thread. @@ -1702,21 +1788,29 @@ private GeneratedFile emitEnum(EnumDecl e) { // Dart's `EnumType.values` is a `List`; expose it as a DartList field // (coexisting with Java's implicit values() method) so `.values.idx(i)` resolves. sb.append(";\n\n"); - sb.append(privateSpellingInit("public enum ", e.file != null ? e.file : e.ownerLibrary != null ? e.ownerLibrary.fileName : null)); sb.append(" public static final dart.core.DartList<").append(enumJava) .append("> values = dart.core.DartList.<").append(enumJava).append(">of(") .append(names).append(");\n\n"); // The Dart names, which a value whose name is a Java keyword (`native`) // does not keep as its Java constant (JavaKeywordEscaper renames it). + // Only an enum with such a value carries the table: every string in it is + // built in the class's static initializer, which in a native build costs + // hundreds of bytes per value, and the gallery has dozens of enums. StringBuilder dartNames = new StringBuilder(); + boolean renamed = false; for (String nm : names.toString().split(", ")) { if (dartNames.length() > 0) { dartNames.append(", "); } dartNames.append('"').append(nm).append('"'); + renamed |= JavaKeywordEscaper.renames(nm); + } + if (renamed) { + sb.append(" private static final String[] $dartNames = {").append(dartNames).append("};\n\n"); + sb.append(" public String name$() {\n return $dartNames[ordinal()];\n }\n\n"); + } else { + sb.append(" public String name$() {\n return name();\n }\n\n"); } - sb.append(" private static final String[] $dartNames = {").append(dartNames).append("};\n\n"); - sb.append(" public String name$() {\n return $dartNames[ordinal()];\n }\n\n"); sb.append(body); // Dart's Enum.toString is "Mode.only" -- what print and string interpolation // show -- where Java's is the bare constant name; .name is still name(). @@ -2991,8 +3085,12 @@ && isPureInterface(implemented)) { kb.append(kb.length() > 0 ? ", " : "").append("this.").append(f.name); } } + boolean keyedSuper = constKeyed(ksup) || ksup == null && stubConstKeyed(c.superclass); + constKeyedClasses.put(javaClassName(c), new String[] { + constKeyed(ksup) ? javaClassName(ksup) : null, + String.valueOf(ksup == null && stubConstKeyed(c.superclass))}); body.append(" public Object[] $constKey() {\n"); - if (constKeyed(ksup) || ksup == null && stubConstKeyed(c.superclass)) { + if (keyedSuper) { body.append(" return dart.runtime.ConstCanon.join(super.$constKey(), new Object[] {") .append(kb).append("});\n"); } else { @@ -5054,26 +5152,6 @@ private List directSubtypes(String name) { return subs; } - /// A class of a library whose private names were renamed ({@code _x$p2}) registers the - /// Dart spelling of each with the runtime when it is initialized; code of the library - /// runs only in its own classes, so the table is filled before any of its dynamic - /// accesses can make an Invocation. An interface cannot have a static block; a field - /// initializer is run when an implementing class is initialized, the interface having - /// default methods, or else when it runs none of the library's code at all. - private String privateSpellingInit(String decl, String dartFile) { - Map sp = dartFile == null ? null : program.privateSpellings.get(dartFile); - if (sp == null || sp.isEmpty()) { - return ""; - } - StringBuilder a = new StringBuilder(); - for (Map.Entry e : sp.entrySet()) { - a.append(a.length() == 0 ? "" : ", ").append(quote(e.getKey())).append(", ").append(quote(e.getValue())); - } - String call = "dart.core.Symbol.spell(" + a + ")"; - return decl.contains(" interface ") ? " Object $privateSpelling = " + call + ";\n\n" - : " static {\n " + call + ";\n }\n\n"; - } - private GeneratedFile finishClassFile(String name, String decl, String ext, String impls, CharSequence body, Ctx ctx, String dartFile) { return finishClassFile(name, decl, ext, impls, null, body, ctx, dartFile); @@ -5100,7 +5178,7 @@ private GeneratedFile finishClassFile(String name, String decl, String ext, Stri if (permits != null && !permits.isEmpty()) { sb.append(" permits ").append(permits); } - sb.append(" {\n\n").append(privateSpellingInit(decl, dartFile)).append(body).append("}\n"); + sb.append(" {\n\n").append(body).append("}\n"); return new GeneratedFile(name + ".java", sb.toString()); } @@ -29240,8 +29318,11 @@ private static boolean isNonNullKind(TypeRef t, String kind) { /// Dart's num.clamp on a boxed receiver: dispatched on the values, so the operand that /// wins comes back as it is, a Long or a Double. private Out numClamp(String boxedTarget, Out lo, Out hi, Ctx ctx) { - return new Out("((Number) dart.runtime.Dyn.call(" + boxedTarget + ", \"clamp\", new Object[] {" - + boxIfPrimitive(lo, ctx) + ", " + boxIfPrimitive(hi, ctx) + "}))", new TypeRef("num")); + // Straight to the helper, not through Dyn.call: dynamic dispatch on a builtin + // reaches every builtin's members, which a native build then has to carry. + return new Out("dart.runtime.Dyn.numClamp((Number) " + paren(boxedTarget) + ", (Number) " + + paren(boxIfPrimitive(lo, ctx)) + ", (Number) " + paren(boxIfPrimitive(hi, ctx)) + ")", + new TypeRef("num")); } private Out coreArg(Expr a, TypeRef param, Ctx ctx) { @@ -29612,6 +29693,10 @@ private Out intrinsicCall(Out target, Call c, Ctx ctx) { || n.equals("clamp") && pos.size() == 2)) { // `(c ? 7 : 2.0).abs()`: a num is a boxed Long or Double, and these answer the // receiver's own kind (an int stays an int), so they dispatch on the value. + if (n.equals("clamp")) { + return numClamp(boxIfPrimitive(target, ctx), coreArg(pos.get(0), new TypeRef("num"), ctx), + coreArg(pos.get(1), new TypeRef("num"), ctx), ctx); + } StringBuilder args = new StringBuilder(); for (int i = 0; i < pos.size(); i++) { Out ao = coreArg(pos.get(i), new TypeRef("num"), ctx); diff --git a/maven/dart-transpiler/src/main/java/com/codename1/dart/transpiler/codegen/JavaKeywordEscaper.java b/maven/dart-transpiler/src/main/java/com/codename1/dart/transpiler/codegen/JavaKeywordEscaper.java index 65a43d17d1d..918831764d0 100644 --- a/maven/dart-transpiler/src/main/java/com/codename1/dart/transpiler/codegen/JavaKeywordEscaper.java +++ b/maven/dart-transpiler/src/main/java/com/codename1/dart/transpiler/codegen/JavaKeywordEscaper.java @@ -51,6 +51,11 @@ final class JavaKeywordEscaper { private JavaKeywordEscaper() { } + /// Whether `name`, used as an identifier, is one this pass renames. + static boolean renames(String name) { + return CANDIDATES.contains(name); + } + static String apply(String src) { boolean any = false; for (String k : CANDIDATES) { diff --git a/maven/dart-transpiler/src/test/java/com/codename1/dart/transpiler/harness/BehaviorTest.java b/maven/dart-transpiler/src/test/java/com/codename1/dart/transpiler/harness/BehaviorTest.java index a6a91ff1705..78d5f5d2b94 100644 --- a/maven/dart-transpiler/src/test/java/com/codename1/dart/transpiler/harness/BehaviorTest.java +++ b/maven/dart-transpiler/src/test/java/com/codename1/dart/transpiler/harness/BehaviorTest.java @@ -147,8 +147,8 @@ private void runCase(File dir) throws Exception { File runner = new File(work, "src/Runner.java"); TestSupport.write(runner, "public class Runner {\n" + " public static void main(String[] args) {\n" - + " " + TestSupport.PKG + ".MainLib.main$();\n" - + " dart.async.Async.runMicrotasks();\n" + // The app's own entry point, which registers what main() relies on. + + " " + TestSupport.PKG + ".FlutterRegistry.invokeMain();\n" + " }\n" + "}\n"); javacArgs.add(runner.getAbsolutePath()); From e3e05a14b02738c97d30ca15e51ea4bf2af09ee6 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 6 Nov 2026 20:41:11 +0200 Subject: [PATCH 11/17] Lower lambdas to shared carriers instead of a class per lambda A lambda becomes an instance of a carrier -- one class per functional interface and capture shape -- holding a function address and its captures. Brought over from the python-hero-ports work; on the Flutter gallery it cuts 268 KB of native code. Co-Authored-By: Claude Opus 5.5 --- .../tools/translator/ByteCodeClass.java | 7 + .../tools/translator/BytecodeMethod.java | 5 + .../tools/translator/IteratorEscape.java | 52 +++- .../tools/translator/LambdaCarriers.java | 266 ++++++++++++++++++ .../codename1/tools/translator/Parser.java | 176 ++++++++++++ .../tools/translator/StreamFusion.java | 39 ++- .../tools/translator/bytecodes/Invoke.java | 7 + 7 files changed, 539 insertions(+), 13 deletions(-) create mode 100644 vm/ByteCodeTranslator/src/com/codename1/tools/translator/LambdaCarriers.java diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeClass.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeClass.java index 90bf7b789c8..32ec47b4338 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeClass.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeClass.java @@ -732,6 +732,13 @@ public void updateAllDependencies() { ByteCodeClass depClass = Parser.getClassObject(dep); if (depClass != null && depClass.getConcreteClass() != null) { String concrete = depClass.getConcreteClass().replace('/', '_').replace('$', '_').replace('-', '_'); + // A concrete class that is not part of this translation (a program + // built without a port) is no dependency: Invoke resolves nothing + // against it and the call stays virtual, so naming it here only + // emitted an #include of a header no translation writes. + if (Parser.getClassObject(concrete) == null) { + continue; + } if (!dependsClassesInterfaces.contains(concrete) && !concreteExtras.contains(concrete)) { concreteExtras.add(concrete); } diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/BytecodeMethod.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/BytecodeMethod.java index 2cc36e557ad..e6a6f2f2d21 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/BytecodeMethod.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/BytecodeMethod.java @@ -3667,6 +3667,11 @@ public void setMaxes(int maxStack, int maxLocals) { this.maxStack = maxStack; } + /** Appends a synthesized instruction (see {@link LambdaCarriers}). */ + public void addRawInstruction(Instruction i) { + addInstruction(i); + } + private void addInstruction(Instruction i) { instructions.add(i); i.setMethod(this); diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/IteratorEscape.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/IteratorEscape.java index 86d5d6cf75f..b05d31319ea 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/IteratorEscape.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/IteratorEscape.java @@ -183,19 +183,59 @@ static int clusterEscapes(BytecodeMethod m, String owner) { /// stores knowable from the call site, where every reference parameter has already /// been shown to be a cluster member. static boolean ctorOnlyStoresParamsIntoThis(String owner, String desc) { - BytecodeMethod ctor = null; - String mangled = mangle(owner); - for (ByteCodeClass c : Parser.getClasses()) { - if (!mangle(c.getClsName()).equals(mangled)) { - continue; + // Called for every constructor call on a tracked receiver, so both the class + // lookup and the answer are memoized. Scanning the class list (mangling every + // name) per call was quadratic: a translation of a few thousand classes spent + // tens of minutes here. Both caches are dropped whenever the class list changes. + syncCaches(); + String key = owner + '\u0000' + desc; + Boolean known = CTOR_ANSWERS.get(key); + if (known != null) { + return known.booleanValue(); + } + boolean answer = ctorOnlyStoresParamsIntoThisUncached(owner, desc); + CTOR_ANSWERS.put(key, Boolean.valueOf(answer)); + return answer; + } + + private static final java.util.Map CTOR_ANSWERS = new java.util.HashMap(); + private static final java.util.Map BY_MANGLED_NAME = new java.util.HashMap(); + private static List cachedList; + private static int cachedSize = -1; + + /// Forgets everything memoized; Parser calls it when it starts a new class list. + static void resetCaches() { + cachedList = null; + cachedSize = -1; + } + + private static void syncCaches() { + List all = Parser.getClasses(); + if (all == cachedList && all.size() == cachedSize) { + return; + } + CTOR_ANSWERS.clear(); + BY_MANGLED_NAME.clear(); + for (ByteCodeClass c : all) { + String m = mangle(c.getClsName()); + if (!BY_MANGLED_NAME.containsKey(m)) { + BY_MANGLED_NAME.put(m, c); } + } + cachedList = all; + cachedSize = all.size(); + } + + private static boolean ctorOnlyStoresParamsIntoThisUncached(String owner, String desc) { + BytecodeMethod ctor = null; + ByteCodeClass c = BY_MANGLED_NAME.get(mangle(owner)); + if (c != null) { for (BytecodeMethod m : c.getMethods()) { if ("__INIT__".equals(m.getMethodName()) && desc.equals(m.getDesc())) { ctor = m; break; } } - break; } if (ctor == null || ctor.isNative() || ctor.isAbstract()) { return false; // an unknown body is an unchecked body diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/LambdaCarriers.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/LambdaCarriers.java new file mode 100644 index 00000000000..f25542e17e7 --- /dev/null +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/LambdaCarriers.java @@ -0,0 +1,266 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.tools.translator; + +import com.codename1.tools.translator.bytecodes.CustomIntruction; +import com.codename1.tools.translator.bytecodes.Invoke; +import com.codename1.tools.translator.classfile.Opcodes; +import com.codename1.tools.translator.classfile.Type; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Lowers a lambda to a function pointer instead of a class. + * + * A lambda is a function plus the values it captured; it has no behaviour of its own + * beyond calling that function. Giving every lambda its own class costs a struct, a + * class object, a vtable, a constructor, a factory and a virtual wrapper per inherited + * method -- about 150 lines of C and one of the 65535 class ids each -- and a Python + * application transpiles to tens of thousands of lambdas. + * + * Here a lambda is an instance of a CARRIER: one class per functional interface and + * capture shape, shared by every lambda of that shape. It holds a function address + * in {@code fn} and the captures in {@code c0..cN}, erased to Object, int, long, float + * or double, and its single method calls {@code fn(captures..., arguments...)}. + * + * That is the calling convention javac already compiles a lambda body to -- a private + * method of the enclosing class taking the captures first, with {@code this} as the + * receiver when the body uses it -- so {@code fn} is normally that method itself, and a + * static method reference is the referenced method. Only when the C types differ + * (boxing, widening, a constructor reference, a virtual target) does the declaring + * class get a static bridge with the same convention that adapts and calls on. + * + * So calling a lambda is the interface dispatch it always was, followed by one + * indirect call. A lambda that captures nothing is still allocated once. + * + * Only the C targets use this. The JavaScript backend keeps a class per lambda, + * because its type-flow analysis summarises each lambda class individually. + */ +final class LambdaCarriers { + private static final Map CARRIERS = new HashMap(); + private static int counter; + + private LambdaCarriers() { + } + + static void reset() { + CARRIERS.clear(); + counter = 0; + } + + /** The erased kind a capture of this type is stored as: O, I, J, F or D. */ + static char kind(Type t) { + switch (t.getSort()) { + case Type.OBJECT: + case Type.ARRAY: + return 'O'; + case Type.LONG: + return 'J'; + case Type.FLOAT: + return 'F'; + case Type.DOUBLE: + return 'D'; + default: + return 'I'; + } + } + + static String fieldDesc(char kind) { + return kind == 'O' ? "Ljava/lang/Object;" : String.valueOf(kind); + } + + /** The C type a capture of this kind is stored and passed as. */ + static String kindCType(char kind) { + switch (kind) { + case 'O': return "JAVA_OBJECT"; + case 'J': return "JAVA_LONG"; + case 'F': return "JAVA_FLOAT"; + case 'D': return "JAVA_DOUBLE"; + default: return "JAVA_INT"; + } + } + + private static String kindMember(char kind) { + switch (kind) { + case 'O': return "o"; + case 'J': return "l"; + case 'F': return "f"; + case 'D': return "d"; + default: return "i"; + } + } + + static String shape(Type[] captures) { + StringBuilder b = new StringBuilder(captures.length); + for (Type t : captures) { + b.append(kind(t)); + } + return b.toString(); + } + + static String cType(Type t) { + switch (t.getSort()) { + case Type.BOOLEAN: return "JAVA_BOOLEAN"; + case Type.BYTE: return "JAVA_BYTE"; + case Type.CHAR: return "JAVA_CHAR"; + case Type.SHORT: return "JAVA_SHORT"; + case Type.INT: return "JAVA_INT"; + case Type.LONG: return "JAVA_LONG"; + case Type.FLOAT: return "JAVA_FLOAT"; + case Type.DOUBLE: return "JAVA_DOUBLE"; + case Type.VOID: return "JAVA_VOID"; + default: return "JAVA_OBJECT"; + } + } + + private static String push(Type t) { + switch (t.getSort()) { + case Type.LONG: return "PUSH_LONG"; + case Type.FLOAT: return "PUSH_FLOAT"; + case Type.DOUBLE: return "PUSH_DOUBLE"; + case Type.OBJECT: + case Type.ARRAY: return "PUSH_OBJ"; + default: return "PUSH_INT"; + } + } + + /** The carrier's interface method. */ + static BytecodeMethod sam(ByteCodeClass carrier, String name, String desc) { + for (BytecodeMethod m : carrier.getMethods()) { + if (name.equals(m.getMethodName()) && desc.equals(m.getSignature())) { + return m; + } + } + return null; + } + + /** + * The carrier for lambdas implementing {@code samName samDesc} of {@code iface} + * with captures of the given shape, created on first use. + */ + static ByteCodeClass carrier(String iface, String samName, String samDesc, String shape, + List classes) { + String key = iface + '.' + samName + samDesc + '/' + shape; + ByteCodeClass c = CARRIERS.get(key); + if (c != null) { + return c; + } + String original = iface + "$cn1fn" + (counter++); + String name = Util.mangle(original); + c = new ByteCodeClass(name, original); + c.setBaseClass("java/lang/Object"); + c.setBaseInterfaces(new String[]{iface}); + c.addField(new ByteCodeField(name, Opcodes.ACC_PRIVATE, "fn", "J", null, null)); + for (int i = 0; i < shape.length(); i++) { + c.addField(new ByteCodeField(name, Opcodes.ACC_PRIVATE, "c" + i, fieldDesc(shape.charAt(i)), null, null)); + } + + BytecodeMethod ctor = new BytecodeMethod(name, Opcodes.ACC_PUBLIC, "", "()V", null, null); + c.addMethod(ctor); + ctor.addVariableOperation(Opcodes.ALOAD, 0); + ctor.addInvoke(Opcodes.INVOKESPECIAL, "java/lang/Object", "", "()V", false); + ctor.addInstruction(Opcodes.RETURN); + ctor.setMaxes(1, 1); + + // The interface method: call fn with the captures, in order, followed by the + // arguments exactly as received. Captures and fn are read with real GETFIELDs + // (so the dead-field pass sees their readers) and taken off the stack by C. + BytecodeMethod sam = new BytecodeMethod(name, Opcodes.ACC_PUBLIC, samName, samDesc, null, null); + c.addMethod(sam); + Type[] args = Type.getArgumentTypes(samDesc); + Type ret = Type.getReturnType(samDesc); + int n = shape.length(); + for (int i = 0; i < n; i++) { + sam.addVariableOperation(Opcodes.ALOAD, 0); + sam.addField(c, Opcodes.GETFIELD, name, "c" + i, fieldDesc(shape.charAt(i))); + } + sam.addVariableOperation(Opcodes.ALOAD, 0); + sam.addField(c, Opcodes.GETFIELD, name, "fn", "J"); + StringBuilder fnType = new StringBuilder("(").append(cType(ret)).append(" (*)(CODENAME_ONE_THREAD_STATE"); + StringBuilder call = new StringBuilder("(threadStateData"); + for (int i = 0; i < n; i++) { + char k = shape.charAt(i); + fnType.append(", ").append(kindCType(k)); + call.append(", SP[-").append(n + 1 - i).append("].data.").append(kindMember(k)); + } + for (int i = 0; i < args.length; i++) { + fnType.append(", ").append(cType(args[i])); + call.append(", __cn1Arg").append(i + 1); + } + fnType.append("))"); + call.append(')'); + String target = "(" + fnType + "(intptr_t)SP[-1].data.l)" + call; + String code = ret.getSort() == Type.VOID + ? " " + target + "; SP -= " + (n + 1) + ";\n" + : " { " + cType(ret) + " cn1fnResult = " + target + "; SP -= " + (n + 1) + "; " + + push(ret) + "(cn1fnResult); }\n"; + sam.addRawInstruction(new IndirectCall(code)); + sam.addInstruction(ret.getOpcode(Opcodes.IRETURN)); + sam.setMaxes(n + 4, args.length * 2 + 1); + + CARRIERS.put(key, c); + classes.add(c); + return c; + } + + /** The call through fn: a call, so its method gets a real frame. */ + static final class IndirectCall extends CustomIntruction { + IndirectCall(String code) { + super(code, code, null); + } + + @Override + public boolean isComplexInstruction() { + return true; + } + } + + /** + * Pushes the address of a static method as a long. It counts as a call of that + * method, so the dead-code pass keeps a bridge that nothing calls by name. + */ + static final class FunctionAddress extends CustomIntruction { + private final Invoke target; + + FunctionAddress(String owner, String name, String desc) { + super(code(owner, name, desc), code(owner, name, desc), + java.util.Collections.singletonList(Util.mangle(owner))); + target = new Invoke(Opcodes.INVOKESTATIC, owner, name, desc, false); + } + + private static String code(String owner, String name, String desc) { + StringBuilder sym = new StringBuilder(Util.mangle(owner)).append('_').append(name).append("__"); + BytecodeMethod.appendMethodSignatureSuffixFromDesc(desc, sym, new ArrayList()); + return " PUSH_LONG((JAVA_LONG)(intptr_t)&" + sym + ");\n"; + } + + @Override public String getMethodUsed() { return target.getMethodUsed(); } + @Override public String getMethodName() { return target.getMethodName(); } + @Override public String getSignature() { return target.getSignature(); } + @Override public boolean containsSignature(SignatureSet s) { return target.containsSignature(s); } + } +} diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/Parser.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/Parser.java index f20c47f8d72..f2761577b0e 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/Parser.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/Parser.java @@ -498,6 +498,8 @@ public static void cleanup() { cn1SubclassIndexSize = -1; nativeSources = null; classes.clear(); + IteratorEscape.resetCaches(); + LambdaCarriers.reset(); // classes is cleared in place (same List reference), so the name index's // (reference, size) guard cannot detect a subsequent same-size refill across // translation runs in the same JVM (e.g. the unit tests). Invalidate it here. @@ -2809,6 +2811,10 @@ && recordMethod(name, desc, bsmArgs)) { if ("java/lang/invoke/LambdaMetafactory".equals(bsm.getOwner()) && ("metafactory".equals(bsm.getName()) || "altMetafactory".equals(bsm.getName()))) { + if (ByteCodeTranslator.output != ByteCodeTranslator.OutputType.OUTPUT_TYPE_JAVASCRIPT + && lowerToCarrier(name, desc, bsm, bsmArgs)) { + return; + } // 1. Generate a unique class name for the lambda String lambdaClassName = clsName + "_lambda_" + (lambdaCounter++); @@ -3087,6 +3093,176 @@ && recordMethod(name, desc, bsmArgs)) { * (e.g. when a method reference's parameter or return type differs from * the functional interface's). A no-op when the types already match. */ + /** + * Lowers a lambda to a shared carrier that calls javac's own method, or a + * bridge in this class when the C types differ; see {@link LambdaCarriers}. + * Answers false, leaving the class-per-lambda path, for + * an altMetafactory that asks for marker interfaces or bridge methods. + */ + private boolean lowerToCarrier(String name, String desc, Handle bsm, Object[] bsmArgs) { + if ("altMetafactory".equals(bsm.getName())) { + if (bsmArgs.length < 4 || !(bsmArgs[3] instanceof Integer) + || (((Integer) bsmArgs[3]).intValue() & ~1) != 0) { + return false; + } + } + Type invokedType = Type.getMethodType(desc); + Type[] captured = invokedType.getArgumentTypes(); + Type samType = (Type) bsmArgs[0]; + Handle implMethod = (Handle) bsmArgs[1]; + String shape = LambdaCarriers.shape(captured); + ByteCodeClass carrier = LambdaCarriers.carrier(invokedType.getReturnType().getInternalName(), + name, samType.getDescriptor(), shape, classes); + String carrierName = carrier.getClsName(); + int n = lambdaCounter++; + + Type[] samArgs = samType.getArgumentTypes(); + boolean isCtorRef = implMethod.getTag() == Opcodes.H_NEWINVOKESPECIAL; + int invokeOpcode; + switch (implMethod.getTag()) { + case Opcodes.H_INVOKEVIRTUAL: invokeOpcode = Opcodes.INVOKEVIRTUAL; break; + case Opcodes.H_INVOKEINTERFACE: invokeOpcode = Opcodes.INVOKEINTERFACE; break; + case Opcodes.H_INVOKESPECIAL: + case Opcodes.H_NEWINVOKESPECIAL: invokeOpcode = Opcodes.INVOKESPECIAL; break; + default: invokeOpcode = Opcodes.INVOKESTATIC; break; + } + boolean instanceCall = !isCtorRef && invokeOpcode != Opcodes.INVOKESTATIC; + Type[] implArgTypes = Type.getArgumentTypes(implMethod.getDesc()); + Type[] targetTypes = new Type[(instanceCall ? 1 : 0) + implArgTypes.length]; + int tIdx = 0; + if (instanceCall) { + targetTypes[tIdx++] = Type.getObjectType(implMethod.getOwner()); + } + for (Type t : implArgTypes) { + targetTypes[tIdx++] = t; + } + Type returnType = samType.getReturnType(); + Type implReturnType = isCtorRef ? Type.getObjectType(implMethod.getOwner()) + : Type.getReturnType(implMethod.getDesc()); + + // fn is javac's own method when its C signature is already + // fn(captures..., arguments...): a static method of this class, or a + // lambda body of this class (private, so a direct, non-virtual C function) + // taking `this`. Another class's method could be translated to a macro or + // an intrinsic with no address, so it goes through a bridge. + boolean direct = !isCtorRef && targetTypes.length == captured.length + samArgs.length + && LambdaCarriers.cType(implReturnType).equals(LambdaCarriers.cType(returnType)) + && Util.mangle(implMethod.getOwner()).equals(clsName) + && (invokeOpcode == Opcodes.INVOKESTATIC + || (implMethod.getName().startsWith("lambda$") + && (invokeOpcode == Opcodes.INVOKESPECIAL || invokeOpcode == Opcodes.INVOKEVIRTUAL))); + for (int i = 0; direct && i < targetTypes.length; i++) { + String have = i < captured.length ? LambdaCarriers.kindCType(shape.charAt(i)) + : LambdaCarriers.cType(samArgs[i - captured.length]); + direct = have.equals(LambdaCarriers.cType(targetTypes[i])); + } + String fnOwner; + String fnName; + String fnDesc; + if (direct) { + fnOwner = implMethod.getOwner(); + fnName = implMethod.getName(); + fnDesc = implMethod.getDesc(); + } else { + // A bridge with the carrier's convention that adapts and calls on. + StringBuilder bridgeDesc = new StringBuilder("("); + for (int i = 0; i < captured.length; i++) { + bridgeDesc.append(LambdaCarriers.fieldDesc(shape.charAt(i))); + } + for (Type t : samArgs) { + bridgeDesc.append(t.getDescriptor()); + } + bridgeDesc.append(')').append(returnType.getDescriptor()); + fnOwner = clsName; + fnName = "cn1fnBridge" + n; + fnDesc = bridgeDesc.toString(); + BytecodeMethod bridge = new BytecodeMethod(clsName, Opcodes.ACC_PRIVATE | Opcodes.ACC_STATIC + | Opcodes.ACC_SYNTHETIC, fnName, fnDesc, null, null); + cls.addMethod(bridge); + boolean adapt = targetTypes.length == captured.length + samArgs.length; + if (isCtorRef) { + bridge.addTypeInstruction(Opcodes.NEW, implMethod.getOwner()); + bridge.addInstruction(Opcodes.DUP); + } + int local = 0; + int targetIndex = 0; + for (int i = 0; i < captured.length; i++) { + char kind = shape.charAt(i); + Type stored = Type.getType(LambdaCarriers.fieldDesc(kind)); + bridge.addVariableOperation(stored.getOpcode(Opcodes.ILOAD), local); + local += stored.getSize(); + if (kind == 'O' && !"java/lang/Object".equals(captured[i].getInternalName())) { + bridge.addTypeInstruction(Opcodes.CHECKCAST, captured[i].getInternalName()); + } + if (adapt) { + adaptLambdaType(bridge, captured[i], targetTypes[targetIndex]); + } + targetIndex++; + } + for (Type t : samArgs) { + bridge.addVariableOperation(t.getOpcode(Opcodes.ILOAD), local); + local += t.getSize(); + if (adapt) { + adaptLambdaType(bridge, t, targetTypes[targetIndex]); + } + targetIndex++; + } + bridge.addInvoke(invokeOpcode, implMethod.getOwner(), implMethod.getName(), implMethod.getDesc(), + !isCtorRef && implMethod.isInterface()); + adaptLambdaType(bridge, implReturnType, returnType); + bridge.addInstruction(returnType.getOpcode(Opcodes.IRETURN)); + bridge.setMaxes(20, local); + } + + String carrierDesc = "L" + carrierName + ";"; + String factoryName = "cn1fnNew" + n; + String factoryDesc = desc.substring(0, desc.lastIndexOf(')') + 1) + carrierDesc; + BytecodeMethod factory = new BytecodeMethod(clsName, Opcodes.ACC_PRIVATE | Opcodes.ACC_STATIC + | Opcodes.ACC_SYNTHETIC, factoryName, factoryDesc, null, null); + cls.addMethod(factory); + Label haveIt = null; + String cacheName = "cn1fnInstance" + n; + if (captured.length == 0) { + // Indistinguishable instances: allocate once (see the class-per-lambda + // path below for why this needs no lock). + cls.addField(new ByteCodeField(clsName, Opcodes.ACC_PRIVATE | Opcodes.ACC_STATIC + | Opcodes.ACC_SYNTHETIC, cacheName, carrierDesc, null, null)); + haveIt = new Label(); + factory.addField(cls, Opcodes.GETSTATIC, clsName, cacheName, carrierDesc); + factory.addInstruction(Opcodes.DUP); + factory.addJump(Opcodes.IFNONNULL, haveIt); + factory.addInstruction(Opcodes.POP); + } + factory.addTypeInstruction(Opcodes.NEW, carrierName); + factory.addInstruction(Opcodes.DUP); + factory.addInvoke(Opcodes.INVOKESPECIAL, carrierName, "", "()V", false); + factory.addInstruction(Opcodes.DUP); + factory.addRawInstruction(new LambdaCarriers.FunctionAddress(fnOwner, fnName, fnDesc)); + factory.addField(carrier, Opcodes.PUTFIELD, carrierName, "fn", "J"); + int arg = 0; + for (int i = 0; i < captured.length; i++) { + factory.addInstruction(Opcodes.DUP); + factory.addVariableOperation(captured[i].getOpcode(Opcodes.ILOAD), arg); + arg += captured[i].getSize(); + factory.addField(carrier, Opcodes.PUTFIELD, carrierName, "c" + i, + LambdaCarriers.fieldDesc(shape.charAt(i))); + } + if (haveIt != null) { + factory.addInstruction(Opcodes.DUP); + factory.addField(cls, Opcodes.PUTSTATIC, clsName, cacheName, carrierDesc); + factory.addLabel(haveIt); + } + factory.addInstruction(Opcodes.ARETURN); + factory.setMaxes(6, arg); + mtd.addInvoke(Opcodes.INVOKESTATIC, clsName, factoryName, factoryDesc, false); + com.codename1.tools.translator.bytecodes.Invoke site = + (com.codename1.tools.translator.bytecodes.Invoke) mtd.getInstructions().get(mtd.getInstructions().size() - 1); + site.setLambdaSam(LambdaCarriers.sam(carrier, name, samType.getDescriptor())); + site.setLambdaCarrier(carrierName, new com.codename1.tools.translator.bytecodes.Invoke( + Opcodes.INVOKESTATIC, fnOwner, fnName, fnDesc, false)); + return true; + } + private void adaptLambdaType(BytecodeMethod m, Type from, Type to) { if (from.equals(to)) { return; diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/StreamFusion.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/StreamFusion.java index 3e5e200daf3..a2d5b3ecfb5 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/StreamFusion.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/StreamFusion.java @@ -109,7 +109,10 @@ static List lower(BytecodeMethod method) { if (item.factory != null) { nextRoot += Type.getArgumentTypes(item.factory.getDesc()).length; BytecodeMethod sam = item.factory.getLambdaSam(); - dependencies.add(new Invoke(Opcodes.INVOKESPECIAL, item.factory.getOwner(), + Invoke bridge = item.factory.getLambdaBridge(); + dependencies.add(bridge != null ? new Invoke(Opcodes.INVOKESTATIC, bridge.getOwner(), + bridge.getName(), bridge.getDesc(), false) + : new Invoke(Opcodes.INVOKESPECIAL, item.factory.getOwner(), sam.getMethodName(), sam.getSignature(), false)); } } @@ -162,9 +165,26 @@ private static void save(StringBuilder out, int slot, String value) { } private static String callback(Stage stage, int index, String element) { BytecodeMethod sam = stage.factory.getLambdaSam(); - StringBuilder name = new StringBuilder(Util.mangle(stage.factory.getOwner())); - name.append('_').append(sam.getMethodName()).append("__"); - BytecodeMethod.appendMethodSignatureSuffixFromDesc(sam.getSignature(), name, new ArrayList()); + Invoke bridge = stage.factory.getLambdaBridge(); + StringBuilder name; + if (bridge != null) { + // A carrier lambda: call its function directly with the stack carrier's + // captures, which is the carrier's own calling convention. + String carrier = stage.factory.getLambdaCarrier(); + name = new StringBuilder(Util.mangle(bridge.getOwner())); + name.append('_').append(bridge.getName()).append("__"); + BytecodeMethod.appendMethodSignatureSuffixFromDesc(bridge.getDesc(), name, new ArrayList()); + name.append("(threadStateData"); + int captures = Type.getArgumentTypes(stage.factory.getDesc()).length; + for (int c = 0; c < captures; c++) { + name.append(", __sfLambda").append(index).append('.').append(carrier).append("_c").append(c); + } + return name + ", " + element + ")"; + } else { + name = new StringBuilder(Util.mangle(stage.factory.getOwner())); + name.append('_').append(sam.getMethodName()).append("__"); + BytecodeMethod.appendMethodSignatureSuffixFromDesc(sam.getSignature(), name, new ArrayList()); + } return name + "(threadStateData, (JAVA_OBJECT)&__sfLambda" + index + ", " + element + ")"; } @@ -187,7 +207,10 @@ private static final class Fused extends Instruction { @Override public boolean isComplexInstruction() { return true; } @Override public void addDependencies(List dependencies) { dependencies.add("java_lang_IllegalArgumentException"); - for (Stage stage : stages) if (stage.factory != null) dependencies.add(Util.mangle(stage.factory.getOwner())); + for (Stage stage : stages) if (stage.factory != null) { + dependencies.add(Util.mangle(stage.factory.getOwner())); + if (stage.factory.getLambdaCarrier() != null) dependencies.add(stage.factory.getLambdaCarrier()); + } } @Override public void appendInstruction(StringBuilder out, List ignored) { out.append("{ /* fused array stream; callbacks cannot escape */\n"); @@ -197,7 +220,8 @@ private static final class Fused extends Instruction { Stage stage = stages.get(i); for (Instruction argument : stage.arguments) argument.appendInstruction(out, Collections.emptyList()); if (stage.factory != null) { - String cls = Util.mangle(stage.factory.getOwner()); + String carrier = stage.factory.getLambdaCarrier(); + String cls = carrier != null ? carrier : Util.mangle(stage.factory.getOwner()); Type[] captures = Type.getArgumentTypes(stage.factory.getDesc()); out.append("__STATIC_INITIALIZER_").append(cls).append("(threadStateData);\n"); out.append("struct obj__").append(cls).append(" __sfLambda").append(i).append(" = {0};\n") @@ -211,7 +235,8 @@ private static final class Fused extends Instruction { save(out, stage.captureRoot + c, value); value = root(stage.captureRoot + c); } - out.append("__sfLambda").append(i).append('.').append(cls).append("_arg_").append(c + 1) + out.append("__sfLambda").append(i).append('.').append(cls) + .append(carrier != null ? "_c" + c : "_arg_" + (c + 1)) .append(" = ").append(value).append(";\n"); } out.append("POP_MANY(").append(captures.length).append(");\n"); diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/bytecodes/Invoke.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/bytecodes/Invoke.java index 65ade4ba2be..387c5035341 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/bytecodes/Invoke.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/bytecodes/Invoke.java @@ -40,6 +40,13 @@ public class Invoke extends Instruction { private BytecodeMethod lambdaSam; public BytecodeMethod getLambdaSam() { return lambdaSam; } public void setLambdaSam(BytecodeMethod method) { lambdaSam = method; } + // For a lambda lowered to a carrier (LambdaCarriers): the carrier class and the + // static bridge that runs the lambda's body given the carrier. + private String lambdaCarrier; + private Invoke lambdaBridge; + public String getLambdaCarrier() { return lambdaCarrier; } + public Invoke getLambdaBridge() { return lambdaBridge; } + public void setLambdaCarrier(String carrier, Invoke bridge) { lambdaCarrier = carrier; lambdaBridge = bridge; } private String exactReceiverType; public void setClosedWorldReceiverTypes(java.util.Set types) { From 446219ee801c86fd60de1637173e7ba7137c871c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 6 Nov 2026 20:41:23 +0200 Subject: [PATCH 12/17] Let a lambda factory be frameless The frameless path rejected any custom instruction, and FunctionAddress -- a constant push -- is the one every lambda factory has, so all 2,080 of the Flutter gallery's factories got a full frame. -103 KB. Co-Authored-By: Claude Opus 5.5 --- .../com/codename1/tools/translator/BytecodeMethod.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/BytecodeMethod.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/BytecodeMethod.java index e6a6f2f2d21..c326f52f1b9 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/BytecodeMethod.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/BytecodeMethod.java @@ -843,6 +843,14 @@ private boolean isFramelessEligibleImpl() { hasRealInstruction = true; continue; } + if (i instanceof LambdaCarriers.FunctionAddress) { + // Pushes a function's address as a long constant -- no call, no object. + // It is the one custom instruction of every lambda factory, and treating + // it as unrecognized gave each of those factories a full frame: about + // 580 KB of the Flutter gallery's native code. + hasRealInstruction = true; + continue; + } if (i instanceof BasicInstruction) { if (isFramelessBasicOpcode(i.getOpcode())) { hasRealInstruction = true; From 6fe03796df361d09b75b36d7e5d5acfe25e9ff46 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 6 Nov 2026 20:41:23 +0200 Subject: [PATCH 13/17] Allocate out of line at NEW sites, keeping only the init guard inline CN1_FAST_NEW expanded the always_inline bump allocator at all 9,951 NEW sites of the Flutter gallery: 894 KB of a 16.0 MB executable. cn1BibopFastNew is the same path behind one direct call. Interleaved vm/benchmarks A/B against -DCN1_INLINE_ALLOC_SITES: geomean 1.004, objectAllocation 1.009, checksums identical. Co-Authored-By: Claude Opus 5.5 --- vm/ByteCodeTranslator/src/cn1_globals.h | 15 +++++++++++++++ vm/ByteCodeTranslator/src/cn1_globals.m | 21 +++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/vm/ByteCodeTranslator/src/cn1_globals.h b/vm/ByteCodeTranslator/src/cn1_globals.h index 1d3aac94cd3..1b10dcdbfa7 100644 --- a/vm/ByteCodeTranslator/src/cn1_globals.h +++ b/vm/ByteCodeTranslator/src/cn1_globals.h @@ -3263,6 +3263,13 @@ static inline JAVA_OBJECT cn1BibopFastAllocNoZero(CODENAME_ONE_THREAD_STATE, int #else #define CN1_BIBOP_NOTE_FINALIZABLE(X, o) do {} while(0) #endif +/* The bump allocation is OUT OF LINE (cn1BibopFastNew), and only the class-init guard + * stays at the site. The site used to expand cn1BibopFastAlloc -- an always_inline bump, + * body zero, header store and publish -- at every NEW in the program: measured on the + * Flutter gallery, 9,951 sites and 894 KB of its 16.0 MB executable, more than any + * library it links. One direct call per allocation buys that back. -DCN1_INLINE_ALLOC_SITES + * restores the inline expansion for an A/B. */ +#ifdef CN1_INLINE_ALLOC_SITES #define CN1_FAST_NEW(X) ({ \ if(__builtin_expect(!__atomic_load_n(&class__##X.initialized, __ATOMIC_ACQUIRE), 0)) __STATIC_INITIALIZER_##X(threadStateData); \ JAVA_OBJECT __cn1fo = cn1BibopFastAlloc(threadStateData, sizeof(struct obj__##X), &class__##X, CN1_BIBOP_CIDX(sizeof(struct obj__##X)), CN1_CLASS_HEADER_INDEX(X)); \ @@ -3272,6 +3279,14 @@ static inline JAVA_OBJECT cn1BibopFastAllocNoZero(CODENAME_ONE_THREAD_STATE, int } \ CN1_BIBOP_NOTE_FINALIZABLE(X, __cn1fo); \ __cn1fo; }) +#else +extern JAVA_OBJECT cn1BibopFastNew(CODENAME_ONE_THREAD_STATE, int size, struct clazz* parent, int ci, uint16_t index, int finalizable); +#define CN1_FAST_NEW(X) ({ \ + if(__builtin_expect(!__atomic_load_n(&class__##X.initialized, __ATOMIC_ACQUIRE), 0)) __STATIC_INITIALIZER_##X(threadStateData); \ + JAVA_OBJECT __cn1fo = cn1BibopFastNew(threadStateData, sizeof(struct obj__##X), &class__##X, CN1_BIBOP_CIDX(sizeof(struct obj__##X)), CN1_CLASS_HEADER_INDEX(X), CN1_FINALIZABLE_##X); \ + if(__builtin_expect(__cn1fo == (JAVA_OBJECT)0, 0)) __cn1fo = __NEW_##X(threadStateData); \ + __cn1fo; }) +#endif // No-body-zero variant (init-before-publish). The slow-path fallback __NEW_X // still fully zeroes (calloc) -- correct, just un-elided on the rare page-full // path. diff --git a/vm/ByteCodeTranslator/src/cn1_globals.m b/vm/ByteCodeTranslator/src/cn1_globals.m index 2f01e8444c1..c63ed684eef 100644 --- a/vm/ByteCodeTranslator/src/cn1_globals.m +++ b/vm/ByteCodeTranslator/src/cn1_globals.m @@ -12302,6 +12302,27 @@ void cn1GcRegisterImmortalObj(JAVA_OBJECT o) { // byte-for-byte what it was, because trimming or growing it has measured slower on every // attempt (REGISTRY Rounds 38 and 39). Answers 0 when there is no current page or its free // list is empty; the caller then takes __NEW_X exactly as before. +// The out-of-line half of CN1_FAST_NEW (see cn1_globals.h). Answers 0 when the class is +// oversized for the page heap or no slot is free, and the site falls back to __NEW_X. +JAVA_OBJECT cn1BibopFastNew(CODENAME_ONE_THREAD_STATE, int size, struct clazz* parent, int ci, uint16_t index, int finalizable) { + JAVA_OBJECT o = cn1BibopFastAlloc(threadStateData, size, parent, ci, index); + if(__builtin_expect(o == (JAVA_OBJECT)0, 0)) { + if(ci < 0) { + return o; + } + o = cn1BibopAllocRecycled(threadStateData, size, parent, ci); + if(o == (JAVA_OBJECT)0) { + return o; + } + } +#if !defined(CN1_BIBOP_NO_FASTSWEEP) + if(finalizable && CN1_OBJ_HEAPPOS(o) == CN1_BIBOP_HEAP_POS) { + ((CN1BibopPage*)((uintptr_t)(o) & ~((uintptr_t)CN1_BIBOP_PAGE_SIZE - 1)))->gcHasFinalizable = JAVA_TRUE; + } +#endif + return o; +} + JAVA_OBJECT cn1BibopAllocRecycled(CODENAME_ONE_THREAD_STATE, int size, struct clazz* parent, int ci) { // A trapped real-time cycle falls through to __NEW_X and codenameOneGcMalloc, // where cn1RtTrappedAlloc decides; a recycled slot taken here would bypass it. From ce338e711c07ba8bcba24c50f51e44a6d024b386 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 7 Nov 2026 06:49:30 +0200 Subject: [PATCH 14/17] Rebaseline the Windows arm64 hello perf row for the lambda carriers Lowering lambdas to shared carriers made translating HelloCodenameOne cheaper on both runtimes; ParparVM got faster, JDK 25 faster still, so the ratio moved without a VM regression. Measurements are in the row's reason. Co-Authored-By: Claude Opus 5.5 --- vm/selfhost/perf-baseline/pr/6172.json | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 vm/selfhost/perf-baseline/pr/6172.json diff --git a/vm/selfhost/perf-baseline/pr/6172.json b/vm/selfhost/perf-baseline/pr/6172.json new file mode 100644 index 00000000000..221261b2b51 --- /dev/null +++ b/vm/selfhost/perf-baseline/pr/6172.json @@ -0,0 +1,25 @@ +{ + "pr": 6172, + "rebaseline": { + "windows-arm64@armv8-64-bit-family-8-model-d49-microsoft-corporation": { + "hello": { + "all": { + "from": { + "memory": 0.752, + "time": 1.107, + "tolerance": { + "memory": 0.35 + } + }, + "memory": 0.752, + "reason": "Lambdas lower to shared carriers (e3e05a14b0): the translator no longer writes a class per lambda, so translating HelloCodenameOne got cheaper on both runtimes. ParparVM itself got faster (macOS, no LTO as Windows builds it: 9.36s -> 8.52s; with LTO 7.77s -> 7.09s), but JDK 25 gained more (8.66s -> 7.20s), moving the ratio. Inline vs out-of-line allocation measured identical (8.51s vs 8.53s).", + "runs": 1, + "time": 1.447, + "tolerance": { + "memory": 0.35 + } + } + } + } + } +} From 55e01af082db38d97788e19b32f027adcf2d9319 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 7 Nov 2026 07:46:10 +0200 Subject: [PATCH 15/17] Count a referenced SVG's syndication PNG as used in the guide image check Co-Authored-By: Claude Opus 5.5 --- scripts/developer-guide/find_unused_images.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/developer-guide/find_unused_images.py b/scripts/developer-guide/find_unused_images.py index c597b98705d..a30256472b1 100755 --- a/scripts/developer-guide/find_unused_images.py +++ b/scripts/developer-guide/find_unused_images.py @@ -95,6 +95,12 @@ def main() -> None: rel_path = image_path.relative_to(doc_root).as_posix() if rel_path in references: continue + # A PNG beside a referenced SVG is that SVG rasterized for syndication + # (rasterize_blog_svgs.py): blog posts share these diagrams, and other + # platforms are sent the PNG. The guide itself shows the SVG. + if (image_path.suffix.lower() == ".png" + and Path(rel_path).with_suffix(".svg").as_posix() in references): + continue if rel_path in allowed_unused: continue unused.append(rel_path) From 736b57578f5549534ecf5b3e2a0a69e9d4069cdf Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 7 Nov 2026 09:59:38 +0200 Subject: [PATCH 16/17] Rebaseline the Linux gallery's cold-start ratio for #6154's code generation Every run since the gallery compiles again measured 0.76-0.85 (still faster than Flutter); the commits here do not move start-up. Evidence in the row. Co-Authored-By: Claude Opus 5.5 --- scripts/flutter-bench/baseline/pr/6172.json | 80 +++++++++++---------- 1 file changed, 44 insertions(+), 36 deletions(-) diff --git a/scripts/flutter-bench/baseline/pr/6172.json b/scripts/flutter-bench/baseline/pr/6172.json index 946863909f0..e18abd8b375 100644 --- a/scripts/flutter-bench/baseline/pr/6172.json +++ b/scripts/flutter-bench/baseline/pr/6172.json @@ -1,79 +1,75 @@ { "pr": 6172, "rebaseline": { - "javascript": { + "android": { "gallery": { "code_bytes": { "from": { - "value": 16457801 - }, - "reason": "Flutter port gaps (#6154) reached master with a transpiler under which the gallery no longer compiled; this PR fixes that, so this is the first measurement of the gallery under #6154's code generation (async lowering, signed tear-offs, type witnesses). The raw script grows by 1,208,552 bytes (code and install move by the same amount) while the gallery's own wire_bytes, gated at 2%, went 80,155,363 -> 80,334,160 (+0.22%) in the same run: the growth is repetitive generated code gzip absorbs.", - "runs": 1, - "value": 17666353 - }, - "install_bytes": { - "from": { - "value": 111957604 + "value": 4965216 }, - "reason": "Flutter port gaps (#6154) reached master with a transpiler under which the gallery no longer compiled; this PR fixes that, so this is the first measurement of the gallery under #6154's code generation (async lowering, signed tear-offs, type witnesses). The raw script grows by 1,208,552 bytes (code and install move by the same amount) while the gallery's own wire_bytes, gated at 2%, went 80,155,363 -> 80,334,160 (+0.22%) in the same run: the growth is repetitive generated code gzip absorbs.", + "reason": "Flutter port gaps (#6154) reached master with a transpiler under which the gallery no longer compiled; this PR fixes that, so this is the first measurement of the gallery's dex under #6154's code generation. Executable code grows 4,965,216 -> 5,447,152 bytes while the APK itself, gated at 2%, went 82,093,372 -> 82,262,888 (+0.21%) in the same run.", "runs": 1, - "value": 113166156 + "value": 5447152 } } }, - "android": { + "ios": { "gallery": { "code_bytes": { "from": { - "value": 4965216 + "value": 18307800 }, - "reason": "Flutter port gaps (#6154) reached master with a transpiler under which the gallery no longer compiled; this PR fixes that, so this is the first measurement of the gallery's dex under #6154's code generation. Executable code grows 4,965,216 -> 5,447,152 bytes while the APK itself, gated at 2%, went 82,093,372 -> 82,262,888 (+0.21%) in the same run.", + "reason": "Flutter port gaps (#6154) reached master with a transpiler under which the gallery no longer compiled; this PR fixes that (and gives the build the heap it then needs on a macOS runner), so this is the first measurement of the iOS gallery under #6154's code generation. Code grows 18,307,800 -> 20,376,264 bytes while install (113,756,645 -> 115,825,110, +1.82%) and wire_bytes (83,914,831 -> 84,538,531, +0.74%) stay inside their 2% tolerance in the same run.", "runs": 1, - "value": 5447152 + "value": 20376264 } } }, - "linux": { + "javascript": { "gallery": { "code_bytes": { "from": { - "value": 16754402 + "value": 16457801 }, - "reason": "Flutter port gaps (#6154) reached master with a transpiler under which the gallery no longer compiled, and with a Dart runtime calling String.split, which the native translation lacks; this PR fixes both, so this is the first measurement of the Linux gallery under #6154's code generation. Code grows 16,754,402 -> 18,748,258 bytes and install 119,197,408 -> 121,747,040 by about the same amount, while the gallery's wire_bytes, gated at 2%, went 85,769,588 -> 86,583,233 (+0.95%) in the same run.", + "reason": "Flutter port gaps (#6154) reached master with a transpiler under which the gallery no longer compiled; this PR fixes that, so this is the first measurement of the gallery under #6154's code generation (async lowering, signed tear-offs, type witnesses). The raw script grows by 1,208,552 bytes (code and install move by the same amount) while the gallery's own wire_bytes, gated at 2%, went 80,155,363 -> 80,334,160 (+0.22%) in the same run: the growth is repetitive generated code gzip absorbs.", "runs": 1, - "value": 18748258 + "value": 17666353 }, "install_bytes": { "from": { - "value": 119197408 + "value": 111957604 }, - "reason": "Flutter port gaps (#6154) reached master with a transpiler under which the gallery no longer compiled, and with a Dart runtime calling String.split, which the native translation lacks; this PR fixes both, so this is the first measurement of the Linux gallery under #6154's code generation. Code grows 16,754,402 -> 18,748,258 bytes and install 119,197,408 -> 121,747,040 by about the same amount, while the gallery's wire_bytes, gated at 2%, went 85,769,588 -> 86,583,233 (+0.95%) in the same run.", + "reason": "Flutter port gaps (#6154) reached master with a transpiler under which the gallery no longer compiled; this PR fixes that, so this is the first measurement of the gallery under #6154's code generation (async lowering, signed tear-offs, type witnesses). The raw script grows by 1,208,552 bytes (code and install move by the same amount) while the gallery's own wire_bytes, gated at 2%, went 80,155,363 -> 80,334,160 (+0.22%) in the same run: the growth is repetitive generated code gzip absorbs.", "runs": 1, - "value": 121747040 + "value": 113166156 } } }, - "windows": { + "linux": { "gallery": { "code_bytes": { "from": { - "value": 12399104 + "value": 16754402 }, - "reason": "Flutter port gaps (#6154) reached master with a transpiler under which the gallery no longer compiled, and with a Dart runtime calling String.split, which the native translation lacks; this PR fixes both, so this is the first measurement of the Windows gallery under #6154's code generation. Code grows 12,399,104 -> 13,748,224 bytes while install (115,558,400, +1.66%) and wire_bytes (82,434,526, +0.69%) stay inside their 2% tolerance in the same run.", + "reason": "Flutter port gaps (#6154) reached master with a transpiler under which the gallery no longer compiled, and with a Dart runtime calling String.split, which the native translation lacks; this PR fixes both, so this is the first measurement of the Linux gallery under #6154's code generation. Code grows 16,754,402 -> 18,748,258 bytes and install 119,197,408 -> 121,747,040 by about the same amount, while the gallery's wire_bytes, gated at 2%, went 85,769,588 -> 86,583,233 (+0.95%) in the same run.", "runs": 1, - "value": 13748224 - } - } - }, - "ios": { - "gallery": { - "code_bytes": { + "value": 18748258 + }, + "cold_start_ratio": { "from": { - "value": 18307800 + "value": 0.67 }, - "reason": "Flutter port gaps (#6154) reached master with a transpiler under which the gallery no longer compiled; this PR fixes that (and gives the build the heap it then needs on a macOS runner), so this is the first measurement of the iOS gallery under #6154's code generation. Code grows 18,307,800 -> 20,376,264 bytes while install (113,756,645 -> 115,825,110, +1.82%) and wire_bytes (83,914,831 -> 84,538,531, +0.74%) stay inside their 2% tolerance in the same run.", + "reason": "First start-up measurements of the gallery under #6154's code generation, which this PR makes compile: every run of this branch since then measured Codename One/Flutter between 0.76 and 0.85 (still a win, 1.17-1.31x faster), against 0.67 from the gallery before #6154. The lambda-carrier and allocation commits do not move it: pre- and post-change gallery binaries launched alternately in the Linux container reached their first frame in the same time (median 1.21s vs 1.17s, 6 launches each).", + "runs": 3, + "value": 0.821 + }, + "install_bytes": { + "from": { + "value": 119197408 + }, + "reason": "Flutter port gaps (#6154) reached master with a transpiler under which the gallery no longer compiled, and with a Dart runtime calling String.split, which the native translation lacks; this PR fixes both, so this is the first measurement of the Linux gallery under #6154's code generation. Code grows 16,754,402 -> 18,748,258 bytes and install 119,197,408 -> 121,747,040 by about the same amount, while the gallery's wire_bytes, gated at 2%, went 85,769,588 -> 86,583,233 (+0.95%) in the same run.", "runs": 1, - "value": 20376264 + "value": 121747040 } } }, @@ -96,6 +92,18 @@ "value": 134911389 } } + }, + "windows": { + "gallery": { + "code_bytes": { + "from": { + "value": 12399104 + }, + "reason": "Flutter port gaps (#6154) reached master with a transpiler under which the gallery no longer compiled, and with a Dart runtime calling String.split, which the native translation lacks; this PR fixes both, so this is the first measurement of the Windows gallery under #6154's code generation. Code grows 12,399,104 -> 13,748,224 bytes while install (115,558,400, +1.66%) and wire_bytes (82,434,526, +0.69%) stay inside their 2% tolerance in the same run.", + "runs": 1, + "value": 13748224 + } + } } } } From 3971a394ef6098a6a4dc08425042864b6b662c77 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 7 Nov 2026 12:51:04 +0200 Subject: [PATCH 17/17] Make the game e2e waits and the site search test immune to runner load The Dash and Skirmish online tests asserted wall-clock totals (360 ticks in 30 s, 100 server ticks in 30 s), so a runner shared with a dozen other jobs failed them while the game was still ticking. They now fail only when the ticks stop for 10 s, which is the stall they were written to catch. The site search test ran against wrangler pages dev, whose workerd exits when Chromium resets a connection on navigation; settling first (#6092) did not stop it. Search is static files only, so it now runs against a plain file server; the redirect checks keep the Pages runtime. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/website-docs.yml | 17 +++++++++++- .../examples/dash/CityRunTest.java | 8 ++++-- .../com/codenameone/examples/dash/E2e.java | 23 +++++++++++++++- .../codenameone/examples/dash/PartyTest.java | 8 +++--- .../codenameone/examples/skirmish/E2e.java | 26 +++++++++++++++++++ .../examples/skirmish/OnlineTest.java | 8 +++--- 6 files changed, 80 insertions(+), 10 deletions(-) diff --git a/.github/workflows/website-docs.yml b/.github/workflows/website-docs.yml index 70ff29079d0..b2950591d14 100644 --- a/.github/workflows/website-docs.yml +++ b/.github/workflows/website-docs.yml @@ -426,7 +426,22 @@ jobs: --base-url "${PAGES_DEV_URL}" \ --redirects-file docs/website/public/_redirects \ --skip-auto-cases - node scripts/website/test_search.mjs "${PAGES_DEV_URL}" + # Search is static files only (prebuilt lunr indexes), so it runs + # against a plain file server: workerd exits when Chromium resets a + # connection mid-write on navigation ("::write ... Connection reset + # by peer"), which no settling before navigate prevented (#6092). + # The redirects above are what needs the Pages runtime. + SEARCH_PORT="$(pages_dev_free_port)" + python3 -m http.server "${SEARCH_PORT}" --bind 127.0.0.1 \ + --directory docs/website/public >/tmp/search-static.log 2>&1 & + SEARCH_PID=$! + for _ in $(seq 1 30); do + curl -fsS -o /dev/null --max-time 2 "http://127.0.0.1:${SEARCH_PORT}/" && break + sleep 1 + done + node scripts/website/test_search.mjs "http://127.0.0.1:${SEARCH_PORT}" \ + || { kill "${SEARCH_PID}"; exit 1; } + kill "${SEARCH_PID}" - name: Validate internal links and images uses: lycheeverse/lychee-action@v2 diff --git a/scripts/dash/common/src/test/java/com/codenameone/examples/dash/CityRunTest.java b/scripts/dash/common/src/test/java/com/codenameone/examples/dash/CityRunTest.java index 0edfcc45bad..3ff2c6c4399 100644 --- a/scripts/dash/common/src/test/java/com/codenameone/examples/dash/CityRunTest.java +++ b/scripts/dash/common/src/test/java/com/codenameone/examples/dash/CityRunTest.java @@ -70,7 +70,9 @@ protected boolean run() throws Exception { waitForForm("run"); final RunView view = track(); // the bot steers for the first stretch - driveUntil(view, () -> view.driver().run().tick() >= 60 * 12, 120000); + driveWhileTicking(view, () -> view.driver().run().tick() >= 60 * 12, 10000); + assertTrue(view.driver().run().tick() >= 60 * 12 || view.driver().run().over(), + "the run stalled at tick " + view.driver().run().tick() + " (no tick for 10 s)"); assertTrue(has("minimap"), "the route's minimap"); assertTrue(has("street"), "the street the runner is on"); shot("city-run", view); @@ -86,8 +88,10 @@ protected boolean run() throws Exception { } final int ahead = mark - 45; // the bot steers up to the landmark - driveUntil(view, () -> view.driver().run().runner(view.driver().me()).z / Track.UNIT >= ahead, 60000); + driveWhileTicking(view, () -> view.driver().run().runner(view.driver().me()).z / Track.UNIT >= ahead, 10000); assertTrue(!view.driver().run().over(), "still running at the landmark"); + assertTrue(view.driver().run().runner(view.driver().me()).z / Track.UNIT >= ahead, + "the run stalled short of the landmark (no tick for 10 s)"); shot("city-landmark", view); finish(view, 60 * 900); awaitResult("the result"); diff --git a/scripts/dash/common/src/test/java/com/codenameone/examples/dash/E2e.java b/scripts/dash/common/src/test/java/com/codenameone/examples/dash/E2e.java index 7116e4dac0b..929072b61a1 100644 --- a/scripts/dash/common/src/test/java/com/codenameone/examples/dash/E2e.java +++ b/scripts/dash/common/src/test/java/com/codenameone/examples/dash/E2e.java @@ -226,6 +226,18 @@ protected boolean driveFor(final RunView view, int millis) { /// calls, the game kept ticking with nobody at the controls, and on a slow /// CI runner a city run crashed in one of those gaps before its landmark. protected boolean driveUntil(final RunView view, Check done, int millis) { + return drive(view, done, millis, false); + } + + /// Steers like `driveUntil`, but `stallMillis` is how long the run may go + /// without a tick, not a budget for the whole stretch: a stall still fails + /// fast, while a runner that merely shares its CPUs with a dozen other jobs + /// gets there later instead of failing on a wall-clock total. + protected boolean driveWhileTicking(final RunView view, Check done, int stallMillis) { + return drive(view, done, stallMillis, true); + } + + private boolean drive(final RunView view, Check done, int millis, boolean resetOnTick) { // A race applies an input when the server has stamped it, at least // INPUT_DELAY ticks on, so the bot looks that much further ahead. final Bot bot = new Bot(7, 90, view.driver() instanceof RaceDriver ? GameProtocol.INPUT_DELAY + 4 : 0); @@ -237,12 +249,21 @@ protected boolean driveUntil(final RunView view, Check done, int millis) { })); try { long end = System.currentTimeMillis() + millis; + int lastTick = -1; while (System.currentTimeMillis() < end && (done == null || !done.ok())) { final boolean[] over = new boolean[1]; - onEdt(() -> over[0] = view.driver().run().over()); + final int[] tick = new int[1]; + onEdt(() -> { + over[0] = view.driver().run().over(); + tick[0] = view.driver().run().tick(); + }); if (over[0]) { return true; } + if (resetOnTick && tick[0] != lastTick) { + lastTick = tick[0]; + end = System.currentTimeMillis() + millis; + } waitFor(16); } } finally { diff --git a/scripts/dash/common/src/test/java/com/codenameone/examples/dash/PartyTest.java b/scripts/dash/common/src/test/java/com/codenameone/examples/dash/PartyTest.java index b4552321d47..98ece49d38c 100644 --- a/scripts/dash/common/src/test/java/com/codenameone/examples/dash/PartyTest.java +++ b/scripts/dash/common/src/test/java/com/codenameone/examples/dash/PartyTest.java @@ -61,11 +61,13 @@ protected boolean run() throws Exception { // race and not of a runner who hit the first barrier. // A race only moves as the server stamps ticks, so a stalled one // would spin here until the suite's timeout killed the test with - // no word of why; give it half a minute and say where it stood. - driveUntil(view, () -> view.driver().run().tick() >= 60 * 6, 30000); + // no word of why. Stalled means no tick for 10 s -- never a + // wall-clock budget for the whole stretch, which a loaded runner + // missed while still ticking (tick 78 of 360 in 30 s). + driveWhileTicking(view, () -> view.driver().run().tick() >= 60 * 6, 10000); int tick = view.driver().run().tick(); assertTrue(tick >= 60 * 6 || view.driver().run().over(), - "the race reached tick " + tick + " of " + 60 * 6 + " in 30 s"); + "the race stalled at tick " + tick + " of " + 60 * 6 + " (no tick for 10 s)"); guest.emote("wow"); guest.say("Catch me if you can!"); click("emote-wave"); diff --git a/scripts/skirmish/common/src/test/java/com/codenameone/examples/skirmish/E2e.java b/scripts/skirmish/common/src/test/java/com/codenameone/examples/skirmish/E2e.java index 588953baeca..c299efd3d2e 100644 --- a/scripts/skirmish/common/src/test/java/com/codenameone/examples/skirmish/E2e.java +++ b/scripts/skirmish/common/src/test/java/com/codenameone/examples/skirmish/E2e.java @@ -145,6 +145,32 @@ protected boolean has(String name) { return findByName(name) != null; } + protected interface Counter { + int value(); + } + + /// Waits for `check` while `progress` keeps moving: `stallMillis` is how + /// long it may stand still, not a budget for the whole wait. A server that + /// stops ticking still fails fast; a runner sharing its CPUs with a dozen + /// other jobs only gets there later, where a wall-clock total failed it. + protected void untilAdvancing(Check check, Counter progress, int stallMillis, String what) { + int last = progress.value(); + long deadline = System.currentTimeMillis() + stallMillis; + while (!check.ok()) { + int now = progress.value(); + if (now != last) { + last = now; + deadline = System.currentTimeMillis() + stallMillis; + } else if (System.currentTimeMillis() > deadline) { + Form current = Display.getInstance().getCurrent(); + shot("stalled-" + (current == null ? "no-form" : current.getName())); + throw new RuntimeException("Stalled waiting for " + what + " at " + now + " (nothing moved for " + + stallMillis + " ms, on " + (current == null ? "no form" : current.getName()) + ")"); + } + waitFor(100); + } + } + protected void until(Check check, int timeoutMillis, String what) { long deadline = System.currentTimeMillis() + timeoutMillis; while (!check.ok()) { diff --git a/scripts/skirmish/common/src/test/java/com/codenameone/examples/skirmish/OnlineTest.java b/scripts/skirmish/common/src/test/java/com/codenameone/examples/skirmish/OnlineTest.java index 1469f45ae72..76e5e9aa796 100644 --- a/scripts/skirmish/common/src/test/java/com/codenameone/examples/skirmish/OnlineTest.java +++ b/scripts/skirmish/common/src/test/java/com/codenameone/examples/skirmish/OnlineTest.java @@ -35,10 +35,12 @@ protected boolean run() throws Exception { final BattleView view = arena(); assertTrue(view.driver() instanceof OnlineDriver, "an online battle"); // The clock only moves when the server says it may: reaching tick 100 - // means the ticks are arriving. - until(() -> view.driver().match().tick() > 100, 30000, "server ticks"); + // means the ticks are arriving. Both waits fail when the ticks stop, + // never on how long a loaded runner took to reach them. + until(() -> view.driver().match() != null, 30000, "the match"); final Match m = view.driver().match(); - until(() -> m.player(view.driver().me()).wholeElixir() >= 7, 30000, "elixir"); + untilAdvancing(() -> m.tick() > 100, () -> m.tick(), 10000, "server ticks"); + untilAdvancing(() -> m.player(view.driver().me()).wholeElixir() >= 7, () -> m.tick(), 10000, "elixir"); touch("card0"); touch(view.getAbsoluteX() + view.getWidth() * 2 / 3, view.getAbsoluteY() + view.getHeight() * 3 / 4); until(() -> m.player(view.driver().me()).wholeElixir() < 7, 10000, "the card to be accepted");