diff --git a/.github/workflows/website-docs.yml b/.github/workflows/website-docs.yml index 708698e283..a23dc8ea3b 100644 --- a/.github/workflows/website-docs.yml +++ b/.github/workflows/website-docs.yml @@ -144,6 +144,11 @@ jobs: WEBSITE_INCLUDE_DEVGUIDE: "true" WEBSITE_INCLUDE_INITIALIZR: "auto" WEBSITE_INCLUDE_PLAYGROUND: "auto" + # The Initializr's JavaScript app is built with the local ParparVM + # target, whose builder lives in the repo (8.0-SNAPSHOT) plugin rather + # than the pinned release. Bootstrap the local snapshot artifacts so + # the initializr (and the other site apps) build against repo HEAD. + WEBSITE_BOOTSTRAP_CN1_SNAPSHOTS: "true" # PR previews build with future-dated posts visible so reviewers # can read posts staged for later in the week. Production deploys # (push to master) keep the default so future posts only appear diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/platform/js/NativeInterfaceBridge.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/platform/js/NativeInterfaceBridge.java new file mode 100644 index 0000000000..e919b3b908 --- /dev/null +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/platform/js/NativeInterfaceBridge.java @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2026 Codename One and contributors. + * Licensed under the PolyForm Noncommercial License 1.0.0. + * You may use this file only in compliance with that license. + * The license notice for this subtree is available in Ports/JavaScriptPort/LICENSE.md. + */ +package com.codename1.impl.platform.js; + +/** + * Host bridge that dispatches Codename One {@code NativeInterface} method calls + * to their JavaScript implementation registered in {@code cn1_native_interfaces}. + * + *

The generated {@code Impl} classes (emitted by the JavaScript + * builder) delegate every interface method to one of the {@code call*} natives + * below, picked by the method's return type. These natives are runtime- + * implemented in {@code parparvm_runtime.js}: the worker suspends, the call is + * replayed on the main thread (via {@code browser_bridge.js}) where the + * developer-authored JS stub runs with full DOM access and completes the call + * through its callback, and the worker resumes with the result coerced to the + * declared Java type.

+ * + *

Supported types mirror {@code NativeInterface}: all primitives, {@code String}, + * primitive arrays plus {@code String[]} (via {@link #callArray}), and + * {@code com.codename1.ui.PeerComponent} (routed through {@link #callObject}).

+ * + *

{@code iface} is the interface class name with dots replaced by underscores + * (the {@code cn1_native_interfaces} registry key), {@code method} is the + * trailing-underscore method key (e.g. {@code "isDarkMode_"}), and {@code args} + * holds the (boxed) Java arguments, or an empty array for a no-arg method.

+ */ +public final class NativeInterfaceBridge { + private NativeInterfaceBridge() { + } + + public static native boolean callBoolean(String iface, String method, Object[] args); + + public static native byte callByte(String iface, String method, Object[] args); + + public static native short callShort(String iface, String method, Object[] args); + + public static native int callInt(String iface, String method, Object[] args); + + public static native char callChar(String iface, String method, Object[] args); + + public static native long callLong(String iface, String method, Object[] args); + + public static native float callFloat(String iface, String method, Object[] args); + + public static native double callDouble(String iface, String method, Object[] args); + + public static native String callString(String iface, String method, Object[] args); + + public static native Object callObject(String iface, String method, Object[] args); + + public static native void callVoid(String iface, String method, Object[] args); + + /** + * Array-returning call. {@code componentToken} identifies the element type so + * the runtime can build the correctly-typed Java array: {@code "JAVA_INT"}, + * {@code "JAVA_BYTE"}, {@code "JAVA_LONG"}, {@code "JAVA_DOUBLE"}, + * {@code "JAVA_FLOAT"}, {@code "JAVA_BOOLEAN"}, {@code "JAVA_CHAR"}, + * {@code "JAVA_SHORT"} or {@code "java_lang_String"}. The caller casts the + * result to the concrete array type. + */ + public static native Object callArray(String iface, String method, Object[] args, String componentToken); +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/JavaScriptBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/JavaScriptBuilder.java index 0afae0e7db..846514acad 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/JavaScriptBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/JavaScriptBuilder.java @@ -29,6 +29,10 @@ import java.io.InputStream; import java.io.OutputStreamWriter; import java.io.PrintWriter; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.net.URL; +import java.net.URLClassLoader; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -123,9 +127,17 @@ public boolean build(File sourceZip, BuildRequest request) throws BuildException File portSources = locateJavaScriptPortSources(request); File portClassesStaged = stageJavaScriptPort(request, portSources, stageClasses, portClasses); + // For every NativeInterface in the app, generate a Impl whose + // methods bridge to the developer's JS stub on the MAIN thread (via + // NativeInterfaceBridge -> browser_bridge.js -> cn1_native_interfaces). The + // launcher registers each impl with NativeLookup so create() resolves and the + // optimizer keeps the impl (it is otherwise only reached reflectively). + List> nativeInterfaces = findNativeInterfaces(stageClasses); + List generatedImpls = generateNativeInterfaceImpls(buildDir, nativeInterfaces); + String translatorAppName = sanitizeIdentifier(request.getMainClass()) + "JavaScriptMain"; - File launcherJava = writeLauncher(buildDir, translatorAppName, request.getPackageName(), request.getMainClass(), stageClasses); - compileLauncher(launcherJava, stageClasses, portClassesStaged); + File launcherJava = writeLauncher(buildDir, translatorAppName, request.getPackageName(), request.getMainClass(), stageClasses, nativeInterfaces); + compileLauncher(launcherJava, generatedImpls, stageClasses, portClassesStaged); File parparvmCompilerJar = extractParparVMCompiler(); @@ -166,6 +178,17 @@ public boolean build(File sourceZip, BuildRequest request) throws BuildException } private boolean checkUserLevel(BuildRequest request) { + // A logged-in Codename One account is the authorization for the local JS + // build, the same way it authorizes the cloud build. The credentials are + // written to the /com/codename1/ui preferences node by `cn1:set-user-token` + // (SetUserTokenMojo) -- e.g. set_cn1_user_token in the website build. Honor + // that login directly so the local target "just works" once you're logged in. + if (hasCodenameOneLogin()) { + log("Local JavaScript builder: authorized via logged-in Codename One account."); + return true; + } + // Fallback for direct CLI invocations that aren't logged in: an explicit + // Enterprise-or-higher user level still unlocks the build. String raw = firstNonEmpty( request.getArg("javascript.userLevel", null), request.getArg("userLevel", null), @@ -179,12 +202,26 @@ private boolean checkUserLevel(BuildRequest request) { return true; } log("ERROR: The local JavaScript build is licensed only to Enterprise and higher tier users. " - + "Set codename1.arg.javascript.userLevel=Enterprise (or a higher tier) in codenameone_settings.properties, " + + "Log in with `cn1:set-user-token -Duser= -Dtoken=`, " + + "set codename1.arg.javascript.userLevel=Enterprise (or a higher tier) in codenameone_settings.properties, " + "or define the CN1_USER_LEVEL environment variable, to enable this preview. " + "See https://www.codenameone.com/pricing.html for tier details."); return false; } + private boolean hasCodenameOneLogin() { + try { + java.util.prefs.Preferences prefs = java.util.prefs.Preferences.userRoot().node("/com/codename1/ui"); + String user = prefs.get("user", null); + String token = prefs.get("token", null); + return user != null && user.trim().length() > 0 + && token != null && token.trim().length() > 0; + } catch (Exception ex) { + // Preferences backing store unavailable -- fall through to the userLevel path. + return false; + } + } + private static int parseUserRank(String raw) { if (raw == null) { return 0; @@ -346,7 +383,8 @@ private String resolveJavac() { return "javac"; } - private File writeLauncher(File workDir, String launcherName, String packageName, String mainClass, File stageClasses) throws IOException { + private File writeLauncher(File workDir, String launcherName, String packageName, String mainClass, File stageClasses, + List> nativeInterfaces) throws IOException { // If the build-time SVG transcoder generated com.codename1.generated.svg.SVGRegistry // for this app, register the transcoded SVGs at startup -- the JS-port analogue of // JavaSEPort.init's reflective installGlobal(). A DIRECT call (not reflection) is @@ -365,6 +403,16 @@ private File writeLauncher(File workDir, String launcherName, String packageName if (hasGeneratedSvg) { pw.println(" com.codename1.generated.svg.SVGRegistry.installGlobal();"); } + // Register the generated native interface implementations. The DIRECT + // class references (not reflection) also keep the optimizer from culling + // the *Impl classes, which are otherwise reached only via NativeLookup. + if (nativeInterfaces != null) { + for (Class iface : nativeInterfaces) { + String ifaceName = iface.getName(); + pw.println(" com.codename1.system.NativeLookup.register(" + + ifaceName + ".class, " + ifaceName + "Impl.class);"); + } + } pw.println(" ParparVMBootstrap.bootstrap(new " + mainClass + "());"); pw.println(" }"); pw.println("}"); @@ -374,15 +422,287 @@ private File writeLauncher(File workDir, String launcherName, String packageName return f; } - private void compileLauncher(File launcherJava, File stageClasses, File portClasses) throws Exception { + private void compileLauncher(File launcherJava, List generatedImpls, File stageClasses, File portClasses) throws Exception { String javac = resolveJavac(); - boolean ok = exec(tmpDir, -1, javac, "-source", "8", "-target", "8", - "-cp", stageClasses.getAbsolutePath() + File.pathSeparator + portClasses.getAbsolutePath(), - "-d", stageClasses.getAbsolutePath(), - launcherJava.getAbsolutePath()); + List cmd = new ArrayList(); + cmd.add(javac); + cmd.add("-source"); cmd.add("8"); + cmd.add("-target"); cmd.add("8"); + cmd.add("-cp"); cmd.add(stageClasses.getAbsolutePath() + File.pathSeparator + portClasses.getAbsolutePath()); + cmd.add("-d"); cmd.add(stageClasses.getAbsolutePath()); + cmd.add(launcherJava.getAbsolutePath()); + if (generatedImpls != null) { + for (File impl : generatedImpls) { + cmd.add(impl.getAbsolutePath()); + } + } + boolean ok = exec(tmpDir, -1, cmd.toArray(new String[cmd.size()])); if (!ok) { - throw new BuildException("Failed to compile JavaScript launcher class"); + throw new BuildException("Failed to compile JavaScript launcher / native interface impl classes"); + } + } + + // ----- Native interface binding -------------------------------------------------- + // Scans the staged app classes for com.codename1.system.NativeInterface subtypes and + // generates, per interface, a Impl whose methods delegate to + // NativeInterfaceBridge.call* (a HOST_HOOK native). At runtime those calls suspend the + // worker and run the developer's JS stub (cn1_native_interfaces[...][method_]) on the + // MAIN thread, then resume the worker with the result. Mirrors the cloud builder's + // JSStubGenerator + NativeLookup.register flow, adapted to the worker/host-call model. + + private List> findNativeInterfaces(File stageClasses) { + List> result = new ArrayList>(); + URLClassLoader loader = null; + try { + loader = new URLClassLoader(new URL[]{ stageClasses.toURI().toURL() }, + JavaScriptBuilder.class.getClassLoader()); + Class niClass; + try { + niClass = loader.loadClass("com.codename1.system.NativeInterface"); + } catch (Throwable t) { + log("com.codename1.system.NativeInterface not on the classpath; no native interfaces to bind"); + return result; + } + List classFiles = new ArrayList(); + collectClassFiles(stageClasses, classFiles); + for (File cf : classFiles) { + // Cheap pre-filter: only classes whose bytes mention the marker interface + // are candidates (native interfaces extend it directly). Avoids loading the + // thousands of unrelated core/runtime classes. + byte[] bytes; + try { + bytes = java.nio.file.Files.readAllBytes(cf.toPath()); + } catch (Throwable t) { + continue; + } + if (!new String(bytes, StandardCharsets.ISO_8859_1).contains("com/codename1/system/NativeInterface")) { + continue; + } + String cn = classNameFor(stageClasses, cf); + if (cn == null) { + continue; + } + try { + Class c = loader.loadClass(cn); + if (c.isInterface() && !c.equals(niClass) && niClass.isAssignableFrom(c)) { + result.add(c); + log("Found native interface: " + c.getName()); + } + } catch (Throwable ignore) { + // class not loadable in isolation (missing deps) -- not a native interface we can bind + } + } + } catch (Throwable t) { + log("Failed scanning for native interfaces: " + t); + } finally { + if (loader != null) { + try { + loader.close(); + } catch (Throwable ignore) { + } + } + } + return result; + } + + private static void collectClassFiles(File dir, List out) { + File[] children = dir.listFiles(); + if (children == null) return; + for (File f : children) { + if (f.isDirectory()) { + collectClassFiles(f, out); + } else if (f.getName().endsWith(".class") && f.getName().indexOf('$') < 0) { + out.add(f); + } + } + } + + private static String classNameFor(File root, File classFile) { + String rootPath = root.getAbsolutePath(); + String filePath = classFile.getAbsolutePath(); + if (!filePath.startsWith(rootPath)) { + return null; + } + String rel = filePath.substring(rootPath.length()); + if (rel.startsWith(File.separator)) { + rel = rel.substring(1); + } + if (!rel.endsWith(".class")) { + return null; + } + rel = rel.substring(0, rel.length() - ".class".length()); + return rel.replace(File.separatorChar, '.').replace('/', '.'); + } + + private List generateNativeInterfaceImpls(File buildDir, List> nativeInterfaces) throws IOException { + List generated = new ArrayList(); + if (nativeInterfaces == null || nativeInterfaces.isEmpty()) { + return generated; + } + File genDir = new File(buildDir, "generated-native-impls"); + genDir.mkdirs(); + for (Class iface : nativeInterfaces) { + File jf = writeNativeInterfaceImpl(genDir, iface); + if (jf != null) { + generated.add(jf); + } + } + return generated; + } + + private File writeNativeInterfaceImpl(File genDir, Class iface) throws IOException { + String pkg = iface.getPackage() != null ? iface.getPackage().getName() : ""; + String simpleImpl = iface.getSimpleName() + "Impl"; + String registryKey = iface.getName().replace('.', '_'); + + File pkgDir = pkg.isEmpty() ? genDir : new File(genDir, pkg.replace('.', File.separatorChar)); + pkgDir.mkdirs(); + File out = new File(pkgDir, simpleImpl + ".java"); + + StringBuilder sb = new StringBuilder(); + if (!pkg.isEmpty()) { + sb.append("package ").append(pkg).append(";\n\n"); + } + sb.append("public class ").append(simpleImpl) + .append(" implements ").append(iface.getName()).append(" {\n"); + sb.append(" private static final String __NI = \"").append(registryKey).append("\";\n\n"); + + for (Method m : iface.getMethods()) { + if (Modifier.isStatic(m.getModifiers())) { + continue; + } + appendNativeInterfaceImplMethod(sb, m); + } + sb.append("}\n"); + + PrintWriter pw = new PrintWriter(new OutputStreamWriter(new FileOutputStream(out), StandardCharsets.UTF_8)); + try { + pw.print(sb.toString()); + } finally { + pw.close(); + } + return out; + } + + private void appendNativeInterfaceImplMethod(StringBuilder sb, Method m) { + Class[] params = m.getParameterTypes(); + Class ret = m.getReturnType(); + String methodKey = nativeInterfaceMethodKey(m); + + sb.append(" public ").append(ret.getCanonicalName()).append(" ").append(m.getName()).append("("); + for (int i = 0; i < params.length; i++) { + if (i > 0) sb.append(", "); + sb.append(params[i].getCanonicalName()).append(" p").append(i); + } + sb.append(") {\n"); + + // Build the boxed argument array. + StringBuilder args = new StringBuilder(); + if (params.length == 0) { + args.append("new Object[0]"); + } else { + args.append("new Object[]{ "); + for (int i = 0; i < params.length; i++) { + if (i > 0) args.append(", "); + args.append(boxArgExpression(params[i], "p" + i)); + } + args.append(" }"); + } + + String call = "com.codename1.impl.platform.js.NativeInterfaceBridge."; + String invokeArgs = "__NI, \"" + methodKey + "\", " + args.toString(); + + if (ret == void.class) { + sb.append(" ").append(call).append("callVoid(").append(invokeArgs).append(");\n"); + } else if (ret == boolean.class) { + sb.append(" return ").append(call).append("callBoolean(").append(invokeArgs).append(");\n"); + } else if (ret == int.class) { + sb.append(" return ").append(call).append("callInt(").append(invokeArgs).append(");\n"); + } else if (ret == long.class) { + sb.append(" return ").append(call).append("callLong(").append(invokeArgs).append(");\n"); + } else if (ret == double.class) { + sb.append(" return ").append(call).append("callDouble(").append(invokeArgs).append(");\n"); + } else if (ret == float.class) { + sb.append(" return ").append(call).append("callFloat(").append(invokeArgs).append(");\n"); + } else if (ret == byte.class) { + sb.append(" return ").append(call).append("callByte(").append(invokeArgs).append(");\n"); + } else if (ret == short.class) { + sb.append(" return ").append(call).append("callShort(").append(invokeArgs).append(");\n"); + } else if (ret == char.class) { + sb.append(" return ").append(call).append("callChar(").append(invokeArgs).append(");\n"); + } else if (ret == String.class) { + sb.append(" return ").append(call).append("callString(").append(invokeArgs).append(");\n"); + } else if (ret.isArray()) { + // Primitive arrays + String[]: callArray builds the correctly-typed + // Java array from the JS array the host returns (componentToken picks + // the element type). + sb.append(" return (").append(ret.getCanonicalName()).append(") ") + .append(call).append("callArray(").append(invokeArgs) + .append(", \"").append(arrayComponentToken(ret.getComponentType())).append("\");\n"); + } else if ("com.codename1.ui.PeerComponent".equals(ret.getName())) { + // The stub returns a native element (delivered to the worker as a + // host-ref); wrap it as a Codename One peer component. + sb.append(" return com.codename1.ui.PeerComponent.create(") + .append(call).append("callObject(").append(invokeArgs).append("));\n"); + } else { + sb.append(" return (").append(ret.getCanonicalName()).append(") ") + .append(call).append("callObject(").append(invokeArgs).append(");\n"); + } + sb.append(" }\n\n"); + } + + private static String boxArgExpression(Class type, String var) { + // Pass a PeerComponent's underlying native element (a host-ref) to the + // stub, not the Java peer wrapper. + if ("com.codename1.ui.PeerComponent".equals(type.getName())) { + return var + ".getNativePeer()"; + } + if (type == int.class) return "Integer.valueOf(" + var + ")"; + if (type == long.class) return "Long.valueOf(" + var + ")"; + if (type == double.class) return "Double.valueOf(" + var + ")"; + if (type == float.class) return "Float.valueOf(" + var + ")"; + if (type == boolean.class) return "Boolean.valueOf(" + var + ")"; + if (type == byte.class) return "Byte.valueOf(" + var + ")"; + if (type == short.class) return "Short.valueOf(" + var + ")"; + if (type == char.class) return "Character.valueOf(" + var + ")"; + return var; + } + + // Mirrors StubGenerator's JS stub key: methodName + "_" + ("_" + xmlvmType) per param. + private static String nativeInterfaceMethodKey(Method m) { + StringBuilder key = new StringBuilder(m.getName()).append("_"); + for (Class p : m.getParameterTypes()) { + if ("com.codename1.ui.PeerComponent".equals(p.getName())) { + key.append("_com_codename1_ui_PeerComponent"); + } else { + key.append("_").append(xmlvmTypeName(p)); + } + } + return key.toString(); + } + + // Runtime newArray() component-class token for an array's element type. + private static String arrayComponentToken(Class component) { + if (component == int.class) return "JAVA_INT"; + if (component == long.class) return "JAVA_LONG"; + if (component == double.class) return "JAVA_DOUBLE"; + if (component == float.class) return "JAVA_FLOAT"; + if (component == boolean.class) return "JAVA_BOOLEAN"; + if (component == byte.class) return "JAVA_BYTE"; + if (component == short.class) return "JAVA_SHORT"; + if (component == char.class) return "JAVA_CHAR"; + if (component == String.class) return "java_lang_String"; + return component.getName().replace('.', '_'); + } + + private static String xmlvmTypeName(Class type) { + if (type.isArray()) { + return xmlvmTypeName(type.getComponentType()) + "_1ARRAY"; + } + if (type.isPrimitive()) { + return type.getName(); } + return type.getName().replace('.', '_'); } private File extractParparVMCompiler() throws BuildException { diff --git a/scripts/initializr/README.adoc b/scripts/initializr/README.adoc index 2ccc909727..d18ca6ce90 100644 --- a/scripts/initializr/README.adoc +++ b/scripts/initializr/README.adoc @@ -59,7 +59,7 @@ mvn -DskipTests install [source,bash] ---- cd ../scripts/initializr -./mvnw package -Dcodename1.platform=javascript -Dcodename1.buildTarget=javascript -Dcn1.localWorkspace=true +./mvnw package -Dcodename1.platform=javascript -Dcodename1.buildTarget=local-javascript -Dcn1.localWorkspace=true ---- This switches Initializr to `8.0-SNAPSHOT` so JavaScript builds use your local Codename One code. diff --git a/scripts/initializr/build.bat b/scripts/initializr/build.bat index 622d02e706..40893d8b27 100644 --- a/scripts/initializr/build.bat +++ b/scripts/initializr/build.bat @@ -22,7 +22,7 @@ goto :EOF goto :EOF :javascript -!MVNW! package -DskipTests -Dcodename1.platform^=javascript -Dcodename1.buildTarget^=javascript -U -e +!MVNW! package -DskipTests -Dcodename1.platform^=javascript -Dcodename1.buildTarget^=local-javascript -U -e goto :EOF :android diff --git a/scripts/initializr/build.sh b/scripts/initializr/build.sh index 196d9f1339..54b63ee6e7 100755 --- a/scripts/initializr/build.sh +++ b/scripts/initializr/build.sh @@ -12,7 +12,7 @@ function windows_desktop { } function javascript { - "$MVNW" "package" "-DskipTests" "-Dcodename1.platform=javascript" "-Dcodename1.buildTarget=javascript" "-U" "-e" + "$MVNW" "package" "-DskipTests" "-Dcodename1.platform=javascript" "-Dcodename1.buildTarget=local-javascript" "-U" "-e" } function android { diff --git a/scripts/initializr/common/codenameone_settings.properties b/scripts/initializr/common/codenameone_settings.properties index a53c18b350..f2da507a85 100644 --- a/scripts/initializr/common/codenameone_settings.properties +++ b/scripts/initializr/common/codenameone_settings.properties @@ -12,6 +12,11 @@ codename1.arg.and.themeMode=modern codename1.arg.desktop.titleBar=native codename1.arg.desktop.interactiveScrollbars=true codename1.arg.java.version=8 +# Local ParparVM JavaScript build (codename1.buildTarget=local-javascript) is +# gated to Enterprise-tier accounts in the released plugin. The website build +# logs in via set_cn1_user_token; declare the tier so the released plugin's +# license gate is satisfied. Newer plugins also accept the login directly. +codename1.arg.javascript.userLevel=Enterprise codename1.displayName=Initializr codename1.icon=icon.png codename1.ios.appid=Q5GHSKAL2F.com.codename1.initializr diff --git a/scripts/initializr/common/src/main/resources/skill/SKILL.md b/scripts/initializr/common/src/main/resources/skill/SKILL.md index d71c510541..4c6ca31dcb 100644 --- a/scripts/initializr/common/src/main/resources/skill/SKILL.md +++ b/scripts/initializr/common/src/main/resources/skill/SKILL.md @@ -253,10 +253,13 @@ mvn -pl common cn1:debug # Execute the CN1 test runner mvn -pl common cn1:test -# Cloud build for Android/iOS/JS (requires CN1 build server creds) +# Cloud build for Android/iOS (requires CN1 build server creds) mvn -pl android package -Dcodename1.platform=android -Dcodename1.buildTarget=android-device mvn -pl ios package -Dcodename1.platform=ios -Dcodename1.buildTarget=ios-device -mvn -pl javascript package -Dcodename1.platform=javascript -Dcodename1.buildTarget=javascript + +# JavaScript / web bundle, built locally via the ParparVM → JS translator (Enterprise-gated). +# Use -Dcodename1.buildTarget=javascript instead for the cloud builder. +mvn -pl javascript package -Dcodename1.platform=javascript -Dcodename1.buildTarget=local-javascript ``` See `references/build-and-run.md` for the local-vs-cloud matrix, automated-build mode (Enterprise), iOS local-build prerequisites, and the complete goal list. The full `codename1.arg.*` index lives in `references/build-hints.md`. diff --git a/scripts/initializr/common/src/main/resources/skill/references/build-and-run.md b/scripts/initializr/common/src/main/resources/skill/references/build-and-run.md index 8f3597f324..059bbab3b3 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/build-and-run.md +++ b/scripts/initializr/common/src/main/resources/skill/references/build-and-run.md @@ -13,7 +13,7 @@ A Codename One project can produce four kinds of artifacts. Some build entirely | iOS app | Cloud, **or** locally as an Xcode project via `ios-source` | `mvn -pl ios package -Dcodename1.platform=ios -Dcodename1.buildTarget=ios-device` (cloud) or `…-Dcodename1.buildTarget=ios-source` (local Xcode project) | | Mac Native app (AOT-compiled, same pipeline as iOS) | Cloud, **or** locally as an Xcode project via `mac-source` | `mvn -pl ios package -Dcodename1.platform=ios -Dcodename1.buildTarget=mac-os-x-native` (cloud) or `…-Dcodename1.buildTarget=mac-source` (local Xcode project) | | Native Windows `.exe` (`win32`, ParparVM → clang-cl, no JVM) | Cloud (Linux build server cross-compiles); **also** locally on Windows, or as a project via `windows-source` | `mvn -pl common package -Dcodename1.platform=windows -Dcodename1.buildTarget=windows-device` (cloud) or `…-Dcodename1.buildTarget=local-windows-device` (local). A regular build returns x64 + arm64 release exes; add the `windows.debug` build hint for a single x64 debug exe. | -| JavaScript / web bundle | Cloud | `mvn -pl javascript package -Dcodename1.platform=javascript -Dcodename1.buildTarget=javascript` | +| JavaScript / web bundle | Local (ParparVM → JavaScript translator; Enterprise-gated). Cloud still available via `…-Dcodename1.buildTarget=javascript`. | `mvn -pl javascript package -Dcodename1.platform=javascript -Dcodename1.buildTarget=local-javascript` | The two big "local-only" outputs are the **simulator** and **tests** — those are everything you need for ordinary development and CI feedback loops. You only invoke the cloud builds when you want a deployable native artifact. @@ -102,8 +102,9 @@ mvn -pl ios package -Dcodename1.platform=ios -Dcodename1.buildTarget=mac-source # Native Android APK/AAB. Cloud-built by default. mvn -pl android package -Dcodename1.platform=android -Dcodename1.buildTarget=android-device -# JavaScript / web bundle. Cloud-built. -mvn -pl javascript package -Dcodename1.platform=javascript -Dcodename1.buildTarget=javascript +# JavaScript / web bundle. Built locally via the ParparVM → JavaScript translator (Enterprise-gated). +# Append -Dcodename1.buildTarget=javascript instead to use the cloud builder. +mvn -pl javascript package -Dcodename1.platform=javascript -Dcodename1.buildTarget=local-javascript # Standalone Mac / Windows / Linux desktop app. Cloud-built. mvn -pl javase package -Dcodename1.platform=javase -Dcodename1.buildTarget=mac-os-x-desktop diff --git a/scripts/initializr/common/src/main/resources/skill/references/native-interfaces.md b/scripts/initializr/common/src/main/resources/skill/references/native-interfaces.md index 775489ff22..37edc5cfce 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/native-interfaces.md +++ b/scripts/initializr/common/src/main/resources/skill/references/native-interfaces.md @@ -70,7 +70,7 @@ mvn -pl ios package -Dcodename1.platform=ios -Dcodename1.buildTarget=ios-source mvn -pl android package -Dcodename1.platform=android -Dcodename1.buildTarget=android-device -Dautomated=true # JavaScript — produces a web bundle; open dev tools and confirm the JS impl is included. -mvn -pl javascript package -Dcodename1.platform=javascript -Dcodename1.buildTarget=javascript +mvn -pl javascript package -Dcodename1.platform=javascript -Dcodename1.buildTarget=local-javascript # Desktop simulator — just run cn1:run and observe the bridge boots without errors. mvn -pl common cn1:run diff --git a/scripts/initializr/javascript/pom.xml b/scripts/initializr/javascript/pom.xml index a68c0251d5..4bba4f16eb 100644 --- a/scripts/initializr/javascript/pom.xml +++ b/scripts/initializr/javascript/pom.xml @@ -18,7 +18,7 @@ 1.8 javascript javascript - javascript + local-javascript diff --git a/scripts/initializr/pom.xml b/scripts/initializr/pom.xml index 95e782606b..d8552381be 100644 --- a/scripts/initializr/pom.xml +++ b/scripts/initializr/pom.xml @@ -117,8 +117,14 @@ true + - 7.0.250 + 8.0-SNAPSHOT + 8.0-SNAPSHOT diff --git a/scripts/run-javascript-headless-browser.mjs b/scripts/run-javascript-headless-browser.mjs index c229bf161b..db60110d6e 100755 --- a/scripts/run-javascript-headless-browser.mjs +++ b/scripts/run-javascript-headless-browser.mjs @@ -45,7 +45,20 @@ let finalizeProfile = async () => {}; const launchArgs = [ '--autoplay-policy=no-user-gesture-required', '--disable-web-security', - '--allow-file-access-from-files' + '--allow-file-access-from-files', + // Headless pages count as hidden, so Chromium's background-timer machinery + // (IntensiveWakeUpThrottling in particular) batches re-armed setTimeout + // chains to ~one firing per MINUTE once the page's wake-up budget drains. + // The ParparVM worker schedules every Thread.sleep / Object.wait(timeout) + // through host timers, so the whole green-thread scheduler stalls in + // 12-60s bursts during quiet (no-host-event) phases -- observed as the + // screenshot suite crawling ~60s/test through the theme cluster with every + // thread parked past its wake deadline. Disable the throttling: this + // harness IS the foreground workload. + '--disable-background-timer-throttling', + '--disable-backgrounding-occluded-windows', + '--disable-renderer-backgrounding', + '--disable-features=IntensiveWakeUpThrottling' ]; if (profileWorker) { launchArgs.push(`--remote-debugging-port=${remoteDebugPort}`); @@ -212,6 +225,18 @@ try { append(`goto:${url}`); await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 }); + // VM liveness nudge from the Node side. Headless Chromium intensively + // throttles page AND worker timers (re-armed setTimeout chains batch to + // ~1/min once the hidden page's wake-up budget drains), which starves the + // ParparVM scheduler's sleep/wait wakeups and crawls the suite. CDP + // Runtime.evaluate is exempt from that throttling, so a Node interval + // pinging the bridge's __cn1NudgeVm (worker postMessage 'timer-wake' -> + // drain -> fire due wakeups) keeps the VM clock honest regardless of the + // browser's visibility heuristics. + const nudgeTimer = setInterval(() => { + page.evaluate('window.__cn1NudgeVm && window.__cn1NudgeVm()').catch(() => {}); + }, 250); + nudgeTimer.unref?.(); await page.waitForTimeout(2000); const start = Date.now(); diff --git a/scripts/website/build.sh b/scripts/website/build.sh index 4f10267f5e..156f3d31f5 100755 --- a/scripts/website/build.sh +++ b/scripts/website/build.sh @@ -48,11 +48,18 @@ bootstrap_local_cn1_snapshots() { return fi + # Each site app (initializr/playground/skindesigner) calls this; the full + # reactor build is expensive, so run setup-workspace.sh only once per build. + if [ "${__CN1_SNAPSHOTS_BOOTSTRAPPED:-}" = "true" ]; then + return + fi + echo "Bootstrapping local Codename One snapshot Maven artifacts..." >&2 ( cd "${REPO_ROOT}" SKIP_CN1_ARCHETYPES=1 ./scripts/setup-workspace.sh -q -DskipTests ) + __CN1_SNAPSHOTS_BOOTSTRAPPED="true" } activate_bootstrapped_java17() { @@ -569,6 +576,13 @@ build_initializr_for_site() { return fi + # The initializr builds the JavaScript app with the local ParparVM target + # (codename1.buildTarget=local-javascript). That builder lives in the repo's + # 8.0-SNAPSHOT plugin, not the pinned release, so bootstrap the local + # snapshots and build with -Dcn1.localWorkspace=true (the cn1-local-workspace + # profile then overrides cn1.version/cn1.plugin.version to the repo build). + bootstrap_local_cn1_snapshots + echo "Building Initializr JavaScript bundle for website..." >&2 ( cd "${REPO_ROOT}/scripts/initializr" @@ -581,7 +595,13 @@ build_initializr_for_site() { fi } - if [ -n "${JAVA_HOME_8_X64:-}" ]; then + local initializr_workspace_args=() + if [ "${WEBSITE_BOOTSTRAP_CN1_SNAPSHOTS}" = "true" ]; then + # Local ParparVM JS build runs the translator + javac; use the + # bootstrapped JDK 17 (matching the Playground/Skin Designer path). + activate_bootstrapped_java17 + initializr_workspace_args+=(-Dcn1.localWorkspace=true) + elif [ -n "${JAVA_HOME_8_X64:-}" ]; then export JAVA_HOME="${JAVA_HOME_8_X64}" export PATH="${JAVA_HOME}/bin:${PATH}" fi @@ -589,6 +609,7 @@ build_initializr_for_site() { # Ensure attached classifier artifact initializr-ZipSupport:jar:common is present # in the local Maven repo before building modules that depend on it (e.g. initializr-common). run_initializr_mvn -q -U -pl cn1libs/ZipSupport -am \ + "${initializr_workspace_args[@]}" \ -DskipTests \ -Dcodename1.platform=javascript \ install @@ -596,6 +617,7 @@ build_initializr_for_site() { set_cn1_user_token "Initializr" run_initializr_mvn -q -U -pl javascript -am \ + "${initializr_workspace_args[@]}" \ -DskipTests \ -Dautomated=true \ -Dcodename1.platform=javascript \ @@ -617,10 +639,30 @@ build_initializr_for_site() { mkdir -p "${output_dir}" unzip -q -o "${result_zip}" -d "${output_dir}" + # The cloud result.zip is flat (index.html at the root), but the local + # ParparVM build (codename1.buildTarget=local-javascript) wraps the bundle in + # a single top-level directory (e.g. Initializr-js/). Flatten that wrapper so + # the served layout is identical regardless of which builder produced the zip. + if [ ! -f "${output_dir}/index.html" ]; then + local inner_dir + inner_dir="$(find "${output_dir}" -mindepth 1 -maxdepth 1 -type d | head -n1 || true)" + if [ -n "${inner_dir}" ] && [ -f "${inner_dir}/index.html" ]; then + ( cd "${inner_dir}" && tar cf - . ) | ( cd "${output_dir}" && tar xf - ) + rm -rf "${inner_dir}" + fi + fi + if [ ! -f "${output_dir}/index.html" ]; then echo "Initializr website bundle is missing index.html after extraction." >&2 exit 1 fi + + # The Initializr page (layouts/_default/initializr.html) shows the app icon + # from /initializr-app/icon.png. The cloud bundle shipped one; the local + # ParparVM bundle does not, so copy the project icon in when it is absent. + if [ ! -f "${output_dir}/icon.png" ] && [ -f "${REPO_ROOT}/scripts/initializr/common/icon.png" ]; then + cp "${REPO_ROOT}/scripts/initializr/common/icon.png" "${output_dir}/icon.png" + fi } build_playground_for_site() { diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/JavascriptBundleWriter.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/JavascriptBundleWriter.java index 0bbaba57fe..9cde045e3e 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/JavascriptBundleWriter.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/JavascriptBundleWriter.java @@ -179,10 +179,48 @@ public int compare(ByteCodeClass a, ByteCodeClass b) { for (int i = 0; i < leadCount; i++) { String suffix = leadCount >= 10 ? String.format("_%02d", i + 1) : String.format("_%d", i + 1); Files.write(new File(outputDirectory, "translated_app" + suffix + ".js").toPath(), - hoistStringConstants(chunks.get(i).toString()).getBytes(StandardCharsets.UTF_8)); + minifyJs(hoistStringConstants(chunks.get(i).toString())).getBytes(StandardCharsets.UTF_8)); } Files.write(new File(outputDirectory, "translated_app.js").toPath(), - hoistStringConstants(tail.toString()).getBytes(StandardCharsets.UTF_8)); + minifyJs(hoistStringConstants(tail.toString())).getBytes(StandardCharsets.UTF_8)); + } + + /** + * Strips the translator's pretty-printing indentation and blank lines from the + * emitted application JS. The translator emits one statement per line with + * generous indentation for readability; for a deployed bundle that is ~20% dead + * weight that the browser must still download and parse. We keep one statement + * per line (newlines preserved) so the transform is safe regardless of ASI or + * {@code //} comments -- only leading/trailing line whitespace and empty lines + * are removed. Set {@code -Dparparvm.js.pretty=true} to keep the readable form + * for debugging the generated code. + */ + private static String minifyJs(String code) { + if (System.getProperty("parparvm.js.pretty") != null) { + return code; + } + int n = code.length(); + StringBuilder out = new StringBuilder(n); + int i = 0; + while (i < n) { + int eol = code.indexOf('\n', i); + if (eol < 0) { + eol = n; + } + int start = i; + int end = eol; + while (start < end && code.charAt(start) <= ' ') { + start++; + } + while (end > start && code.charAt(end - 1) <= ' ') { + end--; + } + if (end > start) { + out.append(code, start, end).append('\n'); + } + i = eol + 1; + } + return out.toString(); } /** @@ -536,6 +574,10 @@ private static void writeWorker(File outputDirectory) throws IOException { // call) but *after* other runtime helpers / native shims. if (name.startsWith("translated_app_") && name.endsWith(".js")) { classChunkScripts.add(name); + } else if (isNativeInterfaceStub(file)) { + // Native-interface implementations run on the MAIN thread (index.html), + // not in the worker -- they need DOM access. Skip them here. + continue; } else { nativeScripts.add(name); } @@ -560,7 +602,53 @@ private static void writeWorker(File outputDirectory) throws IOException { } private static void writeIndex(File outputDirectory) throws IOException { - writeResource(outputDirectory, "index.html", "index.html"); + String index = loadResource("index.html"); + StringBuilder stubs = new StringBuilder(); + for (String stub : collectNativeInterfaceStubs(outputDirectory)) { + stubs.append("\n"); + } + index = index.replace("", stubs.toString().trim()); + Files.write(new File(outputDirectory, "index.html").toPath(), index.getBytes(StandardCharsets.UTF_8)); + } + + /** + * Native-interface JS implementations self-register into + * {@code cn1_native_interfaces} (they end with {@code })(cn1_get_native_interfaces());}). + * They run on the MAIN thread so their DOM access works, and are dispatched from the + * worker via the host-call bridge. Identify them by that content marker so the worker + * importScripts list excludes them and index.html loads them on the page instead. + */ + private static List collectNativeInterfaceStubs(File outputDirectory) { + List stubs = new ArrayList(); + File[] files = outputDirectory.listFiles(); + if (files != null) { + for (File file : files) { + if (file.getName().endsWith(".js") && isNativeInterfaceStub(file)) { + stubs.add(file.getName()); + } + } + } + Collections.sort(stubs); + return stubs; + } + + private static boolean isNativeInterfaceStub(File jsFile) { + String name = jsFile.getName(); + if ("parparvm_runtime.js".equals(name) + || "translated_app.js".equals(name) + || "worker.js".equals(name) + || "sw.js".equals(name) + || "port.js".equals(name) + || "browser_bridge.js".equals(name) + || name.startsWith("translated_app_")) { + return false; + } + try { + String content = new String(Files.readAllBytes(jsFile.toPath()), StandardCharsets.UTF_8); + return content.contains("cn1_get_native_interfaces"); + } catch (IOException ex) { + return false; + } } private static void writeBrowserBridge(File outputDirectory) throws IOException { @@ -651,4 +739,47 @@ private static String loadResource(String resourceName) throws IOException { input.close(); } } + + /** + * Every {@code cn1_*} token referenced as a string literal by the + * hand-written bridge JS (parparvm_runtime.js, browser_bridge.js and + * the JavaScript port's port.js). These are names the bridge resolves + * by string at runtime -- and, in the {@code bindNative} / + * {@code bindCiFallback} case, REPLACES with {@code function*} + * overrides. The suspension analysis must treat the named methods as + * suspending: a translated caller that skipped {@code yield*} (because + * the static body looked synchronous) would receive the installed + * override's raw generator object as its "result" and the override + * body would never run. + */ + static Set collectBridgeReferencedCn1Tokens() { + Set tokens = new HashSet(); + List sources = new ArrayList(); + for (String res : new String[]{ "parparvm_runtime.js", "browser_bridge.js" }) { + try { + sources.add(loadResource(res)); + } catch (IOException ignore) { + // resource absent -- skip + } + } + try { + Path webApp = locateJavaScriptPortWebApp(); + if (webApp != null) { + Path portJs = webApp.resolve("port.js"); + if (Files.exists(portJs)) { + sources.add(new String(Files.readAllBytes(portJs), StandardCharsets.UTF_8)); + } + } + } catch (Exception ignore) { + // port.js unavailable -- skip + } + java.util.regex.Pattern literal = java.util.regex.Pattern.compile("[\"'](cn1_[A-Za-z0-9_]+)[\"']"); + for (String src : sources) { + java.util.regex.Matcher m = literal.matcher(src); + while (m.find()) { + tokens.add(m.group(1)); + } + } + return tokens; + } } diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/JavascriptMethodGenerator.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/JavascriptMethodGenerator.java index d76a812071..d1793dd95b 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/JavascriptMethodGenerator.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/JavascriptMethodGenerator.java @@ -3388,13 +3388,14 @@ private static boolean appendStraightLineInvokeInstruction(StringBuilder out, In // ``resolveVirtual`` handles inheritance without per-class alias // entries. String dispatchId = JavascriptNameUtil.dispatchMethodIdentifier(invoke.getName(), invoke.getDesc()); + boolean suspending = isInvokeSuspending(invoke); if (hasReturn) { out.append(" {\n"); - appendCompactVirtualDispatch(out, " ", dispatchId, argValues.length, true, target, false, argValues); + appendCompactVirtualDispatch(out, " ", dispatchId, argValues.length, true, target, false, argValues, suspending); out.append(" ").append(ctx.push("__result")).append(";\n"); out.append(" }\n"); } else { - appendCompactVirtualDispatch(out, " ", dispatchId, argValues.length, false, target, false, argValues); + appendCompactVirtualDispatch(out, " ", dispatchId, argValues.length, false, target, false, argValues, suspending); } return true; } @@ -4799,6 +4800,13 @@ private static void appendInvokeInstruction(StringBuilder out, Invoke invoke, in } if (invoke.getOpcode() == Opcodes.INVOKEVIRTUAL || invoke.getOpcode() == Opcodes.INVOKEINTERFACE) { + // CHA verdict for this call site: a sync signature uses the + // non-generator ``cn1_ivs*`` family with no ``yield*`` (so a + // method whose every virtual call is sync can itself be a plain + // ``function``); a suspending signature keeps ``yield* cn1_iv*``. + boolean susp = isInvokeSuspending(invoke); + String iv = susp ? "cn1_iv" : "cn1_ivs"; + String yk = susp ? "yield* " : ""; // Fast path for 0-arg virtual dispatch: inline the // target pop into the iv0 call. Pops TOS inside the // invoke's arg list, so the full block collapses to a @@ -4806,9 +4814,9 @@ private static void appendInvokeInstruction(StringBuilder out, Invoke invoke, in // call sites. if (argCount == 0) { if (hasReturn) { - out.append(" stack.p(yield* cn1_iv0(stack.q(), \"").append(dispatchId).append("\"));\n"); + out.append(" stack.p(").append(yk).append(iv).append("0(stack.q(), \"").append(dispatchId).append("\"));\n"); } else { - out.append(" yield* cn1_iv0(stack.q(), \"").append(dispatchId).append("\");\n"); + out.append(" ").append(yk).append(iv).append("0(stack.q(), \"").append(dispatchId).append("\");\n"); } out.append(" pc = ").append(index + 1).append("; break;\n"); return; @@ -4816,9 +4824,9 @@ private static void appendInvokeInstruction(StringBuilder out, Invoke invoke, in if (argCount == 1) { out.append(" { let __arg0 = stack.q(); "); if (hasReturn) { - out.append("stack.p(yield* cn1_iv1(stack.q(), \"").append(dispatchId).append("\", __arg0));"); + out.append("stack.p(").append(yk).append(iv).append("1(stack.q(), \"").append(dispatchId).append("\", __arg0));"); } else { - out.append("yield* cn1_iv1(stack.q(), \"").append(dispatchId).append("\", __arg0);"); + out.append(yk).append(iv).append("1(stack.q(), \"").append(dispatchId).append("\", __arg0);"); } out.append(" pc = ").append(index + 1).append("; break; }\n"); return; @@ -4826,9 +4834,9 @@ private static void appendInvokeInstruction(StringBuilder out, Invoke invoke, in if (argCount == 2) { out.append(" { let __arg1 = stack.q(); let __arg0 = stack.q(); "); if (hasReturn) { - out.append("stack.p(yield* cn1_iv2(stack.q(), \"").append(dispatchId).append("\", __arg0, __arg1));"); + out.append("stack.p(").append(yk).append(iv).append("2(stack.q(), \"").append(dispatchId).append("\", __arg0, __arg1));"); } else { - out.append("yield* cn1_iv2(stack.q(), \"").append(dispatchId).append("\", __arg0, __arg1);"); + out.append(yk).append(iv).append("2(stack.q(), \"").append(dispatchId).append("\", __arg0, __arg1);"); } out.append(" pc = ").append(index + 1).append("; break; }\n"); return; @@ -4836,9 +4844,9 @@ private static void appendInvokeInstruction(StringBuilder out, Invoke invoke, in if (argCount == 3) { out.append(" { let __arg2 = stack.q(); let __arg1 = stack.q(); let __arg0 = stack.q(); "); if (hasReturn) { - out.append("stack.p(yield* cn1_iv3(stack.q(), \"").append(dispatchId).append("\", __arg0, __arg1, __arg2));"); + out.append("stack.p(").append(yk).append(iv).append("3(stack.q(), \"").append(dispatchId).append("\", __arg0, __arg1, __arg2));"); } else { - out.append("yield* cn1_iv3(stack.q(), \"").append(dispatchId).append("\", __arg0, __arg1, __arg2);"); + out.append(yk).append(iv).append("3(stack.q(), \"").append(dispatchId).append("\", __arg0, __arg1, __arg2);"); } out.append(" pc = ").append(index + 1).append("; break; }\n"); return; @@ -4846,9 +4854,9 @@ private static void appendInvokeInstruction(StringBuilder out, Invoke invoke, in if (argCount == 4) { out.append(" { let __arg3 = stack.q(); let __arg2 = stack.q(); let __arg1 = stack.q(); let __arg0 = stack.q(); "); if (hasReturn) { - out.append("stack.p(yield* cn1_iv4(stack.q(), \"").append(dispatchId).append("\", __arg0, __arg1, __arg2, __arg3));"); + out.append("stack.p(").append(yk).append(iv).append("4(stack.q(), \"").append(dispatchId).append("\", __arg0, __arg1, __arg2, __arg3));"); } else { - out.append("yield* cn1_iv4(stack.q(), \"").append(dispatchId).append("\", __arg0, __arg1, __arg2, __arg3);"); + out.append(yk).append(iv).append("4(stack.q(), \"").append(dispatchId).append("\", __arg0, __arg1, __arg2, __arg3);"); } out.append(" pc = ").append(index + 1).append("; break; }\n"); return; @@ -4863,7 +4871,7 @@ private static void appendInvokeInstruction(StringBuilder out, Invoke invoke, in out.append(" {\n"); appendInvocationArgumentBindings(out, argCount, " ", "stack.q()"); out.append(" let __target = stack.q();\n"); - appendCompactVirtualDispatch(out, " ", dispatchId, argCount, hasReturn, "__target", true); + appendCompactVirtualDispatch(out, " ", dispatchId, argCount, hasReturn, "__target", true, isInvokeSuspending(invoke)); out.append(" pc = ").append(index + 1).append("; break;\n"); out.append(" }\n"); return; @@ -5015,32 +5023,46 @@ private static void appendInvocationArgumentBindings(StringBuilder out, int argC * the arg expressions directly (straight-line path). */ private static void appendCompactVirtualDispatch(StringBuilder out, String indent, String methodId, - int argCount, boolean hasReturn, String targetExpr, boolean argsFromStack) { - appendCompactVirtualDispatch(out, indent, methodId, argCount, hasReturn, targetExpr, argsFromStack, null); + int argCount, boolean hasReturn, String targetExpr, boolean argsFromStack, boolean suspending) { + appendCompactVirtualDispatch(out, indent, methodId, argCount, hasReturn, targetExpr, argsFromStack, null, suspending); } + /** + * @param suspending CHA verdict for the dispatched signature. When true the + * call goes through the generator family + * ({@code yield* cn1_iv*}) so a blocking override can + * suspend the cooperative scheduler. When false the + * analysis proved every impl is synchronous, so we emit + * the synchronous family ({@code cn1_ivs*}, no + * {@code yield*}) -- this is what allows a caller that + * makes only non-suspending virtual calls to itself be a + * plain {@code function}. + */ private static void appendCompactVirtualDispatch(StringBuilder out, String indent, String methodId, - int argCount, boolean hasReturn, String targetExpr, boolean argsFromStack, String[] argExpressions) { + int argCount, boolean hasReturn, String targetExpr, boolean argsFromStack, String[] argExpressions, + boolean suspending) { + String base = suspending ? "cn1_iv" : "cn1_ivs"; + String yieldKw = suspending ? "yield* " : ""; String helper; boolean variadic = false; switch (argCount) { - case 0: helper = "cn1_iv0"; break; - case 1: helper = "cn1_iv1"; break; - case 2: helper = "cn1_iv2"; break; - case 3: helper = "cn1_iv3"; break; - case 4: helper = "cn1_iv4"; break; + case 0: helper = base + "0"; break; + case 1: helper = base + "1"; break; + case 2: helper = base + "2"; break; + case 3: helper = base + "3"; break; + case 4: helper = base + "4"; break; default: - helper = "cn1_ivN"; + helper = base + "N"; variadic = true; break; } out.append(indent); if (hasReturn && argsFromStack) { - out.append("stack.p(yield* ").append(helper).append("(").append(targetExpr).append(", \"").append(methodId).append("\""); + out.append("stack.p(").append(yieldKw).append(helper).append("(").append(targetExpr).append(", \"").append(methodId).append("\""); } else if (hasReturn) { - out.append("let __result = yield* ").append(helper).append("(").append(targetExpr).append(", \"").append(methodId).append("\""); + out.append("let __result = ").append(yieldKw).append(helper).append("(").append(targetExpr).append(", \"").append(methodId).append("\""); } else { - out.append("yield* ").append(helper).append("(").append(targetExpr).append(", \"").append(methodId).append("\""); + out.append(yieldKw).append(helper).append("(").append(targetExpr).append(", \"").append(methodId).append("\""); } if (variadic) { out.append(", ["); diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/JavascriptNativeRegistry.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/JavascriptNativeRegistry.java index efa15290cd..2545ceab6b 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/JavascriptNativeRegistry.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/JavascriptNativeRegistry.java @@ -117,7 +117,23 @@ enum NativeCategory { "cn1_java_util_TimeZone_getTimezoneRawOffset_java_lang_String_R_int", "cn1_java_util_TimeZone_isTimezoneDST_java_lang_String_long_R_boolean", "cn1_com_codename1_impl_platform_js_VMHost_getLastEventCode_R_int", - "cn1_com_codename1_impl_platform_js_VMHost_pollEventCode_R_int" + "cn1_com_codename1_impl_platform_js_VMHost_pollEventCode_R_int", + // NativeInterface bridge: runtime-implemented in parparvm_runtime.js + // (bindNative). Each forwards to the main thread via the + // __cn1_native_interface_call__ host hook and coerces the result to + // the declared Java type (createJavaString / _LfromNumber / newArray). + "cn1_com_codename1_impl_platform_js_NativeInterfaceBridge_callBoolean_java_lang_String_java_lang_String_java_lang_Object_1ARRAY_R_boolean", + "cn1_com_codename1_impl_platform_js_NativeInterfaceBridge_callByte_java_lang_String_java_lang_String_java_lang_Object_1ARRAY_R_byte", + "cn1_com_codename1_impl_platform_js_NativeInterfaceBridge_callShort_java_lang_String_java_lang_String_java_lang_Object_1ARRAY_R_short", + "cn1_com_codename1_impl_platform_js_NativeInterfaceBridge_callInt_java_lang_String_java_lang_String_java_lang_Object_1ARRAY_R_int", + "cn1_com_codename1_impl_platform_js_NativeInterfaceBridge_callChar_java_lang_String_java_lang_String_java_lang_Object_1ARRAY_R_char", + "cn1_com_codename1_impl_platform_js_NativeInterfaceBridge_callLong_java_lang_String_java_lang_String_java_lang_Object_1ARRAY_R_long", + "cn1_com_codename1_impl_platform_js_NativeInterfaceBridge_callFloat_java_lang_String_java_lang_String_java_lang_Object_1ARRAY_R_float", + "cn1_com_codename1_impl_platform_js_NativeInterfaceBridge_callDouble_java_lang_String_java_lang_String_java_lang_Object_1ARRAY_R_double", + "cn1_com_codename1_impl_platform_js_NativeInterfaceBridge_callString_java_lang_String_java_lang_String_java_lang_Object_1ARRAY_R_java_lang_String", + "cn1_com_codename1_impl_platform_js_NativeInterfaceBridge_callObject_java_lang_String_java_lang_String_java_lang_Object_1ARRAY_R_java_lang_Object", + "cn1_com_codename1_impl_platform_js_NativeInterfaceBridge_callArray_java_lang_String_java_lang_String_java_lang_Object_1ARRAY_java_lang_String_R_java_lang_Object", + "cn1_com_codename1_impl_platform_js_NativeInterfaceBridge_callVoid_java_lang_String_java_lang_String_java_lang_Object_1ARRAY" )); private static final Set HOST_HOOK_PREFIXES = new HashSet(Arrays.asList( diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/JavascriptSuspensionAnalysis.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/JavascriptSuspensionAnalysis.java index 2de31ee74f..cda51f131f 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/JavascriptSuspensionAnalysis.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/JavascriptSuspensionAnalysis.java @@ -38,12 +38,13 @@ *
  • It is declared {@code synchronized} — monitor acquisition can block.
  • *
  • Its bytecode contains {@code monitorenter} or {@code monitorexit} * (synchronized block) — same reason.
  • - *
  • It contains any {@code invokevirtual} / {@code invokeinterface} - * instruction — the dispatch goes through {@code cn1_iv*} which is - * a generator, so the caller must be ready to {@code yield*}. We - * treat ALL virtuals as suspending rather than doing - * override-set CHA, which keeps the analysis portable and safe - * against future-inherited suspending overrides.
  • + *
  • It contains an {@code invokevirtual} / {@code invokeinterface} + * whose dispatched signature has AT LEAST ONE suspending impl in + * the class hierarchy (override-set CHA). Such sites are emitted as + * {@code yield* cn1_iv*}; sites whose every impl is synchronous use + * the {@code cn1_ivs*} sync dispatcher and do NOT make their caller + * suspending. The suspending-sig set is computed by fixed-point in + * {@link #propagate} and exported via {@link #exportedSuspendingSigs}.
  • *
  • It contains any {@code invokestatic} / {@code invokespecial} whose * resolved target is itself suspending (recursive closure via * fixed-point iteration).
  • @@ -58,6 +59,11 @@ final class JavascriptSuspensionAnalysis { private final Map byName = new HashMap(); private final Set suspending = Collections.newSetFromMap(new IdentityHashMap()); + // Sigs whose runtime impl can be a bindNative-installed generator the + // static concrete-impl scan cannot see: declared (possibly abstractly) + // on JSO-bridge classes, or string-referenced by the bridge JS (see + // seedBridgeReferenced). Unconditionally suspending. + private final Set jsoDeclaredSigs = new java.util.HashSet(); // Sigs (name + descriptor) whose concrete impl set contains AT // LEAST ONE suspending method. Populated during ``propagate`` @@ -73,6 +79,7 @@ static int run(List classes) { JavascriptSuspensionAnalysis a = new JavascriptSuspensionAnalysis(); a.index(classes); a.seedDirectlySuspending(classes); + a.seedBridgeReferenced(classes); a.propagate(classes); return a.applyResults(classes); } @@ -105,6 +112,17 @@ private void seedDirectlySuspending(List classes) { for (ByteCodeClass cls : classes) { boolean clsIsJso = jsoBridgeClasses.contains(cls.getClsName()); for (BytecodeMethod m : cls.getMethods()) { + // JSO-declared SIGNATURES must be suspending even when the + // declaration is abstract (interface methods like + // ``Window.getDocument()`` have NO translated impl at all -- + // the only "impl" is the ``function*`` override bindNative + // installs at runtime, which the concrete-impl scan in + // ``propagate`` can never see). Record the sig here so + // ``propagate`` folds it into ``suspendingSigs`` and every + // dispatching call site keeps its ``yield*``. + if (clsIsJso && !m.isEliminated() && !m.isStatic() && !m.isConstructor()) { + jsoDeclaredSigs.add(m.getMethodName() + m.getSignature()); + } if (m.isEliminated() || m.isAbstract()) { continue; } @@ -119,25 +137,74 @@ private void seedDirectlySuspending(List classes) { // with ``cn1_ivAdapt`` wrappers at every hand-written // ``yield* translatedFn(args)`` call site. // - // ``hasVirtualDispatch`` is required in the seed - // because the emitter hardcodes ``yield* cn1_iv*`` at - // every INVOKEVIRTUAL / INVOKEINTERFACE call site (see - // ``JavascriptMethodGenerator.appendVirtualDispatch`` - // -- there is no ``cn1_ivs*`` synchronous virtual - // dispatcher, and 3 prior attempts to add one all hit - // runtime errors per - // ``project_jsport_suspension_tightening_failure`` - // memory). A method emitted as plain ``function`` - // cannot contain ``yield*``, so any method with even - // ONE virtual call must be a generator. Tightening - // the sync set further requires landing the sync - // virtual dispatcher first. + // Virtual dispatch is NO LONGER an unconditional seed. + // The emitter now has a synchronous virtual-dispatch + // family (``cn1_ivs0..N`` in parparvm_runtime.js) that + // it selects (via ``isInvokeSuspending`` consulting + // ``exportedSuspendingSigs``) for any INVOKEVIRTUAL / + // INVOKEINTERFACE whose CHA impl set is entirely + // synchronous. So a method whose only virtual calls + // target non-suspending sigs can itself be a plain + // ``function``. Suspension still propagates through + // virtual dispatch in ``propagate``: if ANY impl of a + // called sig is suspending, that sig is suspending and + // every caller of it is marked suspending there. The + // earlier sync-dispatcher attempts failed by letting a + // generator leak as a value; ``cn1_ivs*`` drives a + // one-shot and throws a named error on a true gap + // instead (see the runtime helper). if (m.isNative() || m.isSynchronizedMethod() || hasMonitorOps(m) - || clsIsJso - || hasVirtualDispatch(m)) { + || clsIsJso) { + suspending.add(m); + } + } + } + } + + /** + * Any method whose emitted identifier (or its {@code __impl} body, or + * its class-free dispatch id) appears as a string literal in the + * hand-written bridge JS must be suspending. Those strings are how + * {@code bindNative} / {@code bindCiFallback} (port.js, + * parparvm_runtime.js, browser_bridge.js) locate translated methods + * to REPLACE with {@code function*} overrides at runtime. The static + * body may look trivially synchronous, but the override that actually + * runs is a generator -- a caller that skipped {@code yield*} would + * receive the raw generator object as its "result" and the override + * would never execute (observed as the screenshot runner's + * done-callback silently never firing). Over-protecting names the + * bridge merely CALLS (it wraps those in {@code cn1_ivAdapt}, which + * tolerates sync) costs a handful of generators; under-protecting + * breaks the bridge contract silently, so blanket-protect every + * string-referenced name. + */ + private void seedBridgeReferenced(List classes) { + Set tokens = JavascriptBundleWriter.collectBridgeReferencedCn1Tokens(); + if (tokens.isEmpty()) { + return; + } + for (ByteCodeClass cls : classes) { + for (BytecodeMethod m : cls.getMethods()) { + if (m.isEliminated() || m.isAbstract()) { + continue; + } + String full = JavascriptNameUtil.methodIdentifier(cls.getClsName(), m.getMethodName(), m.getSignature()); + boolean referenced = tokens.contains(full) || tokens.contains(full + "__impl"); + boolean dispatchable = !m.isStatic() && !m.isConstructor(); + if (!referenced && dispatchable + && tokens.contains(JavascriptNameUtil.dispatchMethodIdentifier(m.getMethodName(), m.getSignature()))) { + referenced = true; + } + if (referenced) { suspending.add(m); + if (dispatchable) { + // Virtual dispatch can land on the runtime-installed + // override too -- protect the whole signature, same + // as the JSO-declared sigs. + jsoDeclaredSigs.add(m.getMethodName() + m.getSignature()); + } } } } @@ -190,23 +257,6 @@ private static boolean hasMonitorOps(BytecodeMethod m) { return false; } - private static boolean hasVirtualDispatch(BytecodeMethod m) { - List instructions = m.getInstructions(); - if (instructions == null) { - return false; - } - for (Instruction instr : instructions) { - if (!(instr instanceof Invoke)) { - continue; - } - int op = instr.getOpcode(); - if (op == Opcodes.INVOKEVIRTUAL || op == Opcodes.INVOKEINTERFACE) { - return true; - } - } - return false; - } - private void propagate(List classes) { // Build two reverse indexes so a method becoming suspending // can propagate to all its callers without rescanning every @@ -243,6 +293,11 @@ private void propagate(List classes) { } } } + // JSO-bridge declared sigs are suspending regardless of their (often + // absent / abstract) translated impls -- see seedDirectlySuspending. + // Must be folded in BEFORE the caller scan below so dispatching + // callers get escalated. + suspendingSigs.addAll(jsoDeclaredSigs); for (ByteCodeClass cls : classes) { for (BytecodeMethod caller : cls.getMethods()) { if (caller.isEliminated() || caller.isAbstract()) { diff --git a/vm/ByteCodeTranslator/src/javascript/browser_bridge.js b/vm/ByteCodeTranslator/src/javascript/browser_bridge.js index 9967922bff..f5a4463d43 100644 --- a/vm/ByteCodeTranslator/src/javascript/browser_bridge.js +++ b/vm/ByteCodeTranslator/src/javascript/browser_bridge.js @@ -164,6 +164,74 @@ } }; + // ---- Native interface dispatch ------------------------------------------------- + // Codename One NativeInterface calls arrive here (on the MAIN thread) from the + // worker via the generated Impl -> NativeInterfaceBridge.call* host-hooks. + // We look up the developer's JS implementation in cn1_native_interfaces (the + // registry the stub self-registers into, populated on the main thread by the + // + + diff --git a/vm/ByteCodeTranslator/src/javascript/parparvm_runtime.js b/vm/ByteCodeTranslator/src/javascript/parparvm_runtime.js index af95aeeb81..6a28730608 100644 --- a/vm/ByteCodeTranslator/src/javascript/parparvm_runtime.js +++ b/vm/ByteCodeTranslator/src/javascript/parparvm_runtime.js @@ -2405,6 +2405,30 @@ const jvm = { } return out; } + // A Java String is a VM object, not a JS primitive. Host code (e.g. the + // native-interface bridge, which passes the interface/method names) expects + // the actual text, so marshal it to a plain JS string rather than letting it + // fall through to the opaque object-iteration path below. + if (value.__class === "java_lang_String") { + return this.toNativeString(value); + } + // 64-bit long ({__l:1,l,h}) -> JS number for the host. + if (value.__l === 1) { + return _LtoNumber(value); + } + // Boxed primitives -> their JS value. NativeInterface args arrive boxed in an + // Object[] (Integer.valueOf(...) etc.); the host wants the plain value. + switch (value.__class) { + case "java_lang_Integer": return value.cn1_java_lang_Integer_value | 0; + case "java_lang_Short": return value.cn1_java_lang_Short_value | 0; + case "java_lang_Byte": return value.cn1_java_lang_Byte_value | 0; + case "java_lang_Character": return value.cn1_java_lang_Character_value | 0; + case "java_lang_Boolean": return !!value.cn1_java_lang_Boolean_value; + case "java_lang_Double": return Number(value.cn1_java_lang_Double_value); + case "java_lang_Float": return Number(value.cn1_java_lang_Float_value); + case "java_lang_Long": return _LtoNum(value.cn1_java_lang_Long_value); + default: break; + } if (value.__cn1HostRef != null) { return value.__cn1HostClass ? { __cn1HostRef: value.__cn1HostRef, __cn1HostClass: value.__cn1HostClass } @@ -2634,6 +2658,15 @@ const jvm = { if (this.draining) { return; } + // Opportunistic wakeup delivery on every outermost drain (i.e. every host + // event that wakes the worker): when the one-shot wakeup timeout is being + // throttled by the host (see _ensureWakeupPump), due sleeps/waits still + // fire with near-zero latency here instead of waiting for the 1s pump. + // O(pending) once per burst; no-op when nothing is due. + if (this.timedWakeups.length && !this._processingWakeups + && this._earliestWakeAt() <= this.schedulerNow() + 1) { + this._processExpiredTimedWakeups(); + } this.draining = true; const deadline = this.schedulerNow() + 8; let steps = 0; @@ -2794,6 +2827,47 @@ const jvm = { _scheduleTimedWakeup(entry) { this.timedWakeups.push(entry); this._refreshTimedWakeupTimer(); + this._ensureWakeupPump(); + }, + // Permanent low-frequency backstop for the one-shot wakeup timer. + // + // Headless/hidden Chromium intensively throttles rapidly re-armed + // setTimeout CHAINS (nesting depth >= 5, short delays) down to ~one + // firing per minute, while >=1s intervals keep firing normally -- + // observed on the screenshot suite as the heartbeat interval beating + // every 1.5s while the armed wakeup timeout sat 12-48s past its target + // (sinceStepMs == wakeFiredAgo == 12771 with every thread parked), so + // every Thread.sleep / Object.wait(timeout) in the VM stalled in + // batches. The pump bounds that worst case at ~1s: a cheap length + + // earliest-deadline check, processing only when something is actually + // due. The one-shot timer remains the precision path; drain() also + // opportunistically processes due wakeups on every host event. + _ensureWakeupPump() { + if (this._wakeupPump != null || typeof setInterval !== "function") { + return; + } + const self = this; + this._wakeupPump = setInterval(function() { + try { + if (self.timedWakeups.length && self._earliestWakeAt() <= self.schedulerNow() + 1) { + self._processExpiredTimedWakeups(); + } + } catch (_e) { + // Never let the backstop kill itself. + } + }, 1000); + // Node harnesses: don't hold the process open for the pump. + if (this._wakeupPump && typeof this._wakeupPump.unref === "function") { + this._wakeupPump.unref(); + } + }, + _earliestWakeAt() { + let earliest = Infinity; + for (let i = 0; i < this.timedWakeups.length; i++) { + const w = this.timedWakeups[i]; + if (!w.cancelled && w.wakeAt < earliest) earliest = w.wakeAt; + } + return earliest; }, _removeTimedWakeup(entry) { if (!entry || entry.cancelled) return; @@ -2816,15 +2890,37 @@ const jvm = { } return; } - if (this._wakeupTimer != null && this._wakeupAt <= earliest) { - // Existing timer fires sooner or at the same moment; keep it. + if (this._wakeupTimer != null && this._wakeupAt <= earliest + && this._wakeupAt > this.schedulerNow() - 100) { + // Existing timer fires sooner or at the same moment; keep it. The + // third clause guards against a ZOMBIE: a timer whose target time is + // already well past yet whose callback never ran (its first statement + // nulls _wakeupTimer, so non-null + past-due means the host lost the + // timeout -- observed on the screenshot suite as every sleeping thread + // stranded 30s+ past its deadline with the queue intact, because a + // past-due _wakeupAt satisfies ``<= earliest`` for EVERY later wakeup + // and this branch then never re-arms). Distrust it and re-arm; if the + // old timer does still fire, the callback's _wakeupTimer-null reset + + // re-entrant processing are idempotent, so the duplicate is harmless. return; } - if (this._wakeupTimer != null) clearTimeout(this._wakeupTimer); + if (this._wakeupTimer != null) { + if (VM_DIAG_ENABLED && this._wakeupAt !== Infinity + && this._wakeupAt <= this.schedulerNow() - 100) { + try { + vmTrace("DIAG:WAKEUP_TIMER_ZOMBIE:rearmed:staleMs=" + + Math.round(this.schedulerNow() - this._wakeupAt)); + } catch (_e) {} + } + clearTimeout(this._wakeupTimer); + } const delay = Math.max(0, earliest - this.schedulerNow()); this._wakeupAt = earliest; const self = this; + this._wakeupArmCount = (this._wakeupArmCount | 0) + 1; this._wakeupTimer = setTimeout(function() { + self._wakeupFireCount = (self._wakeupFireCount | 0) + 1; + self._wakeupLastFiredAt = self.schedulerNow(); self._wakeupTimer = null; self._wakeupAt = Infinity; // ALWAYS reschedule remaining wakeups, even if processing one throws -- @@ -2837,6 +2933,20 @@ const jvm = { }, delay); }, _processExpiredTimedWakeups() { + if (this._processingWakeups) { + // Re-entrancy guard: the resume loop below runs green threads via + // enqueue -> drain, and drain's opportunistic due-check (or the pump / + // a late one-shot) could otherwise re-enter while a batch is mid-resume. + return; + } + this._processingWakeups = true; + try { + this._processExpiredTimedWakeupsInner(); + } finally { + this._processingWakeups = false; + } + }, + _processExpiredTimedWakeupsInner() { const now = this.schedulerNow(); const expired = []; for (let i = this.timedWakeups.length - 1; i >= 0; i--) { @@ -2850,6 +2960,17 @@ const jvm = { if (w.wakeAt <= now + 1) { expired.push(w); this.timedWakeups.splice(i, 1); + } else if (VM_DIAG_ENABLED && !(w.wakeAt > now - 2000)) { + // An entry that is neither due (<= now+1) nor sane-future fails BOTH + // comparisons only when wakeAt isn't an ordinary number (NaN / boxed + // long / string). Print its raw shape -- this is the only way a + // queued, uncancelled, overdue entry can survive processing. + try { + vmTrace("DIAG:WAKEUP_BAD_ENTRY:kind=" + String(w.kind) + + ":typeof=" + (typeof w.wakeAt) + + ":val=" + String(w.wakeAt).slice(0, 30) + + ":thread=" + (w.thread ? w.thread.id : "-")); + } catch (_e) {} } } expired.reverse(); // restore registration order for FIFO fairness @@ -2867,6 +2988,7 @@ const jvm = { if (w.cancelled) { continue; } + try { if (w.kind === "sleep") { this.enqueue(w.thread); } else if (w.kind === "wait") { @@ -2884,6 +3006,16 @@ const jvm = { this.resolveHostCall(w.id, false, null, "host call timed out (jso bridge)"); } } + } catch (resumeErr) { + // Per-entry guard: every entry in this batch is ALREADY spliced out of + // timedWakeups, so an exception escaping one resume would strand every + // remaining entry's thread in a sleep/wait that can never fire again. + // Contain the failure to the one entry and keep resuming the rest. + try { + vmTrace("DIAG:WAKEUP_RESUME_THREW:kind=" + String(w.kind) + + ":err=" + String(resumeErr && resumeErr.message || resumeErr).slice(0, 120)); + } catch (_e) {} + } } this._refreshTimedWakeupTimer(); }, @@ -4029,6 +4161,77 @@ function* cn1_ivN(target, mid, args) { if (r && typeof r.next === "function") { return yield* r; } return r; } +// Synchronous virtual dispatch family (cn1_ivs0..4 / cn1_ivsN). Emitted +// at INVOKEVIRTUAL / INVOKEINTERFACE call sites whose signature the +// suspension analysis (exportedSuspendingSigs) proved has NO suspending +// impl -- so the resolved override is a plain ``function`` returning a +// value, and the caller need not be a generator. This is what lets a +// method that only makes non-suspending virtual calls be emitted as a +// plain ``function`` instead of ``function*`` (no ``yield*`` ceremony), +// removing per-call generator allocation and shrinking the bundle while +// keeping the green-thread model intact for genuinely-blocking paths. +// +// Defensive drive-once: if a target unexpectedly returns a generator (a +// CHA-soundness gap -- e.g. a runtime-installed override the static +// analysis didn't see, or the ``{}`` broken-receiver canvas no-op stubs +// in cn1_ivResolve which are ``function*``), step it ONCE. A body that +// never actually yields completes on the first next() so we return its +// value safely; one that genuinely suspends in this sync context throws +// a NAMED error rather than letting a raw generator object leak +// downstream as the "result" (the silent-corruption failure mode of the +// three earlier sync-dispatcher attempts). +function cn1_ivsDrive(r, mid) { + if (r && typeof r.next === "function") { + const step = r.next(); + if (!step.done) { + throw new Error("cn1_ivs: sync virtual dispatch reached a yielding method (CHA unsound): " + mid); + } + return step.value; + } + return r; +} +function cn1_ivsNpe() { + const ex = jvm.createException("java_lang_NullPointerException"); + if (typeof ex.ctor === "function") { + const cr = ex.ctor(ex.object); + if (cr && typeof cr.next === "function") { + const s = cr.next(); + if (!s.done) { throw new Error("cn1_ivs: NPE constructor yielded in sync dispatch"); } + } + } + throw ex.object; +} +function cn1_ivs0(target, mid) { + if (target == null) { cn1_ivsNpe(); } + return cn1_ivsDrive(cn1_ivResolve(target, mid)(target), mid); +} +function cn1_ivs1(target, mid, a0) { + if (target == null) { cn1_ivsNpe(); } + return cn1_ivsDrive(cn1_ivResolve(target, mid)(target, a0), mid); +} +function cn1_ivs2(target, mid, a0, a1) { + if (target == null) { cn1_ivsNpe(); } + return cn1_ivsDrive(cn1_ivResolve(target, mid)(target, a0, a1), mid); +} +function cn1_ivs3(target, mid, a0, a1, a2) { + if (target == null) { cn1_ivsNpe(); } + return cn1_ivsDrive(cn1_ivResolve(target, mid)(target, a0, a1, a2), mid); +} +function cn1_ivs4(target, mid, a0, a1, a2, a3) { + if (target == null) { cn1_ivsNpe(); } + return cn1_ivsDrive(cn1_ivResolve(target, mid)(target, a0, a1, a2, a3), mid); +} +function cn1_ivsN(target, mid, args) { + if (target == null) { cn1_ivsNpe(); } + const method = cn1_ivResolve(target, mid); + return cn1_ivsDrive(method.apply(null, [target].concat(args)), mid); +} +global.cn1_ivs0 = cn1_ivs0; +global.cn1_ivs1 = cn1_ivs1; +global.cn1_ivs2 = cn1_ivs2; +global.cn1_ivs3 = cn1_ivs3; +global.cn1_ivs4 = cn1_ivs4; +global.cn1_ivsN = cn1_ivsN; global.cn1_iv0 = cn1_iv0; global.cn1_iv1 = cn1_iv1; global.cn1_iv2 = cn1_iv2; @@ -5164,6 +5367,83 @@ bindNative(["cn1_com_codename1_impl_platform_js_VMHost_pollEventCode_R_int", return event && event.code != null ? (event.code | 0) : -1; }); +// ---- NativeInterface bridge ----------------------------------------------------- +// The generated Impl methods call these NativeInterfaceBridge.call* +// natives. Each forwards the (iface, method, args) tuple to the MAIN thread via +// the shared __cn1_native_interface_call__ host hook (browser_bridge.js runs the +// developer's JS stub with DOM access and resolves through its callback), then +// coerces the JS result to the declared Java return type. Args were already +// unboxed by toHostTransferArg (boxed primitives / Java String / long). +const __NI_PREFIX = "cn1_com_codename1_impl_platform_js_NativeInterfaceBridge_"; +const __NI_SIG = "java_lang_String_java_lang_String_java_lang_Object_1ARRAY"; +function* __cn1NativeInterfaceCall(iface, method, args) { + return yield jvm.invokeHostNative("__cn1_native_interface_call__", [iface, method, args]); +} +function __cn1NativeInterfaceArray(v, token) { + if (v == null) { + return null; + } + const len = v.length | 0; + const arr = jvm.newArray(len, token, 1); + for (let i = 0; i < len; i++) { + const e = v[i]; + switch (token) { + case "java_lang_String": arr[i] = (e == null ? null : createJavaString(e)); break; + case "JAVA_LONG": arr[i] = _LfromNumber(Number(e || 0)); break; + case "JAVA_BOOLEAN": arr[i] = !!e; break; + case "JAVA_CHAR": arr[i] = (e | 0) & 0xffff; break; + case "JAVA_BYTE": arr[i] = ((e | 0) << 24) >> 24; break; + case "JAVA_SHORT": arr[i] = ((e | 0) << 16) >> 16; break; + case "JAVA_INT": arr[i] = e | 0; break; + case "JAVA_FLOAT": arr[i] = Math.fround(Number(e || 0)); break; + case "JAVA_DOUBLE": arr[i] = Number(e || 0); break; + default: arr[i] = e; + } + } + return arr; +} +bindNative([__NI_PREFIX + "callBoolean_" + __NI_SIG + "_R_boolean"], function*(iface, method, args) { + return !!(yield* __cn1NativeInterfaceCall(iface, method, args)); +}); +bindNative([__NI_PREFIX + "callByte_" + __NI_SIG + "_R_byte"], function*(iface, method, args) { + const v = yield* __cn1NativeInterfaceCall(iface, method, args); return ((v | 0) << 24) >> 24; +}); +bindNative([__NI_PREFIX + "callShort_" + __NI_SIG + "_R_short"], function*(iface, method, args) { + const v = yield* __cn1NativeInterfaceCall(iface, method, args); return ((v | 0) << 16) >> 16; +}); +bindNative([__NI_PREFIX + "callInt_" + __NI_SIG + "_R_int"], function*(iface, method, args) { + return (yield* __cn1NativeInterfaceCall(iface, method, args)) | 0; +}); +bindNative([__NI_PREFIX + "callChar_" + __NI_SIG + "_R_char"], function*(iface, method, args) { + return ((yield* __cn1NativeInterfaceCall(iface, method, args)) | 0) & 0xffff; +}); +bindNative([__NI_PREFIX + "callLong_" + __NI_SIG + "_R_long"], function*(iface, method, args) { + return _LfromNumber(Number((yield* __cn1NativeInterfaceCall(iface, method, args)) || 0)); +}); +bindNative([__NI_PREFIX + "callFloat_" + __NI_SIG + "_R_float"], function*(iface, method, args) { + return Math.fround(Number((yield* __cn1NativeInterfaceCall(iface, method, args)) || 0)); +}); +bindNative([__NI_PREFIX + "callDouble_" + __NI_SIG + "_R_double"], function*(iface, method, args) { + return Number((yield* __cn1NativeInterfaceCall(iface, method, args)) || 0); +}); +bindNative([__NI_PREFIX + "callString_" + __NI_SIG + "_R_java_lang_String"], function*(iface, method, args) { + const v = yield* __cn1NativeInterfaceCall(iface, method, args); + return v == null ? null : createJavaString(v); +}); +bindNative([__NI_PREFIX + "callObject_" + __NI_SIG + "_R_java_lang_Object"], function*(iface, method, args) { + const v = yield* __cn1NativeInterfaceCall(iface, method, args); + return (typeof v === "string") ? createJavaString(v) : (v == null ? null : v); +}); +bindNative([__NI_PREFIX + "callVoid_" + __NI_SIG], function*(iface, method, args) { + yield* __cn1NativeInterfaceCall(iface, method, args); + return null; +}); +bindNative([__NI_PREFIX + "callArray_java_lang_String_java_lang_String_java_lang_Object_1ARRAY_java_lang_String_R_java_lang_Object"], + function*(iface, method, args, token) { + const v = yield* __cn1NativeInterfaceCall(iface, method, args); + return __cn1NativeInterfaceArray(v, jvm.toNativeString(token)); +}); + // Worker liveness heartbeat (diag-only). If the worker wedges in a synchronous // green-thread step this timer CANNOT fire (single-threaded) and the heartbeat // STOPS; if the worker is merely parked/starved (idle, a host callback not @@ -5173,12 +5453,46 @@ bindNative(["cn1_com_codename1_impl_platform_js_VMHost_pollEventCode_R_int", if (VM_DIAG_ENABLED && typeof setInterval === "function") { let __cn1HbLastResumes = -1; let __cn1HbFrozenStreak = 0; + let __cn1HbTick = 0; setInterval(function() { try { const rc = jvm.__cn1ResumeCount | 0; const frozen = rc === __cn1HbLastResumes; __cn1HbLastResumes = rc; __cn1HbFrozenStreak = frozen ? (__cn1HbFrozenStreak + 1) : 0; + // Periodic full thread dump (every ~20 beats ~= 30s). The FROZEN dump + // below only covers total wedges (resume count stalled); a single + // parked thread with the EDT still ticking -- e.g. a runner waiting on + // a notify that never comes -- never trips it. The periodic dump shows + // every thread's wait target during such partial stalls. + __cn1HbTick++; + if (__cn1HbTick % 20 === 0) { + vmTrace("DIAG:WORKER_HB_THREADS:" + jvm.dumpThreadStates()); + } + // Stranded-sleep detector: a thread parked in sleep PAST its deadline is + // in one of three states, each implicating a different bug: + // queued=1 -- entry still in timedWakeups; the single host timer is + // not firing / mis-armed (_refreshTimedWakeupTimer). + // cancelled=1 -- something _removeTimedWakeup'd it without resuming + // the thread. + // gone -- spliced out of timedWakeups while not cancelled: + // _processExpiredTimedWakeups collected it but the + // enqueue never landed. + var __ths = jvm.threads || []; + for (var __i = 0; __i < __ths.length; __i++) { + var __t = __ths[__i]; + if (__t.done || !__t.waiting || __t.waiting.op !== "sleep" || !__t.waiting.entry) continue; + var __e = __t.waiting.entry; + var __due = __e.wakeAt - jvm.schedulerNow(); + if (__due > -2000) continue; + vmTrace("DIAG:STRANDED_SLEEP:t" + __t.id + + ":dueIn=" + Math.round(__due) + + ":queued=" + (jvm.timedWakeups.indexOf(__e) >= 0 ? 1 : 0) + + ":cancelled=" + (__e.cancelled ? 1 : 0) + + ":wakeupTimerArmed=" + (jvm._wakeupTimer != null ? 1 : 0) + + ":wakeupAt=" + (jvm._wakeupAt === Infinity ? "inf" : Math.round(jvm._wakeupAt - jvm.schedulerNow())) + + ":pendingWakeups=" + jvm.timedWakeups.length); + } vmTrace("DIAG:WORKER_HB:resumes=" + rc + ":runnable=" + (jvm.runnable ? jvm.runnable.length : -1) + ":draining=" + (jvm.draining ? 1 : 0) @@ -5186,7 +5500,10 @@ if (VM_DIAG_ENABLED && typeof setInterval === "function") { + ":frozen=" + (frozen ? 1 : 0) + ":captureGate=" + (jvm.captureGateOwner ? 1 : 0) + ":sinceStepMs=" + (jvm.__cn1LastResumeTs != null ? Math.round(jvm.schedulerNow() - jvm.__cn1LastResumeTs) : -1) - + ":lastThread=" + String(jvm.__cn1LastResumeLabel)); + + ":lastThread=" + String(jvm.__cn1LastResumeLabel) + + ":wakeArm=" + (jvm._wakeupArmCount | 0) + + ":wakeFire=" + (jvm._wakeupFireCount | 0) + + ":wakeFiredAgo=" + (jvm._wakeupLastFiredAt != null ? Math.round(jvm.schedulerNow() - jvm._wakeupLastFiredAt) : -1)); // When the worker is wedged (frozen with nothing runnable) every green // thread is parked. Dump WHAT they are parked on so the lost-response / // deadlock can be isolated without worker-internal tracing (which