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/.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/.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/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/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/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/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-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-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/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..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,12 +3085,14 @@ && 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)) { - 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"); + if (keyedSuper) { + 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"); } @@ -5056,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); @@ -5102,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()); } @@ -11703,6 +11779,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; @@ -15035,6 +15114,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"); @@ -15042,29 +15143,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++) { @@ -15073,17 +15197,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++) { @@ -15105,6 +15236,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(); @@ -17406,11 +17540,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 +23766,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 +23876,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 +24804,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 +27935,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) { @@ -29135,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) { @@ -29507,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); @@ -30490,7 +30680,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/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()); 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"; 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` 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/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..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 @@ -69,9 +69,10 @@ 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 + 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,12 +87,11 @@ 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 + 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 b160536b9e8..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 @@ -217,6 +217,27 @@ 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) { + 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); @@ -228,12 +249,21 @@ protected boolean driveFor(final RunView view, int millis) { })); try { long end = System.currentTimeMillis() + millis; - while (System.currentTimeMillis() < end) { + 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 { @@ -334,10 +364,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..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,15 +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. - long give = System.currentTimeMillis() + 30000; - while (view.driver().run().tick() < 60 * 6 && System.currentTimeMillis() < give - && !driveFor(view, 200)) { - // keep running - } + // 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/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. 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) 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 \ diff --git a/scripts/flutter-bench/baseline/pr/6172.json b/scripts/flutter-bench/baseline/pr/6172.json new file mode 100644 index 00000000000..e18abd8b375 --- /dev/null +++ b/scripts/flutter-bench/baseline/pr/6172.json @@ -0,0 +1,109 @@ +{ + "pr": 6172, + "rebaseline": { + "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 + } + } + }, + "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 + } + } + }, + "javascript": { + "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 + }, + "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": 113166156 + } + } + }, + "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 + }, + "cold_start_ratio": { + "from": { + "value": 0.67 + }, + "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": 121747040 + } + } + }, + "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 + } + } + }, + "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 + } + } + } + } +} diff --git a/scripts/initializr/common/src/main/resources/dash-modules.zip b/scripts/initializr/common/src/main/resources/dash-modules.zip index 422b0179088..5aaa4982d51 100644 Binary files a/scripts/initializr/common/src/main/resources/dash-modules.zip and b/scripts/initializr/common/src/main/resources/dash-modules.zip differ 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"); 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. 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..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; @@ -3667,6 +3675,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) { 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 + } + } + } + } + } +}