From aad27d0718112b6394c152ce411d7a06dfeee386 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 31 Jul 2026 07:10:02 +0300 Subject: [PATCH 01/91] Report the real port status, and fix the desktop-port defects it was hiding The Port Status table showed almost every column as partial, skipped or stale. Most of that was the reporting pipeline, not the ports. Reporting - scripts/website/sync_port_status_reports.sh re-implemented the publish rule as a jq expression that demanded a measured duration for all ten performance workloads. iOS, tvOS and watchOS legitimately skip the three GC-footprint workloads on the simulator, so every fresh Apple report was rejected, the site served the checked-in fallback, and after fourteen days those four columns rendered as stale. The rule now lives once in port_status.py (publishable_report_problems + the "accept" subcommand) next to the normalizer whose own tests already covered skipped workloads. Contract drift keeps the fallback with a warning; a malformed report fails the website build instead of quietly degrading. - port-status-publish.yml only publishes when a workflow_run event reaches it, and those events never arrive for the Linux and Windows suites: the data branch has never held a linux or windows-x64 report. The nightly now runs backfill_port_status.sh, which publishes from the newest master run of every producing workflow and fails when a port has no report inside the contract's staleness window. - Only the Android pipeline failed on a failing compliance test. iOS, macOS and JavaScript now do too; all three are at zero failures, so this is a ratchet rather than a new red. - A skip the errata account for by name renders as a pass with a marked note instead of a partial, and the page validator refuses a noted cell whose test the errata do not cover. A run that stopped early no longer withdraws the result of a feature whose every mapped test reported back. - The checked-in reports are refreshed, including the real (failing) Linux and Windows results, so the fallback states what those ports actually do. Desktop ports - java.time asked the host for the rules of a fixed offset by handing "GMT-05:00" to the platform time zone database. POSIX inverts the sign of a TZ offset and the Windows CRT cannot parse the form at all, so every OffsetDateTime formatted through a pattern came out shifted by twice its offset. Custom GMT/UTC IDs are now resolved in Java, and a ZoneOffset never reaches the host database. - Offsets now come from TimeZone.getOffset rather than from Calendar, which reconstructs local time from a raw offset plus a flat one-hour daylight guess. - Character.getType threw UnsupportedOperationException, which meant isLetter, isLetterOrDigit and isJavaIdentifierStart/Part threw for every input on these ports. ASCII now has a category table and the rest answers from the primitives this runtime implements. - openInputStream returned a stream wrapping a null file handle for a missing path, so callers could not tell a missing file from an empty one (issue #1502 on both desktop ports); openOutputStream and the storage streams silently discarded writes the same way. - The Linux port carried the Windows port's backslash path join, so its staged-resource fallback never resolved. - CameraApiTest treated the native Linux port as having a headless camera. It drives real V4L2 devices through GStreamer, which a hosted runner does not have, so it now skips with a stated reason like the other native ports. - The Linux capture harness accepts CN1_REQUIRE_SUITE, so it can demand the suite's own completion marker rather than stopping when screenshots go quiet while trailing tests are still queued. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/port-status-nightly.yml | 22 +- .github/workflows/scripts-ios.yml | 20 ++ .github/workflows/scripts-javascript.yml | 5 + .github/workflows/scripts-mac-native.yml | 5 + .../impl/linux/LinuxImplementation.java | 52 ++++- .../impl/windows/WindowsImplementation.java | 42 +++- .../assets/css/extended/cn1-port-status.css | 11 + .../website/data/port_status_environment.json | 8 +- .../data/port_status_reports/android.json | 151 +++++++------- .../data/port_status_reports/ios-gl.json | 143 ++++++------- .../data/port_status_reports/ios-metal.json | 143 ++++++------- .../data/port_status_reports/javascript.json | 158 +++++++------- .../data/port_status_reports/linux-arm64.json | 190 +++++++++-------- .../data/port_status_reports/linux-x64.json | 190 +++++++++-------- .../data/port_status_reports/mac-native.json | 151 +++++++------- .../data/port_status_reports/tvos.json | 143 ++++++------- .../data/port_status_reports/watchos.json | 96 ++++++--- .../data/port_status_reports/windows-x64.json | 195 ++++++++++-------- .../website/layouts/_default/port-status.html | 5 +- .../partials/port-status-feature-status.html | 39 +++- .../hellocodenameone/tests/CameraApiTest.java | 16 +- .../conformance/backfill_port_status.sh | 159 ++++++++++++++ .../conformance/port_status.py | 117 +++++++++++ .../conformance/test_port_status.py | 97 +++++++++ scripts/website/sync_port_status_reports.sh | 46 +++-- scripts/website/validate_port_status.mjs | 17 ++ vm/JavaAPI/src/java/lang/Character.java | 78 +++++-- vm/JavaAPI/src/java/time/DateTimeSupport.java | 27 ++- vm/JavaAPI/src/java/util/TimeZone.java | 80 ++++++- .../CleanTargetLinuxIntegrationTest.java | 20 +- 30 files changed, 1607 insertions(+), 819 deletions(-) create mode 100755 scripts/hellocodenameone/conformance/backfill_port_status.sh diff --git a/.github/workflows/port-status-nightly.yml b/.github/workflows/port-status-nightly.yml index 3d22b52cb5d..f377bede1b3 100644 --- a/.github/workflows/port-status-nightly.yml +++ b/.github/workflows/port-status-nightly.yml @@ -11,6 +11,24 @@ permissions: contents: write jobs: + # port-status-publish.yml only publishes a report when a workflow_run event + # reaches it, and those events never arrive for some producers -- the Linux + # and Windows suites had not landed a single report, so the public table + # served their checked-in fallback until it aged out and the columns rendered + # as unknown. This sweep publishes from the newest master run of every + # producing workflow and fails when a port has no report inside the + # contract's staleness window. + publish-latest-port-reports: + if: github.ref == 'refs/heads/master' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v6 + - name: Publish the newest master report for every port + env: + GH_TOKEN: ${{ github.token }} + run: scripts/hellocodenameone/conformance/backfill_port_status.sh + build-javascript-app: runs-on: ubuntu-latest timeout-minutes: 60 @@ -81,8 +99,10 @@ jobs: if-no-files-found: error publish-browser-evidence: + # Ordered after the report sweep (and tolerant of it failing) so the site + # rebuild at the end of this job picks up everything published tonight. if: always() && needs.build-javascript-app.result == 'success' - needs: [build-javascript-app, browser-lifecycle] + needs: [build-javascript-app, browser-lifecycle, publish-latest-port-reports] runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/scripts-ios.yml b/.github/workflows/scripts-ios.yml index fee02d9c421..cacc624a7ab 100644 --- a/.github/workflows/scripts-ios.yml +++ b/.github/workflows/scripts-ios.yml @@ -104,6 +104,11 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }} + # The device runner reports logical test failures through CN1SS log + # markers, not through the build or the screenshot comparison. Make the + # normalized report authoritative so a failing or never-run compliance + # test cannot leave this workflow green and then be published from master. + CN1SS_FAIL_ON_TEST_PROBLEMS: '1' GH_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }} # Optional: when set, build-ios-app.sh writes it as a bundled resource so # the GoogleWebMap screenshot test renders a live Google map; absent (e.g. @@ -297,6 +302,11 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }} + # The device runner reports logical test failures through CN1SS log + # markers, not through the build or the screenshot comparison. Make the + # normalized report authoritative so a failing or never-run compliance + # test cannot leave this workflow green and then be published from master. + CN1SS_FAIL_ON_TEST_PROBLEMS: '1' GH_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }} # Optional: when set, build-ios-app.sh writes it as a bundled resource so # the GoogleWebMap screenshot test renders a live Google map; absent (e.g. @@ -578,6 +588,11 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }} + # The device runner reports logical test failures through CN1SS log + # markers, not through the build or the screenshot comparison. Make the + # normalized report authoritative so a failing or never-run compliance + # test cannot leave this workflow green and then be published from master. + CN1SS_FAIL_ON_TEST_PROBLEMS: '1' GH_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }} # Optional: when set, build-ios-app.sh writes it as a bundled resource so # the GoogleWebMap screenshot test renders a live Google map; absent (e.g. @@ -745,6 +760,11 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }} + # The device runner reports logical test failures through CN1SS log + # markers, not through the build or the screenshot comparison. Make the + # normalized report authoritative so a failing or never-run compliance + # test cannot leave this workflow green and then be published from master. + CN1SS_FAIL_ON_TEST_PROBLEMS: '1' GH_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }} steps: diff --git a/.github/workflows/scripts-javascript.yml b/.github/workflows/scripts-javascript.yml index 333d71cabd1..ee008b610fb 100644 --- a/.github/workflows/scripts-javascript.yml +++ b/.github/workflows/scripts-javascript.yml @@ -77,6 +77,11 @@ jobs: runs-on: ubuntu-latest env: GITHUB_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }} + # The device runner reports logical test failures through CN1SS log + # markers, not through the build or the screenshot comparison. Make the + # normalized report authoritative so a failing or never-run compliance + # test cannot leave this workflow green and then be published from master. + CN1SS_FAIL_ON_TEST_PROBLEMS: '1' GH_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }} ARTIFACTS_DIR: ${{ github.workspace }}/artifacts/javascript-ui-tests # CN1_JS_TIMEOUT_SECONDS guards the per-suite SUITE:FINISHED wait. diff --git a/.github/workflows/scripts-mac-native.yml b/.github/workflows/scripts-mac-native.yml index 9ad0939de6d..709a368e114 100644 --- a/.github/workflows/scripts-mac-native.yml +++ b/.github/workflows/scripts-mac-native.yml @@ -93,6 +93,11 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }} + # The device runner reports logical test failures through CN1SS log + # markers, not through the build or the screenshot comparison. Make the + # normalized report authoritative so a failing or never-run compliance + # test cannot leave this workflow green and then be published from master. + CN1SS_FAIL_ON_TEST_PROBLEMS: '1' GH_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }} # Optional: when set, build-mac-native-app.sh writes it as a bundled # resource so the GoogleWebMap screenshot test renders a live Google map; diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java index 431d1775b57..1d82f0e51b9 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java @@ -39,6 +39,7 @@ import com.codename1.ui.accessibility.AccessibilityNodeSnapshot; import com.codename1.ui.accessibility.AccessibilityTreeSnapshot; import java.io.ByteArrayOutputStream; +import java.io.FileNotFoundException; import java.io.IOException; import java.io.ByteArrayInputStream; import java.io.InputStream; @@ -2363,8 +2364,8 @@ public int getContentLength(Object connection) { @Override public OutputStream openOutputStream(Object connection) throws IOException { if (connection instanceof String) { - long h = LinuxNative.fileOpenWrite(stripFileUrl((String) connection), false); - return new LinuxOutputStream(h, false); + String path = stripFileUrl((String) connection); + return new LinuxOutputStream(openForWrite(path, false), false); } return new LinuxOutputStream(((LinuxHttpConnection) connection).peer, true); } @@ -2373,23 +2374,44 @@ public OutputStream openOutputStream(Object connection) throws IOException { public OutputStream openOutputStream(Object connection, int offset) throws IOException { // offset-based writing maps to opening the file for append/seek; the // first cut appends, which covers the common resume-write case. - long h = LinuxNative.fileOpenWrite(stripFileUrl((String) connection), true); - return new LinuxOutputStream(h, false); + String path = stripFileUrl((String) connection); + return new LinuxOutputStream(openForWrite(path, true), false); } @Override public InputStream openInputStream(Object connection) throws IOException { if (connection instanceof String) { - long h = LinuxNative.fileOpenRead(stripFileUrl((String) connection)); + String path = stripFileUrl((String) connection); + long h = LinuxNative.fileOpenRead(path); + if (h == 0) { + // fopen() returns NULL for a missing (or unreadable) path. + // Wrapping that handle produced a stream that read as a + // legitimately empty file, so callers could not tell a missing + // file from an empty one -- the exact defect issue #1502 + // reported against iOS. + throw new FileNotFoundException("No such file: " + path); + } return new LinuxInputStream(h, false); } return new LinuxInputStream(((LinuxHttpConnection) connection).peer, true); } + /// Opens `path` for writing, failing loudly when the platform cannot. A + /// null handle otherwise yields a stream that discards every write and + /// closes cleanly, which turns an unwritable path into a file that simply + /// never appears. + private long openForWrite(String path, boolean append) throws IOException { + long h = LinuxNative.fileOpenWrite(path, append); + if (h == 0) { + throw new IOException("Unable to open " + path + " for writing"); + } + return h; + } + /** * Resolves a classpath-style resource (e.g. {@code /theme.res}). The ParparVM - * linux target embeds the app's classpath resources into the executable's PE - * resource section, so they are served straight from the exe -- a single + * linux target embeds the app's classpath resources into the executable's + * data section, so they are served straight from the ELF -- a single * self-contained binary, the Linux analog of the iOS .app bundle. Falls back * to a file shipped next to the executable (a dev/debug convenience for * resources that were staged rather than embedded). Returns null when absent. @@ -2407,8 +2429,11 @@ public InputStream getResourceAsStream(Class cls, String resource) { if (dir == null) { return null; } + // Classpath resources are already '/'-separated, which is what the + // filesystem wants here; this port was carrying the Windows port's + // backslash join, so the staged-resource fallback never resolved. String name = resource.startsWith("/") ? resource.substring(1) : resource; - String path = dir + "\\" + name.replace('/', '\\'); + String path = dir + "/" + name; long h = LinuxNative.fileOpenRead(path); if (h == 0) { return null; @@ -2584,13 +2609,18 @@ public void deleteStorageFile(String name) { @Override public OutputStream createStorageOutputStream(String name) throws IOException { - long h = LinuxNative.fileOpenWrite(storagePath(name), false); - return new LinuxOutputStream(h, false); + // Same reason as openOutputStream: a discarded write that reports + // success loses the entry instead of reporting that it cannot be saved. + return new LinuxOutputStream(openForWrite(storagePath(name), false), false); } @Override public InputStream createStorageInputStream(String name) throws IOException { - long h = LinuxNative.fileOpenRead(storagePath(name)); + String path = storagePath(name); + long h = LinuxNative.fileOpenRead(path); + if (h == 0) { + throw new FileNotFoundException("No such storage entry: " + name); + } return new LinuxInputStream(h, false); } diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java index 41d720777da..da0c0ba313a 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java @@ -39,6 +39,7 @@ import com.codename1.ui.accessibility.AccessibilityNodeSnapshot; import com.codename1.ui.accessibility.AccessibilityTreeSnapshot; import java.io.ByteArrayOutputStream; +import java.io.FileNotFoundException; import java.io.IOException; import java.io.ByteArrayInputStream; import java.io.InputStream; @@ -2377,8 +2378,8 @@ public int getContentLength(Object connection) { @Override public OutputStream openOutputStream(Object connection) throws IOException { if (connection instanceof String) { - long h = WindowsNative.fileOpenWrite(stripFileUrl((String) connection), false); - return new WindowsOutputStream(h, false); + String path = stripFileUrl((String) connection); + return new WindowsOutputStream(openForWrite(path, false), false); } return new WindowsOutputStream(((WindowsHttpConnection) connection).peer, true); } @@ -2387,19 +2388,39 @@ public OutputStream openOutputStream(Object connection) throws IOException { public OutputStream openOutputStream(Object connection, int offset) throws IOException { // offset-based writing maps to opening the file for append/seek; the // first cut appends, which covers the common resume-write case. - long h = WindowsNative.fileOpenWrite(stripFileUrl((String) connection), true); - return new WindowsOutputStream(h, false); + String path = stripFileUrl((String) connection); + return new WindowsOutputStream(openForWrite(path, true), false); } @Override public InputStream openInputStream(Object connection) throws IOException { if (connection instanceof String) { - long h = WindowsNative.fileOpenRead(stripFileUrl((String) connection)); + String path = stripFileUrl((String) connection); + long h = WindowsNative.fileOpenRead(path); + if (h == 0) { + // A null handle otherwise produced a stream that read as a + // legitimately empty file, so callers could not tell a missing + // file from an empty one -- the exact defect issue #1502 + // reported against iOS. + throw new FileNotFoundException("No such file: " + path); + } return new WindowsInputStream(h, false); } return new WindowsInputStream(((WindowsHttpConnection) connection).peer, true); } + /// Opens `path` for writing, failing loudly when the platform cannot. A + /// null handle otherwise yields a stream that discards every write and + /// closes cleanly, which turns an unwritable path into a file that simply + /// never appears. + private long openForWrite(String path, boolean append) throws IOException { + long h = WindowsNative.fileOpenWrite(path, append); + if (h == 0) { + throw new IOException("Unable to open " + path + " for writing"); + } + return h; + } + /** * Resolves a classpath-style resource (e.g. {@code /theme.res}). The ParparVM * windows target embeds the app's classpath resources into the executable's PE @@ -2598,13 +2619,18 @@ public void deleteStorageFile(String name) { @Override public OutputStream createStorageOutputStream(String name) throws IOException { - long h = WindowsNative.fileOpenWrite(storagePath(name), false); - return new WindowsOutputStream(h, false); + // Same reason as openOutputStream: a discarded write that reports + // success loses the entry instead of reporting that it cannot be saved. + return new WindowsOutputStream(openForWrite(storagePath(name), false), false); } @Override public InputStream createStorageInputStream(String name) throws IOException { - long h = WindowsNative.fileOpenRead(storagePath(name)); + String path = storagePath(name); + long h = WindowsNative.fileOpenRead(path); + if (h == 0) { + throw new FileNotFoundException("No such storage entry: " + name); + } return new WindowsInputStream(h, false); } diff --git a/docs/website/assets/css/extended/cn1-port-status.css b/docs/website/assets/css/extended/cn1-port-status.css index 16c4e78741b..20e7179f398 100644 --- a/docs/website/assets/css/extended/cn1-port-status.css +++ b/docs/website/assets/css/extended/cn1-port-status.css @@ -271,6 +271,17 @@ .cn1-port-status__mark.is-fallback { color: #3976c5; } .cn1-port-status__mark.is-unavailable { color: #7d8791; } +/* Marks a pass whose only non-passing test is a skip the errata explain, so + the cell reads as green without hiding that something was not executed. */ +.cn1-port-status__note { + color: #27955b; + font-size: .8rem; + font-weight: 800; + line-height: 1; + margin-left: .1rem; + vertical-align: super; +} + .cn1-port-status__ports { display: grid; gap: .75rem; diff --git a/docs/website/data/port_status_environment.json b/docs/website/data/port_status_environment.json index 8ab8ebc7aa2..65ad2826aa8 100644 --- a/docs/website/data/port_status_environment.json +++ b/docs/website/data/port_status_environment.json @@ -1,19 +1,19 @@ { "schema_version": 1, - "generated_at": "2026-07-16T15:45:26Z", - "commit": "9df6f7a1ea0e10a19c84b8994725d9c9d9dd9899", + "generated_at": "2026-07-30T05:51:23Z", + "commit": "9c7affa41291e328b08a21d823737f581f1b7d3e", "browsers": [ { "id": "chromium", "name": "Chromium", - "engine_version": "149.0.7827.55", + "engine_version": "151.0.7922.34", "status": "pass", "coverage": "Full compliance suite plus nightly lifecycle validation" }, { "id": "firefox", "name": "Firefox", - "engine_version": "151.0", + "engine_version": "153.0", "status": "pass", "coverage": "Nightly lifecycle validation" }, diff --git a/docs/website/data/port_status_reports/android.json b/docs/website/data/port_status_reports/android.json index fb044632f8f..e0662657dc1 100644 --- a/docs/website/data/port_status_reports/android.json +++ b/docs/website/data/port_status_reports/android.json @@ -1,14 +1,64 @@ { - "commit": "b51436a94d6451eae6b2673e1ff48eb88003c142", - "generated_at": "2026-07-16T15:09:30Z", + "commit": "587813ab1f221046e99996b2c1d1dfcb58f4ac94", + "generated_at": "2026-07-30T19:16:00Z", + "performance": { + "benchmark_version": 1, + "benchmarks": { + "arrayRandom": { + "checksum": "-2288487891715278", + "duration_ns": 257653628 + }, + "arraySequential": { + "checksum": "0", + "duration_ns": 35267214 + }, + "hashMapChurn": { + "checksum": "49941", + "duration_ns": 327725521 + }, + "intArithmetic": { + "checksum": "1307491170054", + "duration_ns": 203997199 + }, + "longArithmetic": { + "checksum": "6887886960473257608", + "duration_ns": 141909697 + }, + "mathTranscendental": { + "checksum": "4729652805076374709", + "duration_ns": 4377145955 + }, + "objectAllocation": { + "checksum": "2999790376128", + "duration_ns": 152307139 + }, + "quicksort": { + "checksum": "809667393311589960", + "duration_ns": 172021740 + }, + "recursion": { + "checksum": "33385282", + "duration_ns": 763524967 + }, + "stringBuilding": { + "checksum": "-609121604069", + "duration_ns": 295697914 + } + }, + "method": "minimum of five measured runs after three in-process warm-ups", + "missing": [], + "skipped": {}, + "status": "complete", + "suite_checksum": 0 + }, "port": "android", - "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/29508571507", + "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/30571977096", "schema_version": 1, "suite_finished": true, "summary": { "fail": 0, - "not-run": 5, - "pass": 164, + "not-run": 0, + "pass": 169, "skip": 1 }, "tests": { @@ -72,6 +122,10 @@ "feature": "bytecode-runtime", "status": "pass" }, + "CalendarApiTest": { + "feature": "calendar-integration", + "status": "pass" + }, "CallDetectionAPITest": { "feature": "device-security-signals", "status": "pass" @@ -167,11 +221,11 @@ "feature": "graphics-clipping", "status": "pass" }, - "CodeEditorScreenshotTest": { - "feature": "pure-text-editing", + "ClipboardRoundTripTest": { + "feature": "clipboard", "status": "pass" }, - "PureEditorScreenshotTest": { + "CodeEditorScreenshotTest": { "feature": "pure-text-editing", "status": "pass" }, @@ -339,6 +393,10 @@ "feature": "text-input", "status": "pass" }, + "InferenceOnDeviceApiTest": { + "feature": "on-device-inference", + "status": "pass" + }, "InscribedTriangleGrid": { "feature": "graphics-shapes-strokes", "status": "pass" @@ -351,6 +409,10 @@ "feature": "application-bootstrap", "status": "pass" }, + "LanguageOnDeviceApiTest": { + "feature": "on-device-language", + "status": "pass" + }, "LargeStrokeDirtyClipTest": { "feature": "graphics-clipping", "status": "pass" @@ -455,6 +517,10 @@ "feature": "scrolling-and-pull", "status": "pass" }, + "PureEditorScreenshotTest": { + "feature": "pure-text-editing", + "status": "pass" + }, "RealOsmVectorScreenshotTest": { "feature": "vector-maps", "status": "pass" @@ -675,76 +741,9 @@ "feature": "video-round-trip", "status": "pass" }, - "CalendarApiTest": { - "feature": "calendar-integration", - "status": "not-run" - }, - "ClipboardRoundTripTest": { - "feature": "clipboard", - "status": "not-run" - }, - "InferenceOnDeviceApiTest": { - "feature": "on-device-inference", - "status": "not-run" - }, - "LanguageOnDeviceApiTest": { - "feature": "on-device-language", - "status": "not-run" - }, "VisionOnDeviceApiTest": { "feature": "on-device-vision", - "status": "not-run" + "status": "pass" } - }, - "bootstrap_source": "successful-master-workflow", - "workflow_conclusion": "success", - "performance": { - "benchmark_version": 1, - "benchmarks": { - "arrayRandom": { - "checksum": "-2288487891715278", - "duration_ns": 270971236 - }, - "arraySequential": { - "checksum": "0", - "duration_ns": 33987228 - }, - "hashMapChurn": { - "checksum": "49941", - "duration_ns": 306089084 - }, - "intArithmetic": { - "checksum": "1307491170054", - "duration_ns": 239946517 - }, - "longArithmetic": { - "checksum": "6887886960473257608", - "duration_ns": 174849906 - }, - "mathTranscendental": { - "checksum": "4729652805076374709", - "duration_ns": 5031760674 - }, - "objectAllocation": { - "checksum": "2999790376128", - "duration_ns": 145574439 - }, - "quicksort": { - "checksum": "809667393311589960", - "duration_ns": 151462539 - }, - "recursion": { - "checksum": "33385282", - "duration_ns": 743204825 - }, - "stringBuilding": { - "checksum": "-609121604069", - "duration_ns": 307991556 - } - }, - "method": "minimum of five measured runs after three in-process warm-ups", - "missing": [], - "status": "complete", - "suite_checksum": 0 } } diff --git a/docs/website/data/port_status_reports/ios-gl.json b/docs/website/data/port_status_reports/ios-gl.json index 56dceeb7689..894d5f63a56 100644 --- a/docs/website/data/port_status_reports/ios-gl.json +++ b/docs/website/data/port_status_reports/ios-gl.json @@ -1,14 +1,56 @@ { - "commit": "205dcc675896c1c094db47d9b0a2622adf7ec3bf", - "generated_at": "2026-07-16T12:54:52Z", + "commit": "9c7affa41291e328b08a21d823737f581f1b7d3e", + "generated_at": "2026-07-30T09:24:29Z", + "performance": { + "benchmark_version": 1, + "benchmarks": { + "arrayRandom": { + "checksum": "-2288487891715278", + "duration_ns": 334687000 + }, + "arraySequential": { + "checksum": "0", + "duration_ns": 21787000 + }, + "intArithmetic": { + "checksum": "1307491170054", + "duration_ns": 122655000 + }, + "longArithmetic": { + "checksum": "6887886960473257608", + "duration_ns": 74123000 + }, + "mathTranscendental": { + "checksum": "4729652805076374709", + "duration_ns": 322920000 + }, + "quicksort": { + "checksum": "809667393311589960", + "duration_ns": 134806000 + }, + "recursion": { + "checksum": "33385282", + "duration_ns": 175512000 + } + }, + "method": "minimum of five measured runs after three in-process warm-ups", + "missing": [], + "skipped": { + "hashMapChurn": "ios-simulator-gc-footprint", + "objectAllocation": "ios-simulator-gc-footprint", + "stringBuilding": "ios-simulator-gc-footprint" + }, + "status": "complete", + "suite_checksum": 0 + }, "port": "ios-gl", - "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/29496889339", + "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/30516173164", "schema_version": 1, "suite_finished": true, "summary": { "fail": 0, - "not-run": 5, - "pass": 163, + "not-run": 0, + "pass": 168, "skip": 2 }, "tests": { @@ -72,6 +114,10 @@ "feature": "bytecode-runtime", "status": "pass" }, + "CalendarApiTest": { + "feature": "calendar-integration", + "status": "pass" + }, "CallDetectionAPITest": { "feature": "device-security-signals", "status": "pass" @@ -167,11 +213,11 @@ "feature": "graphics-clipping", "status": "pass" }, - "CodeEditorScreenshotTest": { - "feature": "pure-text-editing", + "ClipboardRoundTripTest": { + "feature": "clipboard", "status": "pass" }, - "PureEditorScreenshotTest": { + "CodeEditorScreenshotTest": { "feature": "pure-text-editing", "status": "pass" }, @@ -339,6 +385,10 @@ "feature": "text-input", "status": "pass" }, + "InferenceOnDeviceApiTest": { + "feature": "on-device-inference", + "status": "pass" + }, "InscribedTriangleGrid": { "feature": "graphics-shapes-strokes", "status": "pass" @@ -351,6 +401,10 @@ "feature": "application-bootstrap", "status": "pass" }, + "LanguageOnDeviceApiTest": { + "feature": "on-device-language", + "status": "pass" + }, "LargeStrokeDirtyClipTest": { "feature": "graphics-clipping", "status": "pass" @@ -455,6 +509,10 @@ "feature": "scrolling-and-pull", "status": "pass" }, + "PureEditorScreenshotTest": { + "feature": "pure-text-editing", + "status": "pass" + }, "RealOsmVectorScreenshotTest": { "feature": "vector-maps", "status": "pass" @@ -678,76 +736,9 @@ ], "status": "skip" }, - "CalendarApiTest": { - "feature": "calendar-integration", - "status": "not-run" - }, - "ClipboardRoundTripTest": { - "feature": "clipboard", - "status": "not-run" - }, - "InferenceOnDeviceApiTest": { - "feature": "on-device-inference", - "status": "not-run" - }, - "LanguageOnDeviceApiTest": { - "feature": "on-device-language", - "status": "not-run" - }, "VisionOnDeviceApiTest": { "feature": "on-device-vision", - "status": "not-run" + "status": "pass" } - }, - "bootstrap_source": "successful-master-workflow", - "workflow_conclusion": "success", - "performance": { - "benchmark_version": 1, - "benchmarks": { - "arrayRandom": { - "checksum": "-2288487891715278", - "duration_ns": 260829000 - }, - "arraySequential": { - "checksum": "0", - "duration_ns": 20455000 - }, - "hashMapChurn": { - "checksum": "49941", - "duration_ns": 39588000 - }, - "intArithmetic": { - "checksum": "1307491170054", - "duration_ns": 106287000 - }, - "longArithmetic": { - "checksum": "6887886960473257608", - "duration_ns": 65812000 - }, - "mathTranscendental": { - "checksum": "4729652805076374709", - "duration_ns": 288774000 - }, - "objectAllocation": { - "checksum": "2999790376128", - "duration_ns": 218533000 - }, - "quicksort": { - "checksum": "809667393311589960", - "duration_ns": 126690000 - }, - "recursion": { - "checksum": "33385282", - "duration_ns": 164612000 - }, - "stringBuilding": { - "checksum": "-609121604069", - "duration_ns": 47528000 - } - }, - "method": "minimum of five measured runs after three in-process warm-ups", - "missing": [], - "status": "complete", - "suite_checksum": 0 } } diff --git a/docs/website/data/port_status_reports/ios-metal.json b/docs/website/data/port_status_reports/ios-metal.json index 41e6d8bb21c..0af514c1187 100644 --- a/docs/website/data/port_status_reports/ios-metal.json +++ b/docs/website/data/port_status_reports/ios-metal.json @@ -1,14 +1,56 @@ { - "commit": "205dcc675896c1c094db47d9b0a2622adf7ec3bf", - "generated_at": "2026-07-16T13:06:47Z", + "commit": "9c7affa41291e328b08a21d823737f581f1b7d3e", + "generated_at": "2026-07-30T10:12:12Z", + "performance": { + "benchmark_version": 1, + "benchmarks": { + "arrayRandom": { + "checksum": "-2288487891715278", + "duration_ns": 346696000 + }, + "arraySequential": { + "checksum": "0", + "duration_ns": 24678000 + }, + "intArithmetic": { + "checksum": "1307491170054", + "duration_ns": 119904000 + }, + "longArithmetic": { + "checksum": "6887886960473257608", + "duration_ns": 73599000 + }, + "mathTranscendental": { + "checksum": "4729652805076374709", + "duration_ns": 388630000 + }, + "quicksort": { + "checksum": "809667393311589960", + "duration_ns": 126606000 + }, + "recursion": { + "checksum": "33385282", + "duration_ns": 178679000 + } + }, + "method": "minimum of five measured runs after three in-process warm-ups", + "missing": [], + "skipped": { + "hashMapChurn": "ios-simulator-gc-footprint", + "objectAllocation": "ios-simulator-gc-footprint", + "stringBuilding": "ios-simulator-gc-footprint" + }, + "status": "complete", + "suite_checksum": 0 + }, "port": "ios-metal", - "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/29496889339", + "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/30516173164", "schema_version": 1, "suite_finished": true, "summary": { "fail": 0, - "not-run": 5, - "pass": 163, + "not-run": 0, + "pass": 168, "skip": 2 }, "tests": { @@ -72,6 +114,10 @@ "feature": "bytecode-runtime", "status": "pass" }, + "CalendarApiTest": { + "feature": "calendar-integration", + "status": "pass" + }, "CallDetectionAPITest": { "feature": "device-security-signals", "status": "pass" @@ -167,11 +213,11 @@ "feature": "graphics-clipping", "status": "pass" }, - "CodeEditorScreenshotTest": { - "feature": "pure-text-editing", + "ClipboardRoundTripTest": { + "feature": "clipboard", "status": "pass" }, - "PureEditorScreenshotTest": { + "CodeEditorScreenshotTest": { "feature": "pure-text-editing", "status": "pass" }, @@ -339,6 +385,10 @@ "feature": "text-input", "status": "pass" }, + "InferenceOnDeviceApiTest": { + "feature": "on-device-inference", + "status": "pass" + }, "InscribedTriangleGrid": { "feature": "graphics-shapes-strokes", "status": "pass" @@ -351,6 +401,10 @@ "feature": "application-bootstrap", "status": "pass" }, + "LanguageOnDeviceApiTest": { + "feature": "on-device-language", + "status": "pass" + }, "LargeStrokeDirtyClipTest": { "feature": "graphics-clipping", "status": "pass" @@ -455,6 +509,10 @@ "feature": "scrolling-and-pull", "status": "pass" }, + "PureEditorScreenshotTest": { + "feature": "pure-text-editing", + "status": "pass" + }, "RealOsmVectorScreenshotTest": { "feature": "vector-maps", "status": "pass" @@ -678,76 +736,9 @@ ], "status": "skip" }, - "CalendarApiTest": { - "feature": "calendar-integration", - "status": "not-run" - }, - "ClipboardRoundTripTest": { - "feature": "clipboard", - "status": "not-run" - }, - "InferenceOnDeviceApiTest": { - "feature": "on-device-inference", - "status": "not-run" - }, - "LanguageOnDeviceApiTest": { - "feature": "on-device-language", - "status": "not-run" - }, "VisionOnDeviceApiTest": { "feature": "on-device-vision", - "status": "not-run" + "status": "pass" } - }, - "bootstrap_source": "successful-master-workflow", - "workflow_conclusion": "success", - "performance": { - "benchmark_version": 1, - "benchmarks": { - "arrayRandom": { - "checksum": "-2288487891715278", - "duration_ns": 440011000 - }, - "arraySequential": { - "checksum": "0", - "duration_ns": 26479000 - }, - "hashMapChurn": { - "checksum": "49941", - "duration_ns": 47714000 - }, - "intArithmetic": { - "checksum": "1307491170054", - "duration_ns": 135175000 - }, - "longArithmetic": { - "checksum": "6887886960473257608", - "duration_ns": 76514000 - }, - "mathTranscendental": { - "checksum": "4729652805076374709", - "duration_ns": 410277000 - }, - "objectAllocation": { - "checksum": "2999790376128", - "duration_ns": 367596000 - }, - "quicksort": { - "checksum": "809667393311589960", - "duration_ns": 129907000 - }, - "recursion": { - "checksum": "33385282", - "duration_ns": 246493000 - }, - "stringBuilding": { - "checksum": "-609121604069", - "duration_ns": 86101000 - } - }, - "method": "minimum of five measured runs after three in-process warm-ups", - "missing": [], - "status": "complete", - "suite_checksum": 0 } } diff --git a/docs/website/data/port_status_reports/javascript.json b/docs/website/data/port_status_reports/javascript.json index a31881d039b..b0152b54e94 100644 --- a/docs/website/data/port_status_reports/javascript.json +++ b/docs/website/data/port_status_reports/javascript.json @@ -1,15 +1,65 @@ { - "commit": "4a3f5807e0850131483b20b45d3802c9c42f7fd7", - "generated_at": "2026-07-16T14:48:44Z", + "commit": "587813ab1f221046e99996b2c1d1dfcb58f4ac94", + "generated_at": "2026-07-30T19:30:48Z", + "performance": { + "benchmark_version": 1, + "benchmarks": { + "arrayRandom": { + "checksum": "-2288487891715278", + "duration_ns": 1486700000 + }, + "arraySequential": { + "checksum": "0", + "duration_ns": 851400000 + }, + "hashMapChurn": { + "checksum": "49941", + "duration_ns": 14321000000 + }, + "intArithmetic": { + "checksum": "1313580095284", + "duration_ns": 705300000 + }, + "longArithmetic": { + "checksum": "6887886960473257608", + "duration_ns": 3920500000 + }, + "mathTranscendental": { + "checksum": "4729652805076374709", + "duration_ns": 586000000 + }, + "objectAllocation": { + "checksum": "2999790376128", + "duration_ns": 2741500000 + }, + "quicksort": { + "checksum": "786886890168670967", + "duration_ns": 576800000 + }, + "recursion": { + "checksum": "33385282", + "duration_ns": 1123899999 + }, + "stringBuilding": { + "checksum": "-609121604069", + "duration_ns": 1223900000 + } + }, + "method": "minimum of five measured runs after three in-process warm-ups", + "missing": [], + "skipped": {}, + "status": "complete", + "suite_checksum": 0 + }, "port": "javascript", - "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/29505603266", + "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/30571977438", "schema_version": 1, "suite_finished": true, "summary": { "fail": 0, - "not-run": 5, - "pass": 164, - "skip": 1 + "not-run": 0, + "pass": 170, + "skip": 0 }, "tests": { "ARApiTest": { @@ -72,16 +122,17 @@ "feature": "bytecode-runtime", "status": "pass" }, + "CalendarApiTest": { + "feature": "calendar-integration", + "status": "pass" + }, "CallDetectionAPITest": { "feature": "device-security-signals", "status": "pass" }, "CameraApiTest": { "feature": "camera-access", - "reasons": [ - "needs-runtime-permission-on-HTML5" - ], - "status": "skip" + "status": "pass" }, "CenteredDialogTitleScreenshotTest": { "feature": "dialogs-and-labels", @@ -167,11 +218,11 @@ "feature": "graphics-clipping", "status": "pass" }, - "CodeEditorScreenshotTest": { - "feature": "pure-text-editing", + "ClipboardRoundTripTest": { + "feature": "clipboard", "status": "pass" }, - "PureEditorScreenshotTest": { + "CodeEditorScreenshotTest": { "feature": "pure-text-editing", "status": "pass" }, @@ -339,6 +390,10 @@ "feature": "text-input", "status": "pass" }, + "InferenceOnDeviceApiTest": { + "feature": "on-device-inference", + "status": "pass" + }, "InscribedTriangleGrid": { "feature": "graphics-shapes-strokes", "status": "pass" @@ -351,6 +406,10 @@ "feature": "application-bootstrap", "status": "pass" }, + "LanguageOnDeviceApiTest": { + "feature": "on-device-language", + "status": "pass" + }, "LargeStrokeDirtyClipTest": { "feature": "graphics-clipping", "status": "pass" @@ -455,6 +514,10 @@ "feature": "scrolling-and-pull", "status": "pass" }, + "PureEditorScreenshotTest": { + "feature": "pure-text-editing", + "status": "pass" + }, "RealOsmVectorScreenshotTest": { "feature": "vector-maps", "status": "pass" @@ -675,76 +738,9 @@ "feature": "video-round-trip", "status": "pass" }, - "CalendarApiTest": { - "feature": "calendar-integration", - "status": "not-run" - }, - "ClipboardRoundTripTest": { - "feature": "clipboard", - "status": "not-run" - }, - "InferenceOnDeviceApiTest": { - "feature": "on-device-inference", - "status": "not-run" - }, - "LanguageOnDeviceApiTest": { - "feature": "on-device-language", - "status": "not-run" - }, "VisionOnDeviceApiTest": { "feature": "on-device-vision", - "status": "not-run" + "status": "pass" } - }, - "bootstrap_source": "successful-master-workflow", - "workflow_conclusion": "success", - "performance": { - "benchmark_version": 1, - "benchmarks": { - "arrayRandom": { - "checksum": "-2288487891715278", - "duration_ns": 1454299999 - }, - "arraySequential": { - "checksum": "0", - "duration_ns": 956000000 - }, - "hashMapChurn": { - "checksum": "49941", - "duration_ns": 15788000000 - }, - "intArithmetic": { - "checksum": "1313580095284", - "duration_ns": 721800001 - }, - "longArithmetic": { - "checksum": "6887886960473257608", - "duration_ns": 3997400000 - }, - "mathTranscendental": { - "checksum": "4729652805076374709", - "duration_ns": 300100000 - }, - "objectAllocation": { - "checksum": "2999790376128", - "duration_ns": 3617099999 - }, - "quicksort": { - "checksum": "786886890168670967", - "duration_ns": 577900001 - }, - "recursion": { - "checksum": "33385282", - "duration_ns": 1071299999 - }, - "stringBuilding": { - "checksum": "-609121604069", - "duration_ns": 1333500000 - } - }, - "method": "minimum of five measured runs after three in-process warm-ups", - "missing": [], - "status": "complete", - "suite_checksum": 0 } } diff --git a/docs/website/data/port_status_reports/linux-arm64.json b/docs/website/data/port_status_reports/linux-arm64.json index e2858afe2d2..d0b356331c1 100644 --- a/docs/website/data/port_status_reports/linux-arm64.json +++ b/docs/website/data/port_status_reports/linux-arm64.json @@ -1,14 +1,64 @@ { - "commit": "205dcc675896c1c094db47d9b0a2622adf7ec3bf", - "generated_at": "2026-07-16T12:38:03Z", + "commit": "587813ab1f221046e99996b2c1d1dfcb58f4ac94", + "generated_at": "2026-07-30T19:22:38Z", + "performance": { + "benchmark_version": 1, + "benchmarks": { + "arrayRandom": { + "checksum": "-2288487891715278", + "duration_ns": 170452739 + }, + "arraySequential": { + "checksum": "0", + "duration_ns": 26587685 + }, + "hashMapChurn": { + "checksum": "49941", + "duration_ns": 35381384 + }, + "intArithmetic": { + "checksum": "1307491170054", + "duration_ns": 62814185 + }, + "longArithmetic": { + "checksum": "6887886960473257608", + "duration_ns": 39201326 + }, + "mathTranscendental": { + "checksum": "4729652805076374709", + "duration_ns": 270735594 + }, + "objectAllocation": { + "checksum": "2999790376128", + "duration_ns": 4175763773 + }, + "quicksort": { + "checksum": "809667393311589960", + "duration_ns": 94506846 + }, + "recursion": { + "checksum": "33385282", + "duration_ns": 132546281 + }, + "stringBuilding": { + "checksum": "-609121604069", + "duration_ns": 44943794 + } + }, + "method": "minimum of five measured runs after three in-process warm-ups", + "missing": [], + "skipped": {}, + "status": "complete", + "suite_checksum": 0 + }, "port": "linux-arm64", - "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/29496888305", + "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/30571977091", "schema_version": 1, "suite_finished": false, "summary": { - "fail": 0, - "not-run": 5, - "pass": 165, + "fail": 7, + "not-run": 2, + "pass": 161, "skip": 0 }, "tests": { @@ -46,7 +96,10 @@ }, "AudioMixerApiTest": { "feature": "audio-media-playback", - "status": "pass" + "reasons": [ + "failed: AudioMixer API test failed: java.lang.IllegalStateException: mixed WAV file was not created" + ], + "status": "fail" }, "BackgroundThreadUiAccessTest": { "feature": "threading", @@ -62,7 +115,10 @@ }, "BrowserComponentScreenshotTest": { "feature": "embedded-web-content", - "status": "pass" + "reasons": [ + "failed due to timeout waiting for DONE stage=show-completed" + ], + "status": "fail" }, "ButtonThemeScreenshotTest": { "feature": "native-theme-controls", @@ -72,13 +128,20 @@ "feature": "bytecode-runtime", "status": "pass" }, + "CalendarApiTest": { + "feature": "calendar-integration", + "status": "pass" + }, "CallDetectionAPITest": { "feature": "device-security-signals", "status": "pass" }, "CameraApiTest": { "feature": "camera-access", - "status": "pass" + "reasons": [ + "failed: Camera.getCameras() returned no cameras" + ], + "status": "fail" }, "CenteredDialogTitleScreenshotTest": { "feature": "dialogs-and-labels", @@ -164,11 +227,11 @@ "feature": "graphics-clipping", "status": "pass" }, - "CodeEditorScreenshotTest": { - "feature": "pure-text-editing", + "ClipboardRoundTripTest": { + "feature": "clipboard", "status": "pass" }, - "PureEditorScreenshotTest": { + "CodeEditorScreenshotTest": { "feature": "pure-text-editing", "status": "pass" }, @@ -190,7 +253,10 @@ }, "CryptoApiTest": { "feature": "secure-storage-crypto", - "status": "pass" + "reasons": [ + "failed: Crypto API test failed: com.codename1.security.CryptoException: Crypto operation secureRandomBytes is not supported on this platform. If you are running in a fresh CodenameOneImplementation subclass, override the matching method." + ], + "status": "fail" }, "CssFilterBlurScreenshotTest": { "feature": "theme-palette-css", @@ -266,7 +332,10 @@ }, "FileSystemStorageOpenInputStreamMissingTest": { "feature": "filesystem-storage", - "status": "pass" + "reasons": [ + "failed: openInputStream returned a stream (com.codename1.impl.linux.LinuxInputStream@FF4236CFD0F0) for a missing path /home/runner/.local/share/codenameone/this-file-must-not-exist-1502-1785438836814.bin instead of throwing. Platform=linux" + ], + "status": "fail" }, "FillArc": { "feature": "graphics-primitives", @@ -336,6 +405,10 @@ "feature": "text-input", "status": "pass" }, + "InferenceOnDeviceApiTest": { + "feature": "on-device-inference", + "status": "pass" + }, "InscribedTriangleGrid": { "feature": "graphics-shapes-strokes", "status": "pass" @@ -348,6 +421,10 @@ "feature": "application-bootstrap", "status": "pass" }, + "LanguageOnDeviceApiTest": { + "feature": "on-device-language", + "status": "pass" + }, "LargeStrokeDirtyClipTest": { "feature": "graphics-clipping", "status": "pass" @@ -452,6 +529,10 @@ "feature": "scrolling-and-pull", "status": "pass" }, + "PureEditorScreenshotTest": { + "feature": "pure-text-editing", + "status": "pass" + }, "RealOsmVectorScreenshotTest": { "feature": "vector-maps", "status": "pass" @@ -558,7 +639,10 @@ }, "SurfacesPublishTest": { "feature": "surfaces", - "status": "pass" + "reasons": [ + "failed: Surfaces publish contract failed: java.lang.UnsupportedOperationException: UnicodeHelper.getClasses() not supported" + ], + "status": "fail" }, "SurfacesRasterizerScreenshotTest": { "feature": "surfaces", @@ -610,7 +694,10 @@ }, "TimeApiTest": { "feature": "java-standard-apis", - "status": "pass" + "reasons": [ + "failed: Time API test failed: java.lang.RuntimeException: Expected [2020-03-08T01:30:00-05:00[America/New_York]], Actual [2020-03-08T11:30:00-05:00[America/New_York]]" + ], + "status": "fail" }, "ToastBarTopPositionScreenshotTest": { "feature": "toast-notifications", @@ -666,82 +753,15 @@ }, "VideoIODecodedFramesScreenshotTest": { "feature": "video-decoding", - "status": "pass" + "status": "not-run" }, "VideoIORoundTripTest": { "feature": "video-round-trip", - "status": "pass" - }, - "CalendarApiTest": { - "feature": "calendar-integration", - "status": "not-run" - }, - "ClipboardRoundTripTest": { - "feature": "clipboard", - "status": "not-run" - }, - "InferenceOnDeviceApiTest": { - "feature": "on-device-inference", - "status": "not-run" - }, - "LanguageOnDeviceApiTest": { - "feature": "on-device-language", "status": "not-run" }, "VisionOnDeviceApiTest": { "feature": "on-device-vision", - "status": "not-run" + "status": "pass" } - }, - "bootstrap_source": "successful-master-workflow", - "workflow_conclusion": "success", - "performance": { - "benchmark_version": 1, - "benchmarks": { - "arrayRandom": { - "checksum": "-2288487891715278", - "duration_ns": 167647092 - }, - "arraySequential": { - "checksum": "0", - "duration_ns": 26067574 - }, - "hashMapChurn": { - "checksum": "49941", - "duration_ns": 29352127 - }, - "intArithmetic": { - "checksum": "1307491170054", - "duration_ns": 62775877 - }, - "longArithmetic": { - "checksum": "6887886960473257608", - "duration_ns": 39325844 - }, - "mathTranscendental": { - "checksum": "4729652805076374709", - "duration_ns": 273404082 - }, - "objectAllocation": { - "checksum": "2999790376128", - "duration_ns": 4638409365 - }, - "quicksort": { - "checksum": "809667393311589960", - "duration_ns": 91416037 - }, - "recursion": { - "checksum": "33385282", - "duration_ns": 137627018 - }, - "stringBuilding": { - "checksum": "-609121604069", - "duration_ns": 36440861 - } - }, - "method": "minimum of five measured runs after three in-process warm-ups", - "missing": [], - "status": "complete", - "suite_checksum": 0 } } diff --git a/docs/website/data/port_status_reports/linux-x64.json b/docs/website/data/port_status_reports/linux-x64.json index 91949b47702..765104c021c 100644 --- a/docs/website/data/port_status_reports/linux-x64.json +++ b/docs/website/data/port_status_reports/linux-x64.json @@ -1,14 +1,64 @@ { - "commit": "205dcc675896c1c094db47d9b0a2622adf7ec3bf", - "generated_at": "2026-07-16T12:37:58Z", + "commit": "587813ab1f221046e99996b2c1d1dfcb58f4ac94", + "generated_at": "2026-07-30T19:22:35Z", + "performance": { + "benchmark_version": 1, + "benchmarks": { + "arrayRandom": { + "checksum": "-2288487891715278", + "duration_ns": 264299278 + }, + "arraySequential": { + "checksum": "0", + "duration_ns": 30422427 + }, + "hashMapChurn": { + "checksum": "49941", + "duration_ns": 47879836 + }, + "intArithmetic": { + "checksum": "1307491170054", + "duration_ns": 79764388 + }, + "longArithmetic": { + "checksum": "6887886960473257608", + "duration_ns": 60672355 + }, + "mathTranscendental": { + "checksum": "4729652805076374709", + "duration_ns": 264162804 + }, + "objectAllocation": { + "checksum": "2999790376128", + "duration_ns": 3044145335 + }, + "quicksort": { + "checksum": "809667393311589960", + "duration_ns": 136000546 + }, + "recursion": { + "checksum": "33385282", + "duration_ns": 181468088 + }, + "stringBuilding": { + "checksum": "-609121604069", + "duration_ns": 37993300 + } + }, + "method": "minimum of five measured runs after three in-process warm-ups", + "missing": [], + "skipped": {}, + "status": "complete", + "suite_checksum": 0 + }, "port": "linux-x64", - "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/29496888305", + "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/30571977091", "schema_version": 1, "suite_finished": false, "summary": { - "fail": 0, - "not-run": 5, - "pass": 165, + "fail": 7, + "not-run": 2, + "pass": 161, "skip": 0 }, "tests": { @@ -46,7 +96,10 @@ }, "AudioMixerApiTest": { "feature": "audio-media-playback", - "status": "pass" + "reasons": [ + "failed: AudioMixer API test failed: java.lang.IllegalStateException: mixed WAV file was not created" + ], + "status": "fail" }, "BackgroundThreadUiAccessTest": { "feature": "threading", @@ -62,7 +115,10 @@ }, "BrowserComponentScreenshotTest": { "feature": "embedded-web-content", - "status": "pass" + "reasons": [ + "failed due to timeout waiting for DONE stage=show-completed" + ], + "status": "fail" }, "ButtonThemeScreenshotTest": { "feature": "native-theme-controls", @@ -72,13 +128,20 @@ "feature": "bytecode-runtime", "status": "pass" }, + "CalendarApiTest": { + "feature": "calendar-integration", + "status": "pass" + }, "CallDetectionAPITest": { "feature": "device-security-signals", "status": "pass" }, "CameraApiTest": { "feature": "camera-access", - "status": "pass" + "reasons": [ + "failed: Camera.getCameras() returned no cameras" + ], + "status": "fail" }, "CenteredDialogTitleScreenshotTest": { "feature": "dialogs-and-labels", @@ -164,11 +227,11 @@ "feature": "graphics-clipping", "status": "pass" }, - "CodeEditorScreenshotTest": { - "feature": "pure-text-editing", + "ClipboardRoundTripTest": { + "feature": "clipboard", "status": "pass" }, - "PureEditorScreenshotTest": { + "CodeEditorScreenshotTest": { "feature": "pure-text-editing", "status": "pass" }, @@ -190,7 +253,10 @@ }, "CryptoApiTest": { "feature": "secure-storage-crypto", - "status": "pass" + "reasons": [ + "failed: Crypto API test failed: com.codename1.security.CryptoException: Crypto operation secureRandomBytes is not supported on this platform. If you are running in a fresh CodenameOneImplementation subclass, override the matching method." + ], + "status": "fail" }, "CssFilterBlurScreenshotTest": { "feature": "theme-palette-css", @@ -266,7 +332,10 @@ }, "FileSystemStorageOpenInputStreamMissingTest": { "feature": "filesystem-storage", - "status": "pass" + "reasons": [ + "failed: openInputStream returned a stream (com.codename1.impl.linux.LinuxInputStream@7FF238EAE6B0) for a missing path /home/runner/.local/share/codenameone/this-file-must-not-exist-1502-1785438759604.bin instead of throwing. Platform=linux" + ], + "status": "fail" }, "FillArc": { "feature": "graphics-primitives", @@ -336,6 +405,10 @@ "feature": "text-input", "status": "pass" }, + "InferenceOnDeviceApiTest": { + "feature": "on-device-inference", + "status": "pass" + }, "InscribedTriangleGrid": { "feature": "graphics-shapes-strokes", "status": "pass" @@ -348,6 +421,10 @@ "feature": "application-bootstrap", "status": "pass" }, + "LanguageOnDeviceApiTest": { + "feature": "on-device-language", + "status": "pass" + }, "LargeStrokeDirtyClipTest": { "feature": "graphics-clipping", "status": "pass" @@ -452,6 +529,10 @@ "feature": "scrolling-and-pull", "status": "pass" }, + "PureEditorScreenshotTest": { + "feature": "pure-text-editing", + "status": "pass" + }, "RealOsmVectorScreenshotTest": { "feature": "vector-maps", "status": "pass" @@ -558,7 +639,10 @@ }, "SurfacesPublishTest": { "feature": "surfaces", - "status": "pass" + "reasons": [ + "failed: Surfaces publish contract failed: java.lang.UnsupportedOperationException: UnicodeHelper.getClasses() not supported" + ], + "status": "fail" }, "SurfacesRasterizerScreenshotTest": { "feature": "surfaces", @@ -610,7 +694,10 @@ }, "TimeApiTest": { "feature": "java-standard-apis", - "status": "pass" + "reasons": [ + "failed: Time API test failed: java.lang.RuntimeException: Expected [2020-03-08T01:30:00-05:00[America/New_York]], Actual [2020-03-08T11:30:00-05:00[America/New_York]]" + ], + "status": "fail" }, "ToastBarTopPositionScreenshotTest": { "feature": "toast-notifications", @@ -666,82 +753,15 @@ }, "VideoIODecodedFramesScreenshotTest": { "feature": "video-decoding", - "status": "pass" + "status": "not-run" }, "VideoIORoundTripTest": { "feature": "video-round-trip", - "status": "pass" - }, - "CalendarApiTest": { - "feature": "calendar-integration", - "status": "not-run" - }, - "ClipboardRoundTripTest": { - "feature": "clipboard", - "status": "not-run" - }, - "InferenceOnDeviceApiTest": { - "feature": "on-device-inference", - "status": "not-run" - }, - "LanguageOnDeviceApiTest": { - "feature": "on-device-language", "status": "not-run" }, "VisionOnDeviceApiTest": { "feature": "on-device-vision", - "status": "not-run" + "status": "pass" } - }, - "bootstrap_source": "successful-master-workflow", - "workflow_conclusion": "success", - "performance": { - "benchmark_version": 1, - "benchmarks": { - "arrayRandom": { - "checksum": "-2288487891715278", - "duration_ns": 253856480 - }, - "arraySequential": { - "checksum": "0", - "duration_ns": 28233661 - }, - "hashMapChurn": { - "checksum": "49941", - "duration_ns": 27566288 - }, - "intArithmetic": { - "checksum": "1307491170054", - "duration_ns": 82153005 - }, - "longArithmetic": { - "checksum": "6887886960473257608", - "duration_ns": 53960096 - }, - "mathTranscendental": { - "checksum": "4729652805076374709", - "duration_ns": 184573752 - }, - "objectAllocation": { - "checksum": "2999790376128", - "duration_ns": 5754321719 - }, - "quicksort": { - "checksum": "809667393311589960", - "duration_ns": 148385092 - }, - "recursion": { - "checksum": "33385282", - "duration_ns": 183272160 - }, - "stringBuilding": { - "checksum": "-609121604069", - "duration_ns": 33385972 - } - }, - "method": "minimum of five measured runs after three in-process warm-ups", - "missing": [], - "status": "complete", - "suite_checksum": 0 } } diff --git a/docs/website/data/port_status_reports/mac-native.json b/docs/website/data/port_status_reports/mac-native.json index d7792f7aa4b..3c9527cf45b 100644 --- a/docs/website/data/port_status_reports/mac-native.json +++ b/docs/website/data/port_status_reports/mac-native.json @@ -1,14 +1,64 @@ { - "commit": "205dcc675896c1c094db47d9b0a2622adf7ec3bf", - "generated_at": "2026-07-16T13:13:15Z", + "commit": "587813ab1f221046e99996b2c1d1dfcb58f4ac94", + "generated_at": "2026-07-30T20:36:03Z", + "performance": { + "benchmark_version": 1, + "benchmarks": { + "arrayRandom": { + "checksum": "-2288487891715278", + "duration_ns": 249161000 + }, + "arraySequential": { + "checksum": "0", + "duration_ns": 20743000 + }, + "hashMapChurn": { + "checksum": "49941", + "duration_ns": 58460000 + }, + "intArithmetic": { + "checksum": "1307491170054", + "duration_ns": 106862000 + }, + "longArithmetic": { + "checksum": "6887886960473257608", + "duration_ns": 67221000 + }, + "mathTranscendental": { + "checksum": "4729652805076374709", + "duration_ns": 281716000 + }, + "objectAllocation": { + "checksum": "2999790376128", + "duration_ns": 261799000 + }, + "quicksort": { + "checksum": "809667393311589960", + "duration_ns": 126419000 + }, + "recursion": { + "checksum": "33385282", + "duration_ns": 171015000 + }, + "stringBuilding": { + "checksum": "-609121604069", + "duration_ns": 60775000 + } + }, + "method": "minimum of five measured runs after three in-process warm-ups", + "missing": [], + "skipped": {}, + "status": "complete", + "suite_checksum": 0 + }, "port": "mac-native", - "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/29496889418", + "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/30571977464", "schema_version": 1, "suite_finished": true, "summary": { "fail": 0, - "not-run": 5, - "pass": 163, + "not-run": 0, + "pass": 168, "skip": 2 }, "tests": { @@ -72,6 +122,10 @@ "feature": "bytecode-runtime", "status": "pass" }, + "CalendarApiTest": { + "feature": "calendar-integration", + "status": "pass" + }, "CallDetectionAPITest": { "feature": "device-security-signals", "status": "pass" @@ -167,11 +221,11 @@ "feature": "graphics-clipping", "status": "pass" }, - "CodeEditorScreenshotTest": { - "feature": "pure-text-editing", + "ClipboardRoundTripTest": { + "feature": "clipboard", "status": "pass" }, - "PureEditorScreenshotTest": { + "CodeEditorScreenshotTest": { "feature": "pure-text-editing", "status": "pass" }, @@ -339,6 +393,10 @@ "feature": "text-input", "status": "pass" }, + "InferenceOnDeviceApiTest": { + "feature": "on-device-inference", + "status": "pass" + }, "InscribedTriangleGrid": { "feature": "graphics-shapes-strokes", "status": "pass" @@ -351,6 +409,10 @@ "feature": "application-bootstrap", "status": "pass" }, + "LanguageOnDeviceApiTest": { + "feature": "on-device-language", + "status": "pass" + }, "LargeStrokeDirtyClipTest": { "feature": "graphics-clipping", "status": "pass" @@ -455,6 +517,10 @@ "feature": "scrolling-and-pull", "status": "pass" }, + "PureEditorScreenshotTest": { + "feature": "pure-text-editing", + "status": "pass" + }, "RealOsmVectorScreenshotTest": { "feature": "vector-maps", "status": "pass" @@ -678,76 +744,9 @@ ], "status": "skip" }, - "CalendarApiTest": { - "feature": "calendar-integration", - "status": "not-run" - }, - "ClipboardRoundTripTest": { - "feature": "clipboard", - "status": "not-run" - }, - "InferenceOnDeviceApiTest": { - "feature": "on-device-inference", - "status": "not-run" - }, - "LanguageOnDeviceApiTest": { - "feature": "on-device-language", - "status": "not-run" - }, "VisionOnDeviceApiTest": { "feature": "on-device-vision", - "status": "not-run" + "status": "pass" } - }, - "bootstrap_source": "successful-master-workflow", - "workflow_conclusion": "success", - "performance": { - "benchmark_version": 1, - "benchmarks": { - "arrayRandom": { - "checksum": "-2288487891715278", - "duration_ns": 284063000 - }, - "arraySequential": { - "checksum": "0", - "duration_ns": 20505000 - }, - "hashMapChurn": { - "checksum": "49941", - "duration_ns": 40363000 - }, - "intArithmetic": { - "checksum": "1307491170054", - "duration_ns": 107821000 - }, - "longArithmetic": { - "checksum": "6887886960473257608", - "duration_ns": 65412000 - }, - "mathTranscendental": { - "checksum": "4729652805076374709", - "duration_ns": 286132000 - }, - "objectAllocation": { - "checksum": "2999790376128", - "duration_ns": 328540000 - }, - "quicksort": { - "checksum": "809667393311589960", - "duration_ns": 111802000 - }, - "recursion": { - "checksum": "33385282", - "duration_ns": 167855000 - }, - "stringBuilding": { - "checksum": "-609121604069", - "duration_ns": 24303000 - } - }, - "method": "minimum of five measured runs after three in-process warm-ups", - "missing": [], - "status": "complete", - "suite_checksum": 0 } } diff --git a/docs/website/data/port_status_reports/tvos.json b/docs/website/data/port_status_reports/tvos.json index d9c49c0ec83..6eafc87c9ea 100644 --- a/docs/website/data/port_status_reports/tvos.json +++ b/docs/website/data/port_status_reports/tvos.json @@ -1,14 +1,56 @@ { - "commit": "205dcc675896c1c094db47d9b0a2622adf7ec3bf", - "generated_at": "2026-07-16T13:10:57Z", + "commit": "9c7affa41291e328b08a21d823737f581f1b7d3e", + "generated_at": "2026-07-30T10:23:02Z", + "performance": { + "benchmark_version": 1, + "benchmarks": { + "arrayRandom": { + "checksum": "-2288487891715278", + "duration_ns": 323661000 + }, + "arraySequential": { + "checksum": "0", + "duration_ns": 148797000 + }, + "intArithmetic": { + "checksum": "1307491170054", + "duration_ns": 168944000 + }, + "longArithmetic": { + "checksum": "6887886960473257608", + "duration_ns": 102844000 + }, + "mathTranscendental": { + "checksum": "4729652805076374709", + "duration_ns": 400868000 + }, + "quicksort": { + "checksum": "809667393311589960", + "duration_ns": 324035000 + }, + "recursion": { + "checksum": "33385282", + "duration_ns": 680592000 + } + }, + "method": "minimum of five measured runs after three in-process warm-ups", + "missing": [], + "skipped": { + "hashMapChurn": "ios-simulator-gc-footprint", + "objectAllocation": "ios-simulator-gc-footprint", + "stringBuilding": "ios-simulator-gc-footprint" + }, + "status": "complete", + "suite_checksum": 0 + }, "port": "tvos", - "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/29496889339", + "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/30516173164", "schema_version": 1, "suite_finished": true, "summary": { "fail": 0, - "not-run": 5, - "pass": 163, + "not-run": 0, + "pass": 168, "skip": 2 }, "tests": { @@ -72,6 +114,10 @@ "feature": "bytecode-runtime", "status": "pass" }, + "CalendarApiTest": { + "feature": "calendar-integration", + "status": "pass" + }, "CallDetectionAPITest": { "feature": "device-security-signals", "status": "pass" @@ -167,11 +213,11 @@ "feature": "graphics-clipping", "status": "pass" }, - "CodeEditorScreenshotTest": { - "feature": "pure-text-editing", + "ClipboardRoundTripTest": { + "feature": "clipboard", "status": "pass" }, - "PureEditorScreenshotTest": { + "CodeEditorScreenshotTest": { "feature": "pure-text-editing", "status": "pass" }, @@ -339,6 +385,10 @@ "feature": "text-input", "status": "pass" }, + "InferenceOnDeviceApiTest": { + "feature": "on-device-inference", + "status": "pass" + }, "InscribedTriangleGrid": { "feature": "graphics-shapes-strokes", "status": "pass" @@ -351,6 +401,10 @@ "feature": "application-bootstrap", "status": "pass" }, + "LanguageOnDeviceApiTest": { + "feature": "on-device-language", + "status": "pass" + }, "LargeStrokeDirtyClipTest": { "feature": "graphics-clipping", "status": "pass" @@ -455,6 +509,10 @@ "feature": "scrolling-and-pull", "status": "pass" }, + "PureEditorScreenshotTest": { + "feature": "pure-text-editing", + "status": "pass" + }, "RealOsmVectorScreenshotTest": { "feature": "vector-maps", "status": "pass" @@ -678,76 +736,9 @@ ], "status": "skip" }, - "CalendarApiTest": { - "feature": "calendar-integration", - "status": "not-run" - }, - "ClipboardRoundTripTest": { - "feature": "clipboard", - "status": "not-run" - }, - "InferenceOnDeviceApiTest": { - "feature": "on-device-inference", - "status": "not-run" - }, - "LanguageOnDeviceApiTest": { - "feature": "on-device-language", - "status": "not-run" - }, "VisionOnDeviceApiTest": { "feature": "on-device-vision", - "status": "not-run" + "status": "pass" } - }, - "bootstrap_source": "successful-master-workflow", - "workflow_conclusion": "success", - "performance": { - "benchmark_version": 1, - "benchmarks": { - "arrayRandom": { - "checksum": "-2288487891715278", - "duration_ns": 558066000 - }, - "arraySequential": { - "checksum": "0", - "duration_ns": 209285000 - }, - "hashMapChurn": { - "checksum": "49941", - "duration_ns": 183568000 - }, - "intArithmetic": { - "checksum": "1307491170054", - "duration_ns": 181322000 - }, - "longArithmetic": { - "checksum": "6887886960473257608", - "duration_ns": 109270000 - }, - "mathTranscendental": { - "checksum": "4729652805076374709", - "duration_ns": 727584000 - }, - "objectAllocation": { - "checksum": "2999790376128", - "duration_ns": 266922000 - }, - "quicksort": { - "checksum": "809667393311589960", - "duration_ns": 331650000 - }, - "recursion": { - "checksum": "33385282", - "duration_ns": 718026000 - }, - "stringBuilding": { - "checksum": "-609121604069", - "duration_ns": 154040000 - } - }, - "method": "minimum of five measured runs after three in-process warm-ups", - "missing": [], - "status": "complete", - "suite_checksum": 0 } } diff --git a/docs/website/data/port_status_reports/watchos.json b/docs/website/data/port_status_reports/watchos.json index b2bdbfad26b..e95b838515d 100644 --- a/docs/website/data/port_status_reports/watchos.json +++ b/docs/website/data/port_status_reports/watchos.json @@ -1,14 +1,56 @@ { - "commit": "dec3d172f6fe327798cc083f2bab03a98cf9a8ac", - "generated_at": "2026-07-15T01:51:30Z", + "commit": "9c7affa41291e328b08a21d823737f581f1b7d3e", + "generated_at": "2026-07-30T09:36:29Z", + "performance": { + "benchmark_version": 1, + "benchmarks": { + "arrayRandom": { + "checksum": "-2288487891715278", + "duration_ns": 426870000 + }, + "arraySequential": { + "checksum": "0", + "duration_ns": 193328000 + }, + "intArithmetic": { + "checksum": "1307491170054", + "duration_ns": 200087000 + }, + "longArithmetic": { + "checksum": "6887886960473257608", + "duration_ns": 114148000 + }, + "mathTranscendental": { + "checksum": "4729652805076374709", + "duration_ns": 488586000 + }, + "quicksort": { + "checksum": "809667393311589960", + "duration_ns": 319774000 + }, + "recursion": { + "checksum": "33385282", + "duration_ns": 710894000 + } + }, + "method": "minimum of five measured runs after three in-process warm-ups", + "missing": [], + "skipped": { + "hashMapChurn": "ios-simulator-gc-footprint", + "objectAllocation": "ios-simulator-gc-footprint", + "stringBuilding": "ios-simulator-gc-footprint" + }, + "status": "complete", + "suite_checksum": 0 + }, "port": "watchos", - "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/29380730117", + "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/30516173164", "schema_version": 1, "suite_finished": true, "summary": { "fail": 0, - "not-run": 5, - "pass": 163, + "not-run": 0, + "pass": 168, "skip": 2 }, "tests": { @@ -72,6 +114,10 @@ "feature": "bytecode-runtime", "status": "pass" }, + "CalendarApiTest": { + "feature": "calendar-integration", + "status": "pass" + }, "CallDetectionAPITest": { "feature": "device-security-signals", "status": "pass" @@ -167,11 +213,11 @@ "feature": "graphics-clipping", "status": "pass" }, - "CodeEditorScreenshotTest": { - "feature": "pure-text-editing", + "ClipboardRoundTripTest": { + "feature": "clipboard", "status": "pass" }, - "PureEditorScreenshotTest": { + "CodeEditorScreenshotTest": { "feature": "pure-text-editing", "status": "pass" }, @@ -339,6 +385,10 @@ "feature": "text-input", "status": "pass" }, + "InferenceOnDeviceApiTest": { + "feature": "on-device-inference", + "status": "pass" + }, "InscribedTriangleGrid": { "feature": "graphics-shapes-strokes", "status": "pass" @@ -351,6 +401,10 @@ "feature": "application-bootstrap", "status": "pass" }, + "LanguageOnDeviceApiTest": { + "feature": "on-device-language", + "status": "pass" + }, "LargeStrokeDirtyClipTest": { "feature": "graphics-clipping", "status": "pass" @@ -455,6 +509,10 @@ "feature": "scrolling-and-pull", "status": "pass" }, + "PureEditorScreenshotTest": { + "feature": "pure-text-editing", + "status": "pass" + }, "RealOsmVectorScreenshotTest": { "feature": "vector-maps", "status": "pass" @@ -678,27 +736,9 @@ ], "status": "skip" }, - "CalendarApiTest": { - "feature": "calendar-integration", - "status": "not-run" - }, - "ClipboardRoundTripTest": { - "feature": "clipboard", - "status": "not-run" - }, - "InferenceOnDeviceApiTest": { - "feature": "on-device-inference", - "status": "not-run" - }, - "LanguageOnDeviceApiTest": { - "feature": "on-device-language", - "status": "not-run" - }, "VisionOnDeviceApiTest": { "feature": "on-device-vision", - "status": "not-run" + "status": "pass" } - }, - "bootstrap_source": "successful-master-workflow", - "workflow_conclusion": "success" + } } diff --git a/docs/website/data/port_status_reports/windows-x64.json b/docs/website/data/port_status_reports/windows-x64.json index 1530b2cc8e0..af3bceabef5 100644 --- a/docs/website/data/port_status_reports/windows-x64.json +++ b/docs/website/data/port_status_reports/windows-x64.json @@ -1,15 +1,65 @@ { - "commit": "d17e18eb583d0fa7f433e3ad3e293264475b49ad", - "generated_at": "2026-07-16T17:21:16Z", + "commit": "587813ab1f221046e99996b2c1d1dfcb58f4ac94", + "generated_at": "2026-07-30T19:12:27Z", + "performance": { + "benchmark_version": 1, + "benchmarks": { + "arrayRandom": { + "checksum": "-2288487891715278", + "duration_ns": 206041000 + }, + "arraySequential": { + "checksum": "0", + "duration_ns": 21366000 + }, + "hashMapChurn": { + "checksum": "49941", + "duration_ns": 49883000 + }, + "intArithmetic": { + "checksum": "1307491170054", + "duration_ns": 69671000 + }, + "longArithmetic": { + "checksum": "6887886960473257608", + "duration_ns": 53309000 + }, + "mathTranscendental": { + "checksum": "4729652805076374709", + "duration_ns": 137248000 + }, + "objectAllocation": { + "checksum": "2999790376128", + "duration_ns": 2387343000 + }, + "quicksort": { + "checksum": "809667393311589960", + "duration_ns": 121121000 + }, + "recursion": { + "checksum": "33385282", + "duration_ns": 166653000 + }, + "stringBuilding": { + "checksum": "-609121604069", + "duration_ns": 45653000 + } + }, + "method": "minimum of five measured runs after three in-process warm-ups", + "missing": [], + "skipped": {}, + "status": "complete", + "suite_checksum": 0 + }, "port": "windows-x64", - "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/29515915384", + "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/30571977214", "schema_version": 1, "suite_finished": false, "summary": { - "fail": 0, - "not-run": 5, - "pass": 165, - "skip": 0 + "fail": 7, + "not-run": 2, + "pass": 160, + "skip": 1 }, "tests": { "ARApiTest": { @@ -46,7 +96,10 @@ }, "AudioMixerApiTest": { "feature": "audio-media-playback", - "status": "pass" + "reasons": [ + "failed: AudioMixer API test failed: java.lang.IllegalStateException: mixed WAV file was not created" + ], + "status": "fail" }, "BackgroundThreadUiAccessTest": { "feature": "threading", @@ -72,13 +125,20 @@ "feature": "bytecode-runtime", "status": "pass" }, + "CalendarApiTest": { + "feature": "calendar-integration", + "status": "not-run" + }, "CallDetectionAPITest": { "feature": "device-security-signals", "status": "pass" }, "CameraApiTest": { "feature": "camera-access", - "status": "pass" + "reasons": [ + "needs-runtime-permission-on-win" + ], + "status": "skip" }, "CenteredDialogTitleScreenshotTest": { "feature": "dialogs-and-labels", @@ -164,11 +224,11 @@ "feature": "graphics-clipping", "status": "pass" }, - "CodeEditorScreenshotTest": { - "feature": "pure-text-editing", + "ClipboardRoundTripTest": { + "feature": "clipboard", "status": "pass" }, - "PureEditorScreenshotTest": { + "CodeEditorScreenshotTest": { "feature": "pure-text-editing", "status": "pass" }, @@ -190,7 +250,10 @@ }, "CryptoApiTest": { "feature": "secure-storage-crypto", - "status": "pass" + "reasons": [ + "failed: Crypto API test failed: com.codename1.security.CryptoException: Crypto operation secureRandomBytes is not supported on this platform. If you are running in a fresh CodenameOneImplementation subclass, override the matching method." + ], + "status": "fail" }, "CssFilterBlurScreenshotTest": { "feature": "theme-palette-css", @@ -266,7 +329,10 @@ }, "FileSystemStorageOpenInputStreamMissingTest": { "feature": "filesystem-storage", - "status": "pass" + "reasons": [ + "failed: openInputStream returned a stream (com.codename1.impl.windows.WindowsInputStream@27714760B90) for a missing path C:\\null\\this-file-must-not-exist-1502-1785438531532.bin instead of throwing. Platform=win" + ], + "status": "fail" }, "FillArc": { "feature": "graphics-primitives", @@ -336,6 +402,10 @@ "feature": "text-input", "status": "pass" }, + "InferenceOnDeviceApiTest": { + "feature": "on-device-inference", + "status": "pass" + }, "InscribedTriangleGrid": { "feature": "graphics-shapes-strokes", "status": "pass" @@ -346,6 +416,14 @@ }, "KotlinUiTest": { "feature": "application-bootstrap", + "reasons": [ + "failed=java.lang.NullPointerException", + "failed: java.lang.NullPointerException" + ], + "status": "fail" + }, + "LanguageOnDeviceApiTest": { + "feature": "on-device-language", "status": "pass" }, "LargeStrokeDirtyClipTest": { @@ -452,6 +530,10 @@ "feature": "scrolling-and-pull", "status": "pass" }, + "PureEditorScreenshotTest": { + "feature": "pure-text-editing", + "status": "pass" + }, "RealOsmVectorScreenshotTest": { "feature": "vector-maps", "status": "pass" @@ -558,7 +640,10 @@ }, "SurfacesPublishTest": { "feature": "surfaces", - "status": "pass" + "reasons": [ + "failed: Surfaces publish contract failed: java.lang.UnsupportedOperationException: UnicodeHelper.getClasses() not supported" + ], + "status": "fail" }, "SurfacesRasterizerScreenshotTest": { "feature": "surfaces", @@ -578,7 +663,11 @@ }, "SwitchThemeScreenshotTest": { "feature": "native-theme-controls", - "status": "pass" + "reasons": [ + "failed=java.lang.NullPointerException", + "failed due to timeout waiting for DONE stage=created" + ], + "status": "fail" }, "TabsAnimatedIndicatorScreenshotTest": { "feature": "tabs-animation", @@ -610,7 +699,10 @@ }, "TimeApiTest": { "feature": "java-standard-apis", - "status": "pass" + "reasons": [ + "failed: Time API test failed: java.lang.RuntimeException: Expected [2020-03-08T01:30:00-05:00[America/New_York]], Actual [2020-03-08T06:30:00+01:00[America/New_York]]" + ], + "status": "fail" }, "ToastBarTopPositionScreenshotTest": { "feature": "toast-notifications", @@ -670,78 +762,11 @@ }, "VideoIORoundTripTest": { "feature": "video-round-trip", - "status": "pass" - }, - "CalendarApiTest": { - "feature": "calendar-integration", - "status": "not-run" - }, - "ClipboardRoundTripTest": { - "feature": "clipboard", - "status": "not-run" - }, - "InferenceOnDeviceApiTest": { - "feature": "on-device-inference", - "status": "not-run" - }, - "LanguageOnDeviceApiTest": { - "feature": "on-device-language", "status": "not-run" }, "VisionOnDeviceApiTest": { "feature": "on-device-vision", - "status": "not-run" + "status": "pass" } - }, - "bootstrap_source": "successful-master-workflow", - "workflow_conclusion": "success", - "performance": { - "benchmark_version": 1, - "benchmarks": { - "arrayRandom": { - "checksum": "-2288487891715278", - "duration_ns": 290140000 - }, - "arraySequential": { - "checksum": "0", - "duration_ns": 24989000 - }, - "hashMapChurn": { - "checksum": "49941", - "duration_ns": 61276000 - }, - "intArithmetic": { - "checksum": "1307491170054", - "duration_ns": 78850000 - }, - "longArithmetic": { - "checksum": "6887886960473257608", - "duration_ns": 60657000 - }, - "mathTranscendental": { - "checksum": "4729652805076374709", - "duration_ns": 175326000 - }, - "objectAllocation": { - "checksum": "2999790376128", - "duration_ns": 4421996000 - }, - "quicksort": { - "checksum": "809667393311589960", - "duration_ns": 135399000 - }, - "recursion": { - "checksum": "33385282", - "duration_ns": 242775000 - }, - "stringBuilding": { - "checksum": "-609121604069", - "duration_ns": 32223000 - } - }, - "method": "minimum of five measured runs after three in-process warm-ups", - "missing": [], - "status": "complete", - "suite_checksum": 0 } } diff --git a/docs/website/layouts/_default/port-status.html b/docs/website/layouts/_default/port-status.html index 8b6c9514160..3c736ae1642 100644 --- a/docs/website/layouts/_default/port-status.html +++ b/docs/website/layouts/_default/port-status.html @@ -86,7 +86,8 @@

{{ .name }}

Passed - Partial or skipped + Passed, with a skip the errata account for + Incomplete run or an unexplained skip Failed No current report
@@ -143,7 +144,7 @@

{{ .name }}

{{- range $contract.ports }} {{- $report := index $reports .id -}} - {{- partial "port-status-feature-status.html" (dict "feature" $feature "port" . "report" $report "contract" $contract "now" $snapshotTime) -}} + {{- partial "port-status-feature-status.html" (dict "feature" $feature "port" . "report" $report "contract" $contract "supplement" $supplement "now" $snapshotTime) -}} {{- end }} {{- end }} diff --git a/docs/website/layouts/partials/port-status-feature-status.html b/docs/website/layouts/partials/port-status-feature-status.html index 5d1572ef68e..e33765bb811 100644 --- a/docs/website/layouts/partials/port-status-feature-status.html +++ b/docs/website/layouts/partials/port-status-feature-status.html @@ -2,16 +2,19 @@ {{- $port := .port -}} {{- $report := .report -}} {{- $contract := .contract -}} +{{- $supplement := .supplement -}} {{- $now := .now -}} {{- $state := "unknown" -}} {{- $mark := "?" -}} {{- $label := "No current report" -}} +{{- $documentedSkips := slice -}} {{- if $report -}} {{- $passed := 0 -}} {{- $failed := 0 -}} {{- $skipped := 0 -}} {{- $notRun := 0 -}} {{- $failedTests := slice -}} + {{- $skippedTests := slice -}} {{- range $feature.tests -}} {{- $result := index $report.tests . -}} {{- $status := "not-run" -}} @@ -23,26 +26,52 @@ {{- $failedTests = $failedTests | append . -}} {{- else if eq $status "skip" -}} {{- $skipped = add $skipped 1 -}} + {{- $skippedTests = $skippedTests | append . -}} {{- else -}} {{- $notRun = add $notRun 1 -}} {{- end -}} {{- end -}} + {{- /* A skip only reads as green when the errata below account for it by + name. An undocumented skip stays a partial result. */ -}} + {{- $documented := gt (len $skippedTests) 0 -}} + {{- range $skippedTests -}} + {{- $test := . -}} + {{- $found := false -}} + {{- range $supplement.skip_reasons -}} + {{- if eq .test $test -}}{{- $found = true -}}{{- end -}} + {{- end -}} + {{- if not $found -}}{{- $documented = false -}}{{- end -}} + {{- end -}} {{- $bootstrapComplete := and (eq $report.bootstrap_source "successful-master-workflow") (eq $report.workflow_conclusion "success") -}} {{- $complete := or $report.suite_finished $bootstrapComplete -}} {{- $total := len $feature.tests -}} {{- $state = "partial" -}} {{- $mark = "−" -}} {{- $label = printf "%d passed, %d skipped, %d not run" $passed $skipped $notRun -}} + {{- /* Evidence is per feature. A run that stopped early leaves its own + unreached tests as "not run" below, and the port card reports the + incomplete run; that is not a reason to withdraw the result of a + feature whose every mapped test reported back. */ -}} + {{- $incomplete := cond $complete "" " (the suite run stopped early)" -}} {{- if gt $failed 0 -}} {{- $state = "fail" -}} {{- $mark = "×" -}} {{- $label = printf "%d failed: %s" $failed (delimit $failedTests ", ") -}} - {{- else if not $complete -}} - {{- $label = "Suite did not finish" -}} {{- else if eq $passed $total -}} {{- $state = "pass" -}} {{- $mark = "✓" -}} - {{- $label = printf "All %d mapped test%s passed" $total (cond (eq $total 1) "" "s") -}} + {{- $label = printf "All %d mapped test%s passed%s" $total (cond (eq $total 1) "" "s") $incomplete -}} + {{- else if not $complete -}} + {{- $label = printf "Suite did not finish; %d passed, %d skipped, %d not run" $passed $skipped $notRun -}} + {{- else if and $documented (eq (add $passed $skipped) $total) -}} + {{- $state = "pass" -}} + {{- $mark = "✓" -}} + {{- $documentedSkips = $skippedTests -}} + {{- if eq $passed 0 -}} + {{- $label = printf "%s skipped by the CI environment, see the skipped-test errata" (delimit $skippedTests ", ") -}} + {{- else -}} + {{- $label = printf "%d of %d mapped tests passed; %s skipped by the CI environment, see the skipped-test errata" $passed $total (delimit $skippedTests ", ") -}} + {{- end -}} {{- else if eq $skipped $total -}} {{- $label = "All mapped tests skipped" -}} {{- end -}} @@ -53,12 +82,14 @@ {{- if ne $state "fail" -}} {{- $state = "stale" -}} {{- $mark = "!" -}} + {{- $documentedSkips = slice -}} {{- end -}} {{- $label = printf "Stale report. %s" $label -}} {{- end -}} {{- end -}} {{- $label = printf "%s: %s" $port.name $label -}} - + + {{- if $documentedSkips }}{{ end }} {{ $label }} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/CameraApiTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/CameraApiTest.java index 0deb9eb3548..36ab771359f 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/CameraApiTest.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/CameraApiTest.java @@ -70,14 +70,22 @@ public boolean runTest() { // JavaSE uses its synthetic CameraImpl. The JavaScript Playwright runner // supplies Chromium's fake media device, which still exercises the real // HTML5 getUserMedia/video/canvas/JPEG path. Native mobile ports need an - // OS permission dialog, and Windows does not implement host webcam - // capture yet, so those remain outside this cross-port headless test. + // OS permission dialog, Windows does not implement host webcam capture + // yet, and the native Linux port drives real V4L2 devices through + // GStreamer -- a hosted runner has no camera to enumerate, so the + // assertion chain below would only ever report the absent hardware. + // Those ports stay outside this cross-port headless test and are + // covered by the camera erratum on the port status page. boolean isHeadlessCameraSupported = "HTML5".equals(platform) || (!"ios".equals(platform) && !"and".equals(platform) - && !"win".equals(platform)); + && !"win".equals(platform) + && !"linux".equals(platform)); if (!isHeadlessCameraSupported) { - System.out.println("CN1SS:INFO:test=CameraApiTest status=SKIPPED reason=needs-runtime-permission-on-" + platform); + String reason = "win".equals(platform) ? "no-host-webcam-capture-on-win" + : ("linux".equals(platform) ? "no-camera-device-on-headless-runner" + : "needs-runtime-permission-on-" + platform); + System.out.println("CN1SS:INFO:test=CameraApiTest status=SKIPPED reason=" + reason); done(); return true; } diff --git a/scripts/hellocodenameone/conformance/backfill_port_status.sh b/scripts/hellocodenameone/conformance/backfill_port_status.sh new file mode 100755 index 00000000000..0ccfea5526f --- /dev/null +++ b/scripts/hellocodenameone/conformance/backfill_port_status.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +# +# Copyright (c) 2026, 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. + +set -euo pipefail + +# Publish the newest master report for every port in the compliance contract. +# +# port-status-publish.yml reacts to workflow_run events from the producing +# workflows. Those events are not delivered reliably for every producer: the +# Linux and Windows suites have never landed a single report that way, so the +# public table served a checked-in fallback for them until it aged past the +# staleness threshold and the whole column rendered as unknown. This sweep does +# not depend on an event arriving. It reads the newest completed master run of +# each producing workflow, takes the normalized report it uploaded, and +# publishes it when it is newer than the copy on the data branch. +# +# It finishes by asserting that every port has a report that is inside the +# contract's staleness window, so a producer that stops emitting reports fails +# here instead of quietly rotting on the website. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" +MANIFEST="${REPO_ROOT}/docs/website/data/port_status.json" +DATA_BRANCH="port-status-data" + +for tool in gh jq python3; do + if ! command -v "${tool}" >/dev/null 2>&1; then + echo "backfill-port-status: ${tool} is required." >&2 + exit 2 + fi +done + +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" + +tmp_dir="$(mktemp -d)" +cleanup() { + rm -rf "${tmp_dir}" +} +trap cleanup EXIT + +published=0 +skipped=0 + +# One producing workflow can own several ports (the iOS suite emits four), so +# sweep per workflow and let the report itself name the port it belongs to. +while IFS= read -r workflow; do + # Newest first, and a failed run counts: a suite that fails still uploads the + # normalized report, and a report that records real failures is the result + # the table is supposed to show. + candidates="$(gh run list --workflow "${workflow}" --branch master --limit 40 \ + --json databaseId,event,conclusion,updatedAt \ + --jq '[.[] | select((.event == "push" or .event == "schedule") and + (.conclusion == "success" or .conclusion == "failure"))] + | sort_by(.updatedAt) | reverse | .[0:5] | .[].databaseId')" + if [ -z "${candidates}" ]; then + echo "No completed master run for ${workflow}; nothing to publish." >&2 + continue + fi + + run_id="" + download_dir="${tmp_dir}/${workflow}" + mkdir -p "${download_dir}" + # A run that died before the suite reported uploads no artifact at all, and + # artifacts expire; walk back until one of the recent runs still has reports. + for candidate in ${candidates}; do + if gh run download "${candidate}" --pattern 'port-status-*' --dir "${download_dir}" >/dev/null 2>&1; then + run_id="${candidate}" + break + fi + done + if [ -z "${run_id}" ]; then + echo "No recent ${workflow} run has a port status artifact." >&2 + continue + fi + + while IFS= read -r report; do + port="$(jq -r '.port // empty' "${report}")" + if [ -z "${port}" ]; then + echo "Ignoring ${report}: it names no port." >&2 + continue + fi + generated="$(jq -r '.generated_at // empty' "${report}")" + current="" + if gh api "repos/${GITHUB_REPOSITORY}/contents/ports/${port}.json?ref=${DATA_BRANCH}" \ + --jq '.content' 2>/dev/null | base64 --decode > "${tmp_dir}/current.json" 2>/dev/null; then + current="$(jq -r '.generated_at // empty' "${tmp_dir}/current.json" 2>/dev/null || true)" + fi + if [ -n "${current}" ] && [[ ! "${generated}" > "${current}" ]]; then + skipped=$((skipped + 1)) + continue + fi + echo "Publishing ${port} from run ${run_id} of ${workflow} (${generated})." + PORT_STATUS_PUBLISH=1 "${SCRIPT_DIR}/publish_port_status.sh" "${report}" + published=$((published + 1)) + done < <(find "${download_dir}" -type f -name 'port-status-*.json' | sort) +done < <(jq -r '[.ports[].workflow] | unique | .[]' "${MANIFEST}") + +echo "Port status sweep: published ${published} report(s), ${skipped} already current." + +# Assert the outcome rather than trusting it: a port whose newest published +# report is outside the staleness window renders as unknown on the public +# table, which is exactly the failure this sweep exists to prevent. +stale_days="$(jq -r '.stale_after_days' "${MANIFEST}")" +problems=() +while IFS= read -r port; do + if ! gh api "repos/${GITHUB_REPOSITORY}/contents/ports/${port}.json?ref=${DATA_BRANCH}" \ + --jq '.content' 2>/dev/null | base64 --decode > "${tmp_dir}/check.json" 2>/dev/null; then + problems+=("${port}: no published report") + continue + fi + generated="$(jq -r '.generated_at // empty' "${tmp_dir}/check.json" 2>/dev/null || true)" + age_days="$(python3 - "${generated}" <<'PY' +import sys +from datetime import datetime, timezone + +raw = sys.argv[1] +try: + stamp = datetime.fromisoformat(raw.replace("Z", "+00:00")) +except ValueError: + print(-1) +else: + print(int((datetime.now(timezone.utc) - stamp).total_seconds() // 86400)) +PY +)" + if [ "${age_days}" -lt 0 ]; then + problems+=("${port}: unreadable generated_at ${generated:-}") + elif [ "${age_days}" -gt "${stale_days}" ]; then + problems+=("${port}: last report is ${age_days} days old (limit ${stale_days})") + fi +done < <(jq -r '.ports[].id' "${MANIFEST}") + +if [ ${#problems[@]} -gt 0 ]; then + echo "Ports without a current compliance report:" >&2 + printf ' %s\n' "${problems[@]}" >&2 + echo "Fix the producing workflow; the public table cannot report a port it never hears from." >&2 + exit 1 +fi + +echo "Every port in the contract has a report inside the ${stale_days}-day window." diff --git a/scripts/hellocodenameone/conformance/port_status.py b/scripts/hellocodenameone/conformance/port_status.py index e4e11980c26..70e6d382c15 100755 --- a/scripts/hellocodenameone/conformance/port_status.py +++ b/scripts/hellocodenameone/conformance/port_status.py @@ -25,6 +25,10 @@ ) COMMON_SOURCES = REPO_ROOT / "scripts/hellocodenameone/common/src/main" STRICT_GATE_FAILED = 10 +# "accept" exit codes: the caller keeps the checked-in fallback for both, but +# only an unusable report is a defect worth failing the website build over. +ACCEPT_CONTRACT_DRIFT = 11 +ACCEPT_UNUSABLE = 12 START_RE = re.compile(r"suite starting test=([A-Za-z0-9_]+)") FINISH_RE = re.compile(r"suite finished test=([A-Za-z0-9_]+)") @@ -581,6 +585,98 @@ def strict_report_errors(report: dict) -> list[str]: return errors +def publishable_report_problems( + manifest: dict, port_id: str, report: dict +) -> tuple[list[str], list[str]]: + """Decide whether a persisted report may replace the checked-in fallback. + + Returns (drift, malformed). Drift means the report is well formed but was + produced against a different revision of the test contract, which happens + for every port between the commit that registers a test and that port's + next master run; the caller keeps the checked-in report and waits. Anything + in malformed is a defect in the report or in the producer and must be loud: + silently falling back for those is what lets a whole column of the public + table rot into "stale" while the port itself is healthy. + """ + drift: list[str] = [] + malformed: list[str] = [] + + if report.get("schema_version") != manifest.get("schema_version"): + malformed.append( + f"schema version {report.get('schema_version')!r} is not " + f"{manifest.get('schema_version')!r}" + ) + if report.get("port") != port_id: + malformed.append(f"report identifies port {report.get('port')!r}") + generated_at = report.get("generated_at") + if not isinstance(generated_at, str) or not generated_at: + malformed.append("report has no generated_at timestamp") + + mapped = test_to_feature(manifest) + tests = report.get("tests") + if not isinstance(tests, dict): + malformed.append("report has no test result map") + tests = {} + else: + missing = sorted(set(mapped) - set(tests)) + unknown = sorted(set(tests) - set(mapped)) + if missing: + drift.append("report predates tests: " + ", ".join(missing)) + if unknown: + drift.append("report carries retired tests: " + ", ".join(unknown)) + + statuses = Counter() + for test, result in tests.items(): + if not isinstance(result, dict) or result.get("status") not in { + "pass", "fail", "skip", "not-run" + }: + malformed.append(f"invalid result for {test}") + continue + statuses[result["status"]] += 1 + expected_summary = { + key: statuses.get(key, 0) for key in ("pass", "fail", "skip", "not-run") + } + if report.get("summary") != expected_summary and not drift: + malformed.append("summary does not match the test results") + + expected_benchmarks = manifest.get("performance_benchmarks", []) + performance = report.get("performance") + if not isinstance(performance, dict): + malformed.append("report has no performance section") + return drift, malformed + if performance.get("status") != "complete": + malformed.append(f"performance run is {performance.get('status')!r}") + if performance.get("missing"): + malformed.append( + "performance workloads never reported: " + + ", ".join(performance["missing"]) + ) + + benchmarks = performance.get("benchmarks") + skipped = performance.get("skipped") or {} + if not isinstance(benchmarks, dict) or not isinstance(skipped, dict): + malformed.append("performance results are not objects") + return drift, malformed + + # A port may legitimately skip a workload (the iOS simulator skips the + # GC-footprint workloads); measured plus skipped has to cover the contract. + accounted = sorted(set(benchmarks) | set(skipped)) + if accounted != sorted(expected_benchmarks): + malformed.append( + "performance workloads do not match the contract: " + + ", ".join(accounted) + ) + for name, measurement in benchmarks.items(): + duration = measurement.get("duration_ns") if isinstance(measurement, dict) else None + if isinstance(duration, bool) or not isinstance(duration, int) or duration < 0: + malformed.append(f"{name} has no measured duration") + for name, reason in skipped.items(): + if not isinstance(reason, str) or not reason: + malformed.append(f"skipped workload {name} has no reason") + + return drift, malformed + + def utc_now() -> str: return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") @@ -592,6 +688,13 @@ def build_parser() -> argparse.ArgumentParser: subparsers.add_parser("validate", help="validate feature and screenshot coverage") + accept_parser = subparsers.add_parser( + "accept", + help="decide whether a persisted report may replace the checked-in fallback", + ) + accept_parser.add_argument("--port", required=True) + accept_parser.add_argument("--report", required=True, type=Path) + normalize_parser = subparsers.add_parser("normalize", help="write a normalized port report") normalize_parser.add_argument("--port", required=True) normalize_parser.add_argument("--log", action="append", type=Path, default=[]) @@ -621,6 +724,20 @@ def main() -> int: f"{counts['ports']} ports, {counts['goldens']} golden names." ) return 0 + if args.command == "accept": + drift, malformed = publishable_report_problems( + manifest, args.port, read_json(args.report) + ) + for problem in malformed: + print(f"port-status: {args.port} report is unusable: {problem}", file=sys.stderr) + for problem in drift: + print(f"port-status: {args.port} {problem}", file=sys.stderr) + if malformed: + return ACCEPT_UNUSABLE + if drift: + return ACCEPT_CONTRACT_DRIFT + print(f"{args.port} report accepted.") + return 0 report = normalize( manifest=manifest, port_id=args.port, diff --git a/scripts/hellocodenameone/conformance/test_port_status.py b/scripts/hellocodenameone/conformance/test_port_status.py index b5817f12521..4db22b7469f 100755 --- a/scripts/hellocodenameone/conformance/test_port_status.py +++ b/scripts/hellocodenameone/conformance/test_port_status.py @@ -251,6 +251,103 @@ def test_validate_rejects_inconsistent_stored_report_summary(self): ): port_status.validate(manifest) + def publishable_report(self, port_id, **overrides): + mapped = port_status.test_to_feature(self.manifest) + tests = { + test: {"status": "pass", "feature": feature} + for test, feature in mapped.items() + } + report = { + "schema_version": self.manifest["schema_version"], + "port": port_id, + "generated_at": "2026-07-30T09:24:29Z", + "suite_finished": True, + "summary": {"pass": len(tests), "fail": 0, "skip": 0, "not-run": 0}, + "tests": tests, + "performance": { + "status": "complete", + "benchmark_version": 1, + "missing": [], + "skipped": {}, + "benchmarks": { + benchmark: {"duration_ns": 12000000, "checksum": "42"} + for benchmark in self.manifest["performance_benchmarks"] + }, + }, + } + report.update(overrides) + return report + + def test_publishable_accepts_a_report_that_skips_workloads(self): + # The shape every iOS, tvOS, and watchOS run produces: the simulator + # skips the three GC-footprint workloads and measures the other seven. + report = self.publishable_report("ios-gl") + for benchmark in ("objectAllocation", "hashMapChurn", "stringBuilding"): + del report["performance"]["benchmarks"][benchmark] + report["performance"]["skipped"][benchmark] = "ios-simulator-gc-footprint" + + self.assertEqual(([], []), port_status.publishable_report_problems( + self.manifest, "ios-gl", report + )) + + def test_publishable_accepts_a_documented_test_skip(self): + report = self.publishable_report("android") + report["tests"]["CameraApiTest"]["status"] = "skip" + report["summary"]["pass"] -= 1 + report["summary"]["skip"] += 1 + + self.assertEqual(([], []), port_status.publishable_report_problems( + self.manifest, "android", report + )) + + def test_publishable_separates_contract_drift_from_a_broken_report(self): + report = self.publishable_report("android") + del report["tests"]["CameraApiTest"] + drift, malformed = port_status.publishable_report_problems( + self.manifest, "android", report + ) + self.assertEqual([], malformed) + self.assertIn("CameraApiTest", drift[0]) + + def test_publishable_rejects_unaccounted_and_unmeasured_workloads(self): + report = self.publishable_report("linux-x64") + del report["performance"]["benchmarks"]["quicksort"] + report["performance"]["benchmarks"]["recursion"]["duration_ns"] = None + drift, malformed = port_status.publishable_report_problems( + self.manifest, "linux-x64", report + ) + self.assertEqual([], drift) + self.assertEqual(2, len(malformed), malformed) + self.assertTrue(any("do not match the contract" in item for item in malformed)) + self.assertTrue(any("recursion" in item for item in malformed)) + + def test_publishable_rejects_an_incomplete_or_mislabelled_run(self): + for mutate, expected in ( + (lambda report: report["performance"].update({"status": "partial"}), "partial"), + (lambda report: report["performance"].update({"missing": ["quicksort"]}), "quicksort"), + (lambda report: report.update({"port": "android"}), "android"), + (lambda report: report["summary"].update({"pass": 3}), "summary"), + ): + with self.subTest(expected=expected): + report = self.publishable_report("watchos") + mutate(report) + _, malformed = port_status.publishable_report_problems( + self.manifest, "watchos", report + ) + self.assertTrue(any(expected in item for item in malformed), malformed) + + def test_publishable_matches_every_report_the_site_serves(self): + for port in self.manifest["ports"]: + report_path = port_status.REPO_ROOT / self.manifest["report_directory"] / ( + port["id"] + ".json" + ) + with self.subTest(port=port["id"]): + drift, malformed = port_status.publishable_report_problems( + self.manifest, port["id"], port_status.read_json(report_path) + ) + self.assertEqual([], malformed) + self.assertEqual([], drift) + if __name__ == "__main__": unittest.main() diff --git a/scripts/website/sync_port_status_reports.sh b/scripts/website/sync_port_status_reports.sh index b4fc0fa9b7d..7debbc3c477 100755 --- a/scripts/website/sync_port_status_reports.sh +++ b/scripts/website/sync_port_status_reports.sh @@ -50,29 +50,51 @@ if ! git -C "${REPO_ROOT}" fetch --quiet --no-tags --depth=1 origin "${DATA_REF} fi synced=0 +unusable=0 +fallback=() while IFS= read -r port; do candidate="${tmp_dir}/${port}.json" if ! git -C "${REPO_ROOT}" show "FETCH_HEAD:ports/${port}.json" > "${candidate}" 2>/dev/null; then echo "No persisted ${port} report; keeping the checked-in report." >&2 + fallback+=("${port} (never published)") continue fi - if ! jq -e --arg port "${port}" --slurpfile contract "${MANIFEST}" ' - .schema_version == $contract[0].schema_version and - .port == $port and - ((.tests | keys | sort) == ([$contract[0].features[].tests[]] | sort)) and - .performance.status == "complete" and - ([.performance.benchmarks[].duration_ns | type] | all(. == "number")) and - ([.performance.benchmarks[].duration_ns] | all(. >= 0)) and - ((.performance.benchmarks | keys) == ($contract[0].performance_benchmarks | sort)) - ' "${candidate}" >/dev/null; then - echo "Persisted ${port} report does not match the current contract; keeping the checked-in report." >&2 - continue - fi + # One implementation of "may this report be published", shared with the + # normalizer's own unit tests. Duplicating it here as a jq expression is what + # silently rejected every iOS-family report: those runs legitimately skip + # three GC-footprint workloads, and the copy demanded a measurement for all + # ten, so the site quietly served the checked-in copy until it went stale. + set +e + python3 "${REPO_ROOT}/scripts/hellocodenameone/conformance/port_status.py" \ + accept --port "${port}" --report "${candidate}" + accept_rc=$? + set -e + case "${accept_rc}" in + 0) ;; + 11) + echo "Persisted ${port} report predates the current test contract; keeping the checked-in report." >&2 + fallback+=("${port} (waiting for a run on the current contract)") + continue + ;; + *) + echo "Persisted ${port} report is unusable; keeping the checked-in report." >&2 + fallback+=("${port} (unusable report)") + unusable=$((unusable + 1)) + continue + ;; + esac cp "${candidate}" "${REPORT_DIR}/${port}.json" synced=$((synced + 1)) done < <(jq -r '.ports[].id' "${MANIFEST}") echo "Resolved ${synced} Port Status reports from ${DATA_REF}; remaining ports use checked-in reports." +if [ ${#fallback[@]} -gt 0 ]; then + echo "Ports served from the checked-in fallback: ${fallback[*]}" >&2 +fi +if [ "${unusable}" -gt 0 ]; then + echo "${unusable} persisted report(s) are unusable; fix the producing workflow." >&2 + exit 1 +fi environment_candidate="${tmp_dir}/environment.json" environment_target="${REPO_ROOT}/docs/website/data/port_status_environment.json" diff --git a/scripts/website/validate_port_status.mjs b/scripts/website/validate_port_status.mjs index ffdaee6f18d..04071758648 100644 --- a/scripts/website/validate_port_status.mjs +++ b/scripts/website/validate_port_status.mjs @@ -167,6 +167,23 @@ function validate() { fail("the generated page does not contain exhaustive skipped-test errata"); } + // A cell may only read as a pass while carrying a skip when the errata name + // that exact test, so a green mark can never outrun its explanation. + const notedCells = primaryCellTags.filter((cell) => /\bhas-documented-skip\b/.test(cell)); + if (notedCells.length === 0) { + fail("no cell reports a documented skip; the errata and the table disagree"); + } + for (const cell of notedCells) { + const skips = attribute(cell, "data-documented-skip").split(/\s+/).filter(Boolean); + if (!/\bis-pass\b/.test(attribute(cell, "class")) || skips.length === 0 || + !skips.every((test) => errata.includes(test))) { + fail(`a cell claims a documented skip the errata do not cover: ${cell}`); + } + } + if (countMatches(page, /)/g); const manualCells = countMatches(page, /\bdata-manual-feature-cell(?:=|\s|>)/g); if (manualRows < 20 || manualCells !== manualRows * portCards) { diff --git a/vm/JavaAPI/src/java/lang/Character.java b/vm/JavaAPI/src/java/lang/Character.java index d51b2b7719f..3b7cf1ac461 100644 --- a/vm/JavaAPI/src/java/lang/Character.java +++ b/vm/JavaAPI/src/java/lang/Character.java @@ -1306,30 +1306,70 @@ public static boolean isSurrogate(char ch) { return isHighSurrogate(ch) || isLowSurrogate(ch); } - private static UnicodeHelper.Range[] classMapping; - private static UnicodeHelper.Range[] getClasses() { - throw new UnsupportedOperationException("UnicodeHelper.getClasses() not supported"); - } - + /** + * General category of every ASCII code point, indexed by code point. + * + * The rest of this class is deliberately ASCII-only (isDigit, isLowerCase + * and isUpperCase all answer false above 127), so the category table is + * too. It used to be absent altogether, and getType threw + * UnsupportedOperationException -- which meant isLetter, isLetterOrDigit, + * isJavaIdentifierStart, isJavaIdentifierPart and isIdentifierIgnorable + * threw for every input on the ports that use this runtime, rather than + * answering for the ASCII text they are almost always asked about. + */ + private static final byte[] ASCII_TYPES = { + /* 00-0f */ CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, + CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, + /* 10-1f */ CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, + CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, + /* ' '!"# */ SPACE_SEPARATOR, OTHER_PUNCTUATION, OTHER_PUNCTUATION, OTHER_PUNCTUATION, + /* $%&' */ CURRENCY_SYMBOL, OTHER_PUNCTUATION, OTHER_PUNCTUATION, OTHER_PUNCTUATION, + /* ()*+ */ START_PUNCTUATION, END_PUNCTUATION, OTHER_PUNCTUATION, MATH_SYMBOL, + /* ,-./ */ OTHER_PUNCTUATION, DASH_PUNCTUATION, OTHER_PUNCTUATION, OTHER_PUNCTUATION, + /* 0-7 */ DECIMAL_DIGIT_NUMBER, DECIMAL_DIGIT_NUMBER, DECIMAL_DIGIT_NUMBER, DECIMAL_DIGIT_NUMBER, + DECIMAL_DIGIT_NUMBER, DECIMAL_DIGIT_NUMBER, DECIMAL_DIGIT_NUMBER, DECIMAL_DIGIT_NUMBER, + /* 89:; */ DECIMAL_DIGIT_NUMBER, DECIMAL_DIGIT_NUMBER, OTHER_PUNCTUATION, OTHER_PUNCTUATION, + /* <=>? */ MATH_SYMBOL, MATH_SYMBOL, MATH_SYMBOL, OTHER_PUNCTUATION, + /* @A-G */ OTHER_PUNCTUATION, UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, + UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, + /* H-O */ UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, + UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, + /* P-W */ UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, + UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, + /* XYZ[ */ UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, START_PUNCTUATION, + /* \]^_ */ OTHER_PUNCTUATION, END_PUNCTUATION, MODIFIER_SYMBOL, CONNECTOR_PUNCTUATION, + /* `a-g */ MODIFIER_SYMBOL, LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, + LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, + /* h-o */ LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, + LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, + /* p-w */ LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, + LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, + /* xyz{ */ LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, START_PUNCTUATION, + /* |}~del */ MATH_SYMBOL, END_PUNCTUATION, MATH_SYMBOL, CONTROL + }; + public static int getType(int codePoint) { if (isBmpCodePoint(codePoint) && isSurrogate((char) codePoint)) { return SURROGATE; } - UnicodeHelper.Range[] classes = getClasses(); - int l = 0; - int u = classes.length - 1; - while (l <= u) { - int i = (l + u) / 2; - UnicodeHelper.Range range = classes[i]; - if (codePoint >= range.end) { - l = i + 1; - } else if (codePoint < range.start) { - u = i - 1; - } else { - return range.data[codePoint - range.start]; - } + if (codePoint >= 0 && codePoint < ASCII_TYPES.length) { + return ASCII_TYPES[codePoint]; + } + // Above ASCII this runtime carries no category table, so answer from + // the primitives it does implement instead of failing the call. + if (isLowerCase(codePoint)) { + return LOWERCASE_LETTER; + } + if (isUpperCase(codePoint)) { + return UPPERCASE_LETTER; + } + if (isDigit(codePoint)) { + return DECIMAL_DIGIT_NUMBER; + } + if (isWhitespace(codePoint)) { + return SPACE_SEPARATOR; } - return 0; + return UNASSIGNED; } /** diff --git a/vm/JavaAPI/src/java/time/DateTimeSupport.java b/vm/JavaAPI/src/java/time/DateTimeSupport.java index 29a36927ee8..4935ad1afdd 100644 --- a/vm/JavaAPI/src/java/time/DateTimeSupport.java +++ b/vm/JavaAPI/src/java/time/DateTimeSupport.java @@ -182,13 +182,28 @@ public static LocalDateTime localDateTimeFromInstant(Instant instant, ZoneId zon } public static ZoneOffset offsetFromInstant(Instant instant, ZoneId zone) { + if (zone instanceof ZoneOffset) { + // A fixed offset already is the answer; round-tripping it through + // the host time zone database can only lose or invert it. + return (ZoneOffset) zone; + } TimeZone tz = TimeZoneSupport.toTimeZone(zone); - Calendar cal = newCalendar(tz); - cal.setTime(new Date(instant.toEpochMilli())); - LocalDate localDate = LocalDate.of(cal.get(Calendar.YEAR), cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH)); - LocalTime localTime = LocalTime.of(cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE), cal.get(Calendar.SECOND)); - long localEpochSecond = localDate.toEpochDay() * SECONDS_PER_DAY + localTime.toSecondOfDay(); - return ZoneOffset.ofTotalSeconds((int) (localEpochSecond - instant.getEpochSecond())); + // Ask the zone for its offset at this instant rather than reading the + // fields back out of a Calendar. Calendar reconstructs the local time + // from a raw offset plus a fixed one-hour daylight guess, which loses + // the saving on the desktop ports (Europe/Berlin in June came back as + // UTC), while TimeZone.getOffset consults the platform's own rules. + // The fields below are UTC, which is the reference frame every port's + // getOffset native resolves against. + long epochMilli = instant.toEpochMilli(); + long epochDay = floorDiv(epochMilli, MILLIS_PER_DAY); + int millisOfDay = (int) floorMod(epochMilli, MILLIS_PER_DAY); + LocalDate utcDate = LocalDate.ofEpochDay(epochDay); + // Calendar.SUNDAY is 1 and epoch day 0 was a Thursday. + int dayOfWeek = (int) floorMod(epochDay + 4, 7) + 1; + int offsetMillis = tz.getOffset(1 /* GregorianCalendar.AD */, utcDate.getYear(), + utcDate.getMonthValue() - 1, utcDate.getDayOfMonth(), dayOfWeek, millisOfDay); + return ZoneOffset.ofTotalSeconds(offsetMillis / 1000); } public static SimpleDateFormat newFormat(String pattern, ZoneId zone, Locale locale) { diff --git a/vm/JavaAPI/src/java/util/TimeZone.java b/vm/JavaAPI/src/java/util/TimeZone.java index 2b67656bce2..2fbbaa0cb46 100644 --- a/vm/JavaAPI/src/java/util/TimeZone.java +++ b/vm/JavaAPI/src/java/util/TimeZone.java @@ -167,7 +167,12 @@ public java.lang.String getID(){ public static java.util.TimeZone getTimeZone(final java.lang.String ID){ if(ID != null && ID.equalsIgnoreCase("gmt")) { return GMT; - } else if (ID.equalsIgnoreCase(getTimezoneId())) { + } + TimeZone custom = customTimeZone(ID); + if (custom != null) { + return custom; + } + if (ID.equalsIgnoreCase(getTimezoneId())) { return getDefault(); } else { TimeZone out = new TimeZone() { @@ -204,6 +209,79 @@ public int hashCode() { } } + /** + * Resolves a custom fixed-offset ID -- {@code GMT+2}, {@code GMT-05:00}, + * {@code UTC+01:30} -- to a zone with that raw offset and no daylight + * saving, exactly as {@code java.util.TimeZone} documents them. + * + * These must never reach the host time zone database. A POSIX {@code TZ} + * value inverts the sign of its offset, so handing {@code "GMT-05:00"} to + * {@code tzset()} produced UTC+5 -- java.time converts a ZoneOffset to + * exactly this form, so every OffsetDateTime formatted through a pattern + * came out shifted by twice its offset. Windows is worse: its C runtime + * cannot parse the form at all. + * + * @return the fixed-offset zone, or null when {@code ID} is not a custom ID + */ + private static TimeZone customTimeZone(String ID) { + if (ID == null) { + return null; + } + int index; + if (ID.regionMatches(true, 0, "GMT", 0, 3)) { + index = 3; + } else if (ID.regionMatches(true, 0, "UTC", 0, 3)) { + index = 3; + } else if (ID.regionMatches(true, 0, "UT", 0, 2)) { + index = 2; + } else { + return null; + } + if (index >= ID.length()) { + return new SimpleTimeZone(0, ID); + } + char sign = ID.charAt(index); + if (sign == 'Z' && index + 1 == ID.length()) { + return new SimpleTimeZone(0, ID); + } + if (sign != '+' && sign != '-') { + return null; + } + String digits = ID.substring(index + 1); + int colon = digits.indexOf(':'); + String hourPart = colon < 0 ? digits : digits.substring(0, colon); + String rest = colon < 0 ? "" : digits.substring(colon + 1); + String minutePart = "0"; + String secondPart = "0"; + if (colon < 0) { + // The colon-less forms are hh, hhmm and hhmmss. + if (digits.length() == 4 || digits.length() == 6) { + hourPart = digits.substring(0, 2); + minutePart = digits.substring(2, 4); + secondPart = digits.length() == 6 ? digits.substring(4, 6) : "0"; + } + } else { + int secondColon = rest.indexOf(':'); + minutePart = secondColon < 0 ? rest : rest.substring(0, secondColon); + secondPart = secondColon < 0 ? "0" : rest.substring(secondColon + 1); + } + int hours; + int minutes; + int seconds; + try { + hours = Integer.parseInt(hourPart); + minutes = Integer.parseInt(minutePart); + seconds = Integer.parseInt(secondPart); + } catch (NumberFormatException notCustom) { + return null; + } + if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59 || seconds < 0 || seconds > 59) { + return null; + } + int offset = ((hours * 60 + minutes) * 60 + seconds) * 1000; + return new SimpleTimeZone(sign == '-' ? -offset : offset, ID); + } + /** * Queries if this time zone uses Daylight Savings Time. */ diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java index 83959fbd326..f374f19a818 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java @@ -417,6 +417,14 @@ void capturesHelloSuiteOverWebSocketLinux() throws Exception { // 40-minute hard cap either way. long stableMs = 300_000L; long deadline = System.currentTimeMillis() + 40L * 60 * 1000; + // Screenshot stabilization is a weak completion signal: DesktopMode, + // the VideoIO grid, the VR scene and the 360 panorama all capture + // AFTER the non-rendering API tail, so a slow tail trips the window + // while real screenshot tests are still queued -- the suite is then + // force-killed and every trailing test is reported as never run. + // CN1_REQUIRE_SUITE (as on the Windows arm64 pipeline) demands the + // suite's own completion marker instead. + boolean requireSuite = Boolean.parseBoolean(System.getenv("CN1_REQUIRE_SUITE")); int pngs = 0, lastPngs = -1; long lastChange = System.currentTimeMillis(); while (System.currentTimeMillis() < deadline) { @@ -432,12 +440,18 @@ void capturesHelloSuiteOverWebSocketLinux() throws Exception { } pngs = CleanTargetIntegrationTest.countPngFiles(outDir); if (pngs != lastPngs) { lastPngs = pngs; lastChange = System.currentTimeMillis(); } - if (pngs >= minPngs && (System.currentTimeMillis() - lastChange) >= stableMs) { break; } + if (!requireSuite && pngs >= minPngs + && (System.currentTimeMillis() - lastChange) >= stableMs) { break; } Thread.sleep(3000); } pngs = CleanTargetIntegrationTest.countPngFiles(outDir); - assertTrue(finished.get() || pngs >= minPngs, - "hello suite capture incomplete: pngs=" + pngs + " (need " + minPngs + ")\n" + serverLog); + if (!finished.get()) { + System.out.println("CN1SS:HARNESS: suite never emitted CN1SS:SUITE:FINISHED; pngs=" + pngs + + " -- every test after the last logged one is reported as never run."); + } + assertTrue(finished.get() || (!requireSuite && pngs >= minPngs), + "hello suite capture incomplete: pngs=" + pngs + " (need " + minPngs + ")" + + " suiteFinished=" + finished.get() + "\n" + serverLog); String outEnv = System.getenv("CN1_SHOT_OUTPUT_DIR"); if (outEnv != null) { From 3c58dcc345f79e8bdff7a56394f8e3a93790da8e Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 31 Jul 2026 08:16:27 +0300 Subject: [PATCH 02/91] Address review: validate before publishing, wire the suite gate, fix native semantics - backfill_port_status.sh published whatever the newest run produced. A report built against an older contract passes the freshness check but is rejected by the website sync, so the column would stay on its stale fallback while the sweep reported success. Each artifact now goes through "port_status.py accept" before publication, and the closing assertion re-checks the published file instead of only its timestamp -- which is how windows-arm64's drifted report now surfaces. - CN1_REQUIRE_SUITE is now set by both Linux legs. Left unset, the new branch in the capture harness was unreachable and both jobs kept the screenshot stabilization exit that kills the suite while DesktopMode, the VideoIO grid, the VR scene and the 360 panorama are still queued. - The shared offset lookup passes UTC fields. The POSIX native resolves them with timegm and the JavaScript runtime with Date.UTC, but the iOS native built its NSDate from [NSCalendar currentCalendar], reading them in the device's zone; near a transition that lands on the wrong side of it. It now builds the date in UTC, and no longer drops the hour and second components. - Character.getType collapsed every non-ASCII whitespace code point to SPACE_SEPARATOR. U+2028 and U+2029 are LINE_SEPARATOR and PARAGRAPH_SEPARATOR and U+180E is FORMAT, all of which isWhitespace already treats individually. - The page validator matched the note marker with quoted attributes, which the production build minifies away, so the check failed on CI and passed locally. - java.time/DateTimeSupport.java carries the project header; java.util.TimeZone keeps its Apache Harmony notice and is recorded in the exclusions list. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/linux-build-run.yml | 7 ++++++ Ports/iOSPort/nativeSources/IOSNative.m | 12 ++++++++-- scripts/copyright-header-exclusions.txt | 1 + .../conformance/backfill_port_status.sh | 15 +++++++++++++ scripts/website/validate_port_status.mjs | 4 +++- vm/JavaAPI/src/java/lang/Character.java | 14 +++++++++++- vm/JavaAPI/src/java/time/DateTimeSupport.java | 22 +++++++++++++++++++ 7 files changed, 71 insertions(+), 4 deletions(-) diff --git a/.github/workflows/linux-build-run.yml b/.github/workflows/linux-build-run.yml index 5a14f0bbd57..3be28477197 100644 --- a/.github/workflows/linux-build-run.yml +++ b/.github/workflows/linux-build-run.yml @@ -219,6 +219,12 @@ jobs: # Full app stdout/stderr -- the only evidence when the suite wedges # mid-run (uploaded with the screenshot artifact below). CN1_APP_LOG_TEE: ${{ github.workspace }}/artifacts/linux-port/raw/app-output.log + # Wait for the suite's own completion marker instead of stopping when + # screenshots go quiet. The stabilization exit fired while DesktopMode, + # the VideoIO grid, the VR scene and the 360 panorama were still queued + # behind the slow non-rendering API tail, so the suite was force-killed + # and every trailing test was published as "never run". + CN1_REQUIRE_SUITE: '1' # Build the native ELF (and the demo) against an old glibc for portability. CN1_CC: /usr/local/bin/cn1-zig-cc # After the suite runs, the capture test relinks the same objects into a @@ -320,6 +326,7 @@ jobs: -v "$GITHUB_WORKSPACE":/cn1 -w /cn1 \ -e CN1_SHOT_OUTPUT_DIR=/cn1/artifacts/linux-port/raw-musl \ -e CN1_APP_LOG_TEE=/cn1/artifacts/linux-port/raw-musl/app-output.log \ + -e CN1_REQUIRE_SUITE=1 \ -e LIBGL_ALWAYS_SOFTWARE=1 \ docker.io/library/alpine:3.20 sh -ec ' sed -i "s|^#\(.*/community\)|\1|" /etc/apk/repositories diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index 797d7259761..fcfe7cd9ce7 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -10017,8 +10017,16 @@ JAVA_INT java_util_TimeZone_getTimezoneOffset___java_lang_String_int_int_int_int [comps setDay:day]; [comps setYear:year]; [comps setMonth:month]; - [comps setMinute:timeOfDayMillis/60000]; - NSCalendar* cal = [NSCalendar currentCalendar]; + [comps setHour:timeOfDayMillis/3600000]; + [comps setMinute:(timeOfDayMillis/60000)%60]; + [comps setSecond:(timeOfDayMillis/1000)%60]; + // The caller passes UTC fields -- the POSIX implementation of this native + // resolves them with timegm() -- so build the date in UTC too. Reading them + // in the device's own zone (currentCalendar) moved the instant by the + // device offset, which lands on the wrong side of a transition when the + // requested zone changes offset within that window. + NSCalendar* cal = [NSCalendar calendarWithIdentifier:NSCalendarIdentifierGregorian]; + [cal setTimeZone:[NSTimeZone timeZoneWithAbbreviation:@"UTC"]]; NSDate *date = [cal dateFromComponents:comps]; JAVA_INT result = [tzone secondsFromGMTForDate:date] * 1000; [comps release]; diff --git a/scripts/copyright-header-exclusions.txt b/scripts/copyright-header-exclusions.txt index 4990d7702a7..210cfbf13d6 100644 --- a/scripts/copyright-header-exclusions.txt +++ b/scripts/copyright-header-exclusions.txt @@ -22,3 +22,4 @@ Ports/JavaScriptPort/src/main/webapp/js/videojs/video.min.js | Video.js 7.4.1 an Ports/JavaScriptPort/src/main/webapp/js/videojs/videojs.record.min.css | videojs-record 3.5.0 third-party stylesheet Ports/JavaScriptPort/src/main/webapp/js/videojs/videojs.record.min.js | videojs-record 3.5.0 third-party bundle Ports/JavaScriptPort/src/main/webapp/sw.js | Codename One service-worker adapter containing the UpUp 1.0.0 MIT-licensed service worker +vm/JavaAPI/src/java/util/TimeZone.java | Apache Harmony source retaining its original Apache-2.0 notice diff --git a/scripts/hellocodenameone/conformance/backfill_port_status.sh b/scripts/hellocodenameone/conformance/backfill_port_status.sh index 0ccfea5526f..2f0ded77ee2 100755 --- a/scripts/hellocodenameone/conformance/backfill_port_status.sh +++ b/scripts/hellocodenameone/conformance/backfill_port_status.sh @@ -99,6 +99,14 @@ while IFS= read -r workflow; do echo "Ignoring ${report}: it names no port." >&2 continue fi + # Publish only what the website will actually serve. A report built against + # an older contract passes the freshness check below but is rejected by the + # sync, which would leave the public column on its stale fallback while + # this sweep reported success. + if ! python3 "${SCRIPT_DIR}/port_status.py" accept --port "${port}" --report "${report}"; then + echo "Not publishing the ${port} report from run ${run_id}: it is not usable by the website." >&2 + continue + fi generated="$(jq -r '.generated_at // empty' "${report}")" current="" if gh api "repos/${GITHUB_REPOSITORY}/contents/ports/${port}.json?ref=${DATA_BRANCH}" \ @@ -128,6 +136,13 @@ while IFS= read -r port; do problems+=("${port}: no published report") continue fi + # Freshness alone is not enough: a published report the website rejects + # leaves the column on its checked-in fallback, which is the state this + # sweep exists to detect. + if ! python3 "${SCRIPT_DIR}/port_status.py" accept --port "${port}" --report "${tmp_dir}/check.json" >/dev/null; then + problems+=("${port}: published report is not usable by the website") + continue + fi generated="$(jq -r '.generated_at // empty' "${tmp_dir}/check.json" 2>/dev/null || true)" age_days="$(python3 - "${generated}" <<'PY' import sys diff --git a/scripts/website/validate_port_status.mjs b/scripts/website/validate_port_status.mjs index 04071758648..62418bb169c 100644 --- a/scripts/website/validate_port_status.mjs +++ b/scripts/website/validate_port_status.mjs @@ -180,7 +180,9 @@ function validate() { fail(`a cell claims a documented skip the errata do not cover: ${cell}`); } } - if (countMatches(page, /]*\bcn1-port-status__note\b/g) < notedCells.length) { fail("documented-skip cells must carry a visible note marker"); } diff --git a/vm/JavaAPI/src/java/lang/Character.java b/vm/JavaAPI/src/java/lang/Character.java index 3b7cf1ac461..7ffc234a140 100644 --- a/vm/JavaAPI/src/java/lang/Character.java +++ b/vm/JavaAPI/src/java/lang/Character.java @@ -1356,7 +1356,19 @@ public static int getType(int codePoint) { return ASCII_TYPES[codePoint]; } // Above ASCII this runtime carries no category table, so answer from - // the primitives it does implement instead of failing the call. + // the primitives it does implement instead of failing the call. The + // code points isWhitespace() knows about individually are named here: + // they are not all separators, and the two that are come from + // different categories. + if (codePoint == 0x2028) { + return LINE_SEPARATOR; + } + if (codePoint == 0x2029) { + return PARAGRAPH_SEPARATOR; + } + if (codePoint == 0x180E) { + return FORMAT; + } if (isLowerCase(codePoint)) { return LOWERCASE_LETTER; } diff --git a/vm/JavaAPI/src/java/time/DateTimeSupport.java b/vm/JavaAPI/src/java/time/DateTimeSupport.java index 4935ad1afdd..aef3a8541cb 100644 --- a/vm/JavaAPI/src/java/time/DateTimeSupport.java +++ b/vm/JavaAPI/src/java/time/DateTimeSupport.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2026, 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 java.time; import com.codename1.impl.time.TimeZoneSupport; From a2cb05fce5f03e89640d3d5a4bcd56eea643f2fa Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 31 Jul 2026 08:45:13 +0300 Subject: [PATCH 03/91] Fix the desktop-port file paths the louder IO errors exposed The first CI run on this branch confirmed the time zone, Character.getType and openInputStream fixes -- TimeApiTest, SurfacesPublishTest and FileSystemStorageOpenInputStreamMissingTest all pass on Linux now -- and the new exceptions turned two silent write failures into named ones. - getAppHomePath() returned a bare path on both desktop ports. Android and iOS return it with the file:// scheme, and com.codename1.io.File prepends the app home to any path that lacks the scheme, so new File(fs.getAppHomePath() + "x") resolved to the home directory joined to itself: AudioMixerApiTest was asking to write ".../codenameone//home/runner/.local/share/codenameone/audio-mixer-api-test.wav". Both ports now return the scheme and implement toNativePath. - The Windows port never overrode getAppHomePath at all, so it inherited listFilesystemRoots()[0] + AppName, which is a drive root plus the literal string "null" when no app name is set. It now anchors on the same per-user storage directory the Linux port uses. - cn1StorageDir() created only the leaf directory. A home without an existing ~/.local/share -- a fresh CI runner, or a new account -- left the storage directory absent, so every write into it failed at fopen(); that is why ClipboardRoundTripTest could not create its file. The path is now created component by component. - Both ports record why the last open failed and include it in the exception, so a missing directory is distinguishable from a permission or sharing problem without another CI round trip. Co-Authored-By: Claude Opus 5 (1M context) --- Ports/LinuxPort/nativeSources/cn1_linux_io.c | 47 ++++++++++++++++++- .../impl/linux/LinuxImplementation.java | 23 +++++++-- .../com/codename1/impl/linux/LinuxNative.java | 3 ++ .../nativeSources/cn1_windows_io.c | 14 ++++++ .../impl/windows/WindowsImplementation.java | 39 +++++++++++++-- .../codename1/impl/windows/WindowsNative.java | 3 ++ 6 files changed, 120 insertions(+), 9 deletions(-) diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_io.c b/Ports/LinuxPort/nativeSources/cn1_linux_io.c index 9be20a1d3ed..1e4ef2ff79d 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_io.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_io.c @@ -153,15 +153,35 @@ static const char* cn1JStr(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT s) { /* ------------------------------------------------------------ file io */ +/* Reason the last open failed. The port reports "could not open X" from Java, + * where errno is long gone; without this the only way to tell a missing + * directory from a permission problem was another CI round trip. */ +static char cn1LastIoError[512]; + +static void cn1RecordIoError(const char* path) { + snprintf(cn1LastIoError, sizeof(cn1LastIoError), "%s", strerror(errno)); + (void) path; +} + +JAVA_OBJECT com_codename1_impl_linux_LinuxNative_lastIoError___R_java_lang_String(CODENAME_ONE_THREAD_STATE) { + return newStringFromCString(threadStateData, cn1LastIoError[0] ? cn1LastIoError : "unknown error"); +} + JAVA_LONG com_codename1_impl_linux_LinuxNative_fileOpenRead___java_lang_String_R_long(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT path) { const char* p = cn1JStr(threadStateData, path); FILE* f = p ? fopen(p, "rb") : 0; + if (f == 0) { + cn1RecordIoError(p); + } return (JAVA_LONG) (intptr_t) f; } JAVA_LONG com_codename1_impl_linux_LinuxNative_fileOpenWrite___java_lang_String_boolean_R_long(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT path, JAVA_BOOLEAN append) { const char* p = cn1JStr(threadStateData, path); FILE* f = p ? fopen(p, append ? "ab" : "wb") : 0; + if (f == 0) { + cn1RecordIoError(p); + } return (JAVA_LONG) (intptr_t) f; } @@ -291,6 +311,30 @@ JAVA_OBJECT com_codename1_impl_linux_LinuxNative_fileList___java_lang_String_R_j /* The per-user app storage directory ($XDG_DATA_HOME/codenameone, else * ~/.local/share/codenameone), created on first use. Backs Storage + the * FileSystemStorage app-home. */ +/* Creates every missing component of an absolute path. mkdir(2) only creates + * the leaf, so a home without an existing ~/.local/share -- which is the state + * of a fresh CI runner or a freshly created account -- left the storage + * directory absent. Every write into it then failed at fopen(), which the port + * used to swallow: Storage entries and files written through + * FileSystemStorage were silently discarded. */ +static void cn1MkdirParents(const char* path) { + char work[4096]; + char* p; + size_t len = strlen(path); + if (len == 0 || len >= sizeof(work)) { + return; + } + memcpy(work, path, len + 1); + for (p = work + 1; *p; p++) { + if (*p == '/') { + *p = 0; + mkdir(work, 0755); + *p = '/'; + } + } + mkdir(work, 0755); +} + static const char* cn1StorageDir(void) { static char dir[4096]; if (dir[0] == 0) { @@ -304,9 +348,8 @@ static const char* cn1StorageDir(void) { } else { snprintf(share, sizeof(share), "/tmp"); } - mkdir(share, 0755); snprintf(dir, sizeof(dir), "%s/codenameone", share); - mkdir(dir, 0755); + cn1MkdirParents(dir); } return dir; } diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java index 1d82f0e51b9..a8b4cc0f8ce 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java @@ -2389,7 +2389,8 @@ public InputStream openInputStream(Object connection) throws IOException { // legitimately empty file, so callers could not tell a missing // file from an empty one -- the exact defect issue #1502 // reported against iOS. - throw new FileNotFoundException("No such file: " + path); + throw new FileNotFoundException("No such file: " + path + + " (" + LinuxNative.lastIoError() + ")"); } return new LinuxInputStream(h, false); } @@ -2403,7 +2404,8 @@ public InputStream openInputStream(Object connection) throws IOException { private long openForWrite(String path, boolean append) throws IOException { long h = LinuxNative.fileOpenWrite(path, append); if (h == 0) { - throw new IOException("Unable to open " + path + " for writing"); + throw new IOException("Unable to open " + path + " for writing (" + + LinuxNative.lastIoError() + ")"); } return h; } @@ -2619,7 +2621,8 @@ public InputStream createStorageInputStream(String name) throws IOException { String path = storagePath(name); long h = LinuxNative.fileOpenRead(path); if (h == 0) { - throw new FileNotFoundException("No such storage entry: " + name); + throw new FileNotFoundException("No such storage entry: " + name + + " (" + LinuxNative.lastIoError() + ")"); } return new LinuxInputStream(h, false); } @@ -2649,6 +2652,13 @@ public String[] listFilesystemRoots() { * is exactly why a recorded "tmpaudio.wav" came back as file:///null/tmpaudio.wav * and would not play). Use the same writable per-user directory that Storage and * capturePhoto already rely on. + * + * The result carries the {@code file://} scheme, as it does on Android and + * iOS. {@link com.codename1.io.File} treats any path without that scheme as + * relative to the app home and prepends the home to it, so returning a bare + * path made {@code new File(fs.getAppHomePath() + "x")} resolve to the home + * directory joined to itself -- every write through that class landed on a + * path that could not exist. */ @Override public String getAppHomePath() { @@ -2659,7 +2669,12 @@ public String getAppHomePath() { if (!dir.endsWith("/")) { dir += "/"; } - return dir; + return "file://" + dir; + } + + @Override + public String toNativePath(String path) { + return stripFileUrl(path); } @Override diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java index b8496b7097d..2c195321312 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java @@ -388,6 +388,9 @@ public static native long editStringAt(int x, int y, int w, int h, String text, public static native long fileOpenWrite(String path, boolean append); + /** Why the most recent open failed, for the exception the port raises. */ + public static native String lastIoError(); + public static native int fileRead(long handle, byte[] buffer, int offset, int length); public static native int fileWrite(long handle, byte[] buffer, int offset, int length); diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_io.c b/Ports/WindowsPort/nativeSources/cn1_windows_io.c index 442889295fa..a3a0528d6cb 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_io.c +++ b/Ports/WindowsPort/nativeSources/cn1_windows_io.c @@ -82,6 +82,18 @@ static JAVA_OBJECT cn1WinWideToJavaString(CODENAME_ONE_THREAD_STATE, const WCHAR /* ------------------------------------------------------------------- files */ +/* Reason the last open failed. The port reports "could not open X" from Java, + * where the thread's last-error value is long gone; without this the only way + * to tell a missing directory from a sharing violation was another CI run. */ +static DWORD cn1WinLastIoError; + +JAVA_OBJECT com_codename1_impl_windows_WindowsNative_lastIoError___R_java_lang_String(CODENAME_ONE_THREAD_STATE) { + char buffer[256]; + _snprintf(buffer, sizeof(buffer), "Windows error %lu", (unsigned long) cn1WinLastIoError); + buffer[sizeof(buffer) - 1] = 0; + return newStringFromCString(threadStateData, buffer); +} + JAVA_LONG com_codename1_impl_windows_WindowsNative_fileOpenRead___java_lang_String_R_long(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT __cn1Arg1) { UINT32 len = 0; WCHAR* path = cn1WinJavaStringToWide(threadStateData, __cn1Arg1, &len); @@ -93,6 +105,7 @@ JAVA_LONG com_codename1_impl_windows_WindowsNative_fileOpenRead___java_lang_Stri OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); free(path); if (h == INVALID_HANDLE_VALUE) { + cn1WinLastIoError = GetLastError(); return 0; } return (JAVA_LONG)(intptr_t)h; @@ -119,6 +132,7 @@ JAVA_LONG com_codename1_impl_windows_WindowsNative_fileOpenWrite___java_lang_Str } free(path); if (h == INVALID_HANDLE_VALUE) { + cn1WinLastIoError = GetLastError(); return 0; } return (JAVA_LONG)(intptr_t)h; diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java index da0c0ba313a..d278f5f15e5 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java @@ -2402,7 +2402,8 @@ public InputStream openInputStream(Object connection) throws IOException { // legitimately empty file, so callers could not tell a missing // file from an empty one -- the exact defect issue #1502 // reported against iOS. - throw new FileNotFoundException("No such file: " + path); + throw new FileNotFoundException("No such file: " + path + + " (" + WindowsNative.lastIoError() + ")"); } return new WindowsInputStream(h, false); } @@ -2416,7 +2417,8 @@ public InputStream openInputStream(Object connection) throws IOException { private long openForWrite(String path, boolean append) throws IOException { long h = WindowsNative.fileOpenWrite(path, append); if (h == 0) { - throw new IOException("Unable to open " + path + " for writing"); + throw new IOException("Unable to open " + path + " for writing (" + + WindowsNative.lastIoError() + ")"); } return h; } @@ -2629,7 +2631,8 @@ public InputStream createStorageInputStream(String name) throws IOException { String path = storagePath(name); long h = WindowsNative.fileOpenRead(path); if (h == 0) { - throw new FileNotFoundException("No such storage entry: " + name); + throw new FileNotFoundException("No such storage entry: " + name + + " (" + WindowsNative.lastIoError() + ")"); } return new WindowsInputStream(h, false); } @@ -2649,6 +2652,36 @@ public String[] listFilesystemRoots() { return WindowsNative.fileRoots(); } + /** + * Anchors the app home at the per-user storage directory, with the + * {@code file://} scheme Android, iOS and the Linux port also use. + * + * The inherited implementation builds {@code listFilesystemRoots()[0] + + * AppName}, which here is a drive root plus an app name that is literally + * "null" when neither the AppName property nor a package name is set -- + * every path it produced pointed at an unwritable {@code C:\null\}. The + * scheme matters as well: {@link com.codename1.io.File} prepends the app + * home to any path that lacks it, so a bare path made + * {@code new File(fs.getAppHomePath() + "x")} resolve to the home + * directory joined to itself. + */ + @Override + public String getAppHomePath() { + String dir = WindowsNative.storageDir(); + if (dir == null || dir.length() == 0) { + dir = "."; + } + if (!dir.endsWith("\\") && !dir.endsWith("/")) { + dir += getFileSystemSeparator(); + } + return "file://" + dir; + } + + @Override + public String toNativePath(String path) { + return stripFileUrl(path); + } + @Override public String[] listFiles(String directory) throws IOException { return WindowsNative.fileList(stripFileUrl(directory)); diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java index 7f27de2b42a..9d4bad3ebbb 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java @@ -396,6 +396,9 @@ public static native long editStringAt(int x, int y, int w, int h, String text, public static native long fileOpenWrite(String path, boolean append); + /** Why the most recent open failed, for the exception the port raises. */ + public static native String lastIoError(); + public static native int fileRead(long handle, byte[] buffer, int offset, int length); public static native int fileWrite(long handle, byte[] buffer, int offset, int length); From 8dd4c6a954fc0ec3c0f830f5be62436da06e822d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:06:39 +0300 Subject: [PATCH 04/91] Implement the desktop crypto bridge, and fix the shared UTF-8 buffer misuse Crypto - The Linux port answers the whole com.codename1.security surface through OpenSSL's EVP layer: secure random, AES in GCM/CBC/ECB, RSA with OAEP or PKCS#1, SHA-2 signatures and RSA key generation. Keys cross the boundary in the encodings the portable API documents -- X.509 SubjectPublicKeyInfo and PKCS#8 PrivateKeyInfo -- so d2i_PUBKEY and d2i_PKCS8_PRIV_KEY_INFO do the ASN.1 and nothing parses DER by hand. libcrypto comes with the libcurl the port already links. - The Windows port answers the same surface through CNG, with crypt32 doing the ASN.1 between those DER encodings and BCRYPT_RSAKEY_BLOB. - A failed operation raises rather than returning an empty array: an authentication failure that answered "no bytes" would read as a successful decryption of nothing. GCM keeps the tag appended to the ciphertext, which is the convention the portable API documents. - The OpenSSL implementation was exercised against libcrypto off-device before landing: GCM round trip, tamper and wrong-AAD rejection, CBC with padding, OAEP round trip, and sign/verify including tampered-data and wrong-key rejection. stringToUTF8 aliasing stringToUTF8 returns one buffer per thread and overwrites it on every call, so a native that converted a second String silently repointed the first result at the second string. Five natives in the Linux port did exactly that: - fileRename renamed a file onto itself, which is why WAVWriter's rename step left AudioMixerApiTest without its output; - httpSetHeader sent every request header as "value: value"; - printDocument, showNotification and shareText each collapsed their arguments onto the last one. They now copy through cn1LinuxJStrDup, which the header documents as mandatory for any native converting more than one String. The Windows port was already safe -- its wide-string helper allocates. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/linux-build-run.yml | 4 +- Ports/LinuxPort/nativeSources/cn1_linux.h | 9 + .../nativeSources/cn1_linux_crypto.c | 454 +++++++++++++ Ports/LinuxPort/nativeSources/cn1_linux_io.c | 30 +- Ports/LinuxPort/nativeSources/cn1_linux_net.c | 18 +- .../LinuxPort/nativeSources/cn1_linux_print.c | 32 +- .../nativeSources/cn1_linux_services.c | 25 +- .../impl/linux/LinuxImplementation.java | 78 +++ .../com/codename1/impl/linux/LinuxNative.java | 30 + .../nativeSources/cn1_windows_crypto.c | 640 ++++++++++++++++++ .../impl/windows/WindowsImplementation.java | 78 +++ .../codename1/impl/windows/WindowsNative.java | 30 + .../tools/translator/ByteCodeTranslator.java | 7 +- 13 files changed, 1405 insertions(+), 30 deletions(-) create mode 100644 Ports/LinuxPort/nativeSources/cn1_linux_crypto.c create mode 100644 Ports/WindowsPort/nativeSources/cn1_windows_crypto.c diff --git a/.github/workflows/linux-build-run.yml b/.github/workflows/linux-build-run.yml index 3be28477197..488c69b532b 100644 --- a/.github/workflows/linux-build-run.yml +++ b/.github/workflows/linux-build-run.yml @@ -149,7 +149,7 @@ jobs: cmake ninja-build pkg-config unzip xvfb fonts-dejavu-core \ libgtk-3-dev libcairo2-dev libpango1.0-dev libgdk-pixbuf-2.0-dev libglib2.0-dev \ libfontconfig1-dev libfreetype-dev \ - libcurl4-openssl-dev \ + libcurl4-openssl-dev libssl-dev \ libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev gstreamer1.0-plugins-base gstreamer1.0-plugins-good \ libwebkit2gtk-4.1-dev libsecret-1-dev libnotify-dev libgeoclue-2-dev \ libepoxy-dev libegl1-mesa-dev libgles2-mesa-dev libgl1-mesa-dri @@ -332,7 +332,7 @@ jobs: sed -i "s|^#\(.*/community\)|\1|" /etc/apk/repositories apk add --no-cache build-base cmake samurai pkgconf bash git openjdk8 openjdk17 maven \ gtk+3.0-dev cairo-dev pango-dev gdk-pixbuf-dev glib-dev fontconfig-dev freetype-dev \ - curl-dev libepoxy-dev mesa-dev mesa-gles mesa-egl mesa-gbm mesa-dri-gallium \ + curl-dev openssl-dev libepoxy-dev mesa-dev mesa-gles mesa-egl mesa-gbm mesa-dri-gallium \ webkit2gtk-4.1-dev gstreamer-dev gst-plugins-base-dev \ libsecret-dev libnotify-dev geoclue-dev xvfb ttf-dejavu # JDK 8 runs the translator/maven; JDK 17 is needed to compile the diff --git a/Ports/LinuxPort/nativeSources/cn1_linux.h b/Ports/LinuxPort/nativeSources/cn1_linux.h index 3aec9368d54..56dde128c7c 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux.h +++ b/Ports/LinuxPort/nativeSources/cn1_linux.h @@ -145,6 +145,15 @@ void cn1LinuxLog(const char* message); */ JAVA_OBJECT cn1LinuxNewByteArray(CODENAME_ONE_THREAD_STATE, const void* src, int n); +/* Copy of a Java String's UTF-8 bytes, owned by the caller (free it). + * + * stringToUTF8 hands back one buffer per thread and overwrites it on every + * call, so a native that converts a second String silently repoints the first + * result at the second string. That is not theoretical: it made fileRename + * rename a file onto itself and sent every HTTP request header as + * "value: value". Any native converting more than one String must use this. */ +char* cn1LinuxJStrDup(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT value); + #ifdef __cplusplus } #endif diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c b/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c new file mode 100644 index 00000000000..db518bf43a0 --- /dev/null +++ b/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c @@ -0,0 +1,454 @@ +/* + * Copyright (c) 2026, 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. + */ + +/* + * The crypto half of com.codename1.impl.CodenameOneImplementation, backed by + * OpenSSL's EVP layer (libcrypto, which the port already pulls in through + * libcurl). + * + * Key material crosses this boundary in the same DER encodings the portable + * API documents -- X.509 SubjectPublicKeyInfo for public keys and PKCS#8 + * PrivateKeyInfo for private keys -- so nothing here has to parse ASN.1 by + * hand: d2i_PUBKEY and d2i_PKCS8_PRIV_KEY_INFO do it. + * + * Every entry point answers null (or false) on failure and records a reason + * retrievable through lastCryptoError, which the Java side turns into the + * CryptoException message. A silent empty result would look like a successful + * encryption of nothing. + */ + +#include "cn1_linux.h" +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#define CN1_GCM_TAG_BYTES 16 + +static char cn1CryptoError[512]; + +static void cn1CryptoFail(const char* what) { + unsigned long code = ERR_get_error(); + char detail[256]; + detail[0] = 0; + if (code != 0) { + ERR_error_string_n(code, detail, sizeof(detail)); + } + snprintf(cn1CryptoError, sizeof(cn1CryptoError), "%s%s%s", what, + detail[0] ? ": " : "", detail); + ERR_clear_error(); +} + +JAVA_OBJECT com_codename1_impl_linux_LinuxNative_lastCryptoError___R_java_lang_String(CODENAME_ONE_THREAD_STATE) { + return newStringFromCString(threadStateData, + cn1CryptoError[0] ? cn1CryptoError : "unknown crypto error"); +} + +static const unsigned char* cn1Bytes(JAVA_OBJECT array, int* length) { + if (array == JAVA_NULL) { + *length = 0; + return 0; + } + *length = (int) (*(JAVA_ARRAY) array).length; + return (const unsigned char*) (*(JAVA_ARRAY) array).data; +} + +/* ------------------------------------------------------------ random */ + +JAVA_VOID com_codename1_impl_linux_LinuxNative_secureRandomBytes___byte_1ARRAY(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT out) { + int length = 0; + unsigned char* data = (unsigned char*) cn1Bytes(out, &length); + if (data == 0 || length <= 0) { + return; + } + if (RAND_bytes(data, length) != 1) { + cn1CryptoFail("secure random"); + memset(data, 0, (size_t) length); + } +} + +/* ------------------------------------------------------------ AES */ + +static const EVP_CIPHER* cn1AesCipher(const char* transformation, int keyLength) { + int gcm = strstr(transformation, "/GCM/") != 0; + int ecb = strstr(transformation, "/ECB/") != 0; + switch (keyLength) { + case 16: + return gcm ? EVP_aes_128_gcm() : (ecb ? EVP_aes_128_ecb() : EVP_aes_128_cbc()); + case 24: + return gcm ? EVP_aes_192_gcm() : (ecb ? EVP_aes_192_ecb() : EVP_aes_192_cbc()); + case 32: + return gcm ? EVP_aes_256_gcm() : (ecb ? EVP_aes_256_ecb() : EVP_aes_256_cbc()); + default: + return 0; + } +} + +JAVA_OBJECT com_codename1_impl_linux_LinuxNative_aesCrypt___java_lang_String_boolean_byte_1ARRAY_byte_1ARRAY_byte_1ARRAY_byte_1ARRAY_R_byte_1ARRAY( + CODENAME_ONE_THREAD_STATE, JAVA_OBJECT transformation, JAVA_BOOLEAN encrypt, + JAVA_OBJECT keyArray, JAVA_OBJECT ivArray, JAVA_OBJECT aadArray, JAVA_OBJECT dataArray) { + const char* mode = transformation == JAVA_NULL ? "" : stringToUTF8(threadStateData, transformation); + int keyLength = 0, ivLength = 0, aadLength = 0, dataLength = 0; + const unsigned char* key = cn1Bytes(keyArray, &keyLength); + const unsigned char* iv = cn1Bytes(ivArray, &ivLength); + const unsigned char* aad = cn1Bytes(aadArray, &aadLength); + const unsigned char* data = cn1Bytes(dataArray, &dataLength); + int gcm = strstr(mode, "/GCM/") != 0; + int padded = strstr(mode, "NoPadding") == 0; + const EVP_CIPHER* cipher = cn1AesCipher(mode, keyLength); + EVP_CIPHER_CTX* ctx = 0; + unsigned char* out = 0; + unsigned char tag[CN1_GCM_TAG_BYTES]; + int bodyLength = dataLength; + int outLength = 0, finalLength = 0, discard = 0; + JAVA_OBJECT result = JAVA_NULL; + + if (cipher == 0) { + cn1CryptoFail("unsupported AES key length"); + return JAVA_NULL; + } + if (gcm && !encrypt) { + if (dataLength < CN1_GCM_TAG_BYTES) { + cn1CryptoFail("AES-GCM input is shorter than its authentication tag"); + return JAVA_NULL; + } + bodyLength = dataLength - CN1_GCM_TAG_BYTES; + } + + ctx = EVP_CIPHER_CTX_new(); + if (ctx == 0) { + cn1CryptoFail("cipher context"); + return JAVA_NULL; + } + /* Room for a full trailing block of padding, plus the tag when sealing. */ + out = (unsigned char*) malloc((size_t) bodyLength + EVP_MAX_BLOCK_LENGTH + CN1_GCM_TAG_BYTES); + if (out == 0) { + EVP_CIPHER_CTX_free(ctx); + cn1CryptoFail("out of memory"); + return JAVA_NULL; + } + + if (EVP_CipherInit_ex(ctx, cipher, 0, 0, 0, encrypt ? 1 : 0) != 1) { + goto failed; + } + if (gcm && ivLength > 0 && + EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_AEAD_SET_IVLEN, ivLength, 0) != 1) { + goto failed; + } + if (EVP_CipherInit_ex(ctx, 0, 0, key, ivLength > 0 ? iv : 0, encrypt ? 1 : 0) != 1) { + goto failed; + } + if (EVP_CIPHER_CTX_set_padding(ctx, padded ? 1 : 0) != 1) { + goto failed; + } + if (gcm && aadLength > 0 && EVP_CipherUpdate(ctx, 0, &discard, aad, aadLength) != 1) { + goto failed; + } + if (bodyLength > 0 && EVP_CipherUpdate(ctx, out, &outLength, data, bodyLength) != 1) { + goto failed; + } + if (gcm && !encrypt && + EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_AEAD_SET_TAG, CN1_GCM_TAG_BYTES, + (void*) (data + bodyLength)) != 1) { + goto failed; + } + if (EVP_CipherFinal_ex(ctx, out + outLength, &finalLength) != 1) { + /* For GCM this is the tag check: a tampered message lands here. */ + cn1CryptoFail(gcm && !encrypt ? "AES-GCM authentication failed" : "AES finalize"); + free(out); + EVP_CIPHER_CTX_free(ctx); + return JAVA_NULL; + } + outLength += finalLength; + if (gcm && encrypt) { + if (EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_AEAD_GET_TAG, CN1_GCM_TAG_BYTES, tag) != 1) { + goto failed; + } + memcpy(out + outLength, tag, CN1_GCM_TAG_BYTES); + outLength += CN1_GCM_TAG_BYTES; + } + result = cn1LinuxNewByteArray(threadStateData, out, outLength); + free(out); + EVP_CIPHER_CTX_free(ctx); + return result; + +failed: + cn1CryptoFail("AES"); + free(out); + EVP_CIPHER_CTX_free(ctx); + return JAVA_NULL; +} + +/* ------------------------------------------------------------ keys */ + +static EVP_PKEY* cn1PublicKey(const unsigned char* der, int length) { + const unsigned char* cursor = der; + EVP_PKEY* key = d2i_PUBKEY(0, &cursor, (long) length); + if (key == 0) { + cn1CryptoFail("public key is not X.509 SubjectPublicKeyInfo DER"); + } + return key; +} + +static EVP_PKEY* cn1PrivateKey(const unsigned char* der, int length) { + const unsigned char* cursor = der; + EVP_PKEY* key = 0; + PKCS8_PRIV_KEY_INFO* info = d2i_PKCS8_PRIV_KEY_INFO(0, &cursor, (long) length); + if (info != 0) { + key = EVP_PKCS82PKEY(info); + PKCS8_PRIV_KEY_INFO_free(info); + } + if (key == 0) { + /* Tolerate a bare PKCS#1/SEC1 key as well; some callers keep those. */ + cursor = der; + key = d2i_AutoPrivateKey(0, &cursor, (long) length); + } + if (key == 0) { + cn1CryptoFail("private key is not PKCS#8 DER"); + } + return key; +} + +static int cn1ApplyRsaPadding(EVP_PKEY_CTX* ctx, const char* transformation) { + if (strstr(transformation, "OAEP") != 0) { + const EVP_MD* md = strstr(transformation, "SHA-1") != 0 ? EVP_sha1() : EVP_sha256(); + if (EVP_PKEY_CTX_set_rsa_padding(ctx, RSA_PKCS1_OAEP_PADDING) <= 0 || + EVP_PKEY_CTX_set_rsa_oaep_md(ctx, md) <= 0 || + EVP_PKEY_CTX_set_rsa_mgf1_md(ctx, md) <= 0) { + return 0; + } + return 1; + } + return EVP_PKEY_CTX_set_rsa_padding(ctx, RSA_PKCS1_PADDING) > 0; +} + +JAVA_OBJECT com_codename1_impl_linux_LinuxNative_rsaCrypt___java_lang_String_boolean_byte_1ARRAY_byte_1ARRAY_R_byte_1ARRAY( + CODENAME_ONE_THREAD_STATE, JAVA_OBJECT transformation, JAVA_BOOLEAN encrypt, + JAVA_OBJECT keyArray, JAVA_OBJECT dataArray) { + const char* mode = transformation == JAVA_NULL ? "" : stringToUTF8(threadStateData, transformation); + int keyLength = 0, dataLength = 0; + const unsigned char* keyDer = cn1Bytes(keyArray, &keyLength); + const unsigned char* data = cn1Bytes(dataArray, &dataLength); + EVP_PKEY* key = encrypt ? cn1PublicKey(keyDer, keyLength) : cn1PrivateKey(keyDer, keyLength); + EVP_PKEY_CTX* ctx = 0; + unsigned char* out = 0; + size_t outLength = 0; + JAVA_OBJECT result = JAVA_NULL; + + if (key == 0) { + return JAVA_NULL; + } + ctx = EVP_PKEY_CTX_new(key, 0); + if (ctx == 0) { + cn1CryptoFail("RSA context"); + EVP_PKEY_free(key); + return JAVA_NULL; + } + if ((encrypt ? EVP_PKEY_encrypt_init(ctx) : EVP_PKEY_decrypt_init(ctx)) <= 0 || + !cn1ApplyRsaPadding(ctx, mode)) { + cn1CryptoFail("RSA init"); + goto done; + } + if ((encrypt ? EVP_PKEY_encrypt(ctx, 0, &outLength, data, (size_t) dataLength) + : EVP_PKEY_decrypt(ctx, 0, &outLength, data, (size_t) dataLength)) <= 0) { + cn1CryptoFail("RSA size"); + goto done; + } + out = (unsigned char*) malloc(outLength); + if (out == 0) { + cn1CryptoFail("out of memory"); + goto done; + } + if ((encrypt ? EVP_PKEY_encrypt(ctx, out, &outLength, data, (size_t) dataLength) + : EVP_PKEY_decrypt(ctx, out, &outLength, data, (size_t) dataLength)) <= 0) { + cn1CryptoFail(encrypt ? "RSA encrypt" : "RSA decrypt"); + goto done; + } + result = cn1LinuxNewByteArray(threadStateData, out, (int) outLength); + +done: + free(out); + EVP_PKEY_CTX_free(ctx); + EVP_PKEY_free(key); + return result; +} + +/* ------------------------------------------------------------ signatures */ + +static const EVP_MD* cn1SignatureDigest(const char* algorithm) { + if (strstr(algorithm, "SHA512") != 0 || strstr(algorithm, "SHA-512") != 0) { + return EVP_sha512(); + } + if (strstr(algorithm, "SHA384") != 0 || strstr(algorithm, "SHA-384") != 0) { + return EVP_sha384(); + } + if (strstr(algorithm, "SHA1") != 0 || strstr(algorithm, "SHA-1") != 0) { + return EVP_sha1(); + } + return EVP_sha256(); +} + +JAVA_OBJECT com_codename1_impl_linux_LinuxNative_signData___java_lang_String_byte_1ARRAY_byte_1ARRAY_R_byte_1ARRAY( + CODENAME_ONE_THREAD_STATE, JAVA_OBJECT algorithm, JAVA_OBJECT keyArray, JAVA_OBJECT dataArray) { + const char* name = algorithm == JAVA_NULL ? "" : stringToUTF8(threadStateData, algorithm); + int keyLength = 0, dataLength = 0; + const unsigned char* keyDer = cn1Bytes(keyArray, &keyLength); + const unsigned char* data = cn1Bytes(dataArray, &dataLength); + EVP_PKEY* key = cn1PrivateKey(keyDer, keyLength); + EVP_MD_CTX* ctx = 0; + unsigned char* out = 0; + size_t outLength = 0; + JAVA_OBJECT result = JAVA_NULL; + + if (key == 0) { + return JAVA_NULL; + } + ctx = EVP_MD_CTX_new(); + if (ctx == 0) { + cn1CryptoFail("digest context"); + EVP_PKEY_free(key); + return JAVA_NULL; + } + if (EVP_DigestSignInit(ctx, 0, cn1SignatureDigest(name), 0, key) <= 0 || + EVP_DigestSign(ctx, 0, &outLength, data, (size_t) dataLength) <= 0) { + cn1CryptoFail("sign init"); + goto done; + } + out = (unsigned char*) malloc(outLength); + if (out == 0) { + cn1CryptoFail("out of memory"); + goto done; + } + if (EVP_DigestSign(ctx, out, &outLength, data, (size_t) dataLength) <= 0) { + cn1CryptoFail("sign"); + goto done; + } + result = cn1LinuxNewByteArray(threadStateData, out, (int) outLength); + +done: + free(out); + EVP_MD_CTX_free(ctx); + EVP_PKEY_free(key); + return result; +} + +JAVA_BOOLEAN com_codename1_impl_linux_LinuxNative_verifyData___java_lang_String_byte_1ARRAY_byte_1ARRAY_byte_1ARRAY_R_boolean( + CODENAME_ONE_THREAD_STATE, JAVA_OBJECT algorithm, JAVA_OBJECT keyArray, + JAVA_OBJECT dataArray, JAVA_OBJECT signatureArray) { + const char* name = algorithm == JAVA_NULL ? "" : stringToUTF8(threadStateData, algorithm); + int keyLength = 0, dataLength = 0, signatureLength = 0; + const unsigned char* keyDer = cn1Bytes(keyArray, &keyLength); + const unsigned char* data = cn1Bytes(dataArray, &dataLength); + const unsigned char* signature = cn1Bytes(signatureArray, &signatureLength); + EVP_PKEY* key = cn1PublicKey(keyDer, keyLength); + EVP_MD_CTX* ctx = 0; + JAVA_BOOLEAN result = JAVA_FALSE; + + if (key == 0) { + return JAVA_FALSE; + } + ctx = EVP_MD_CTX_new(); + if (ctx == 0) { + cn1CryptoFail("digest context"); + EVP_PKEY_free(key); + return JAVA_FALSE; + } + if (EVP_DigestVerifyInit(ctx, 0, cn1SignatureDigest(name), 0, key) > 0 && + EVP_DigestVerify(ctx, signature, (size_t) signatureLength, data, (size_t) dataLength) == 1) { + result = JAVA_TRUE; + } else { + /* A rejected signature is a normal answer, not a fault; clear the + * queue so it cannot be reported against a later operation. */ + ERR_clear_error(); + } + EVP_MD_CTX_free(ctx); + EVP_PKEY_free(key); + return result; +} + +/* ------------------------------------------------------------ key pairs */ + +/* Returns the pair as one array: a four-byte big-endian public-key length, + * the X.509 public key, then the PKCS#8 private key. A pair has to come from + * a single call -- two calls would produce two unrelated keys. */ +JAVA_OBJECT com_codename1_impl_linux_LinuxNative_generateRsaKeyPair___int_R_byte_1ARRAY(CODENAME_ONE_THREAD_STATE, JAVA_INT bits) { + EVP_PKEY_CTX* ctx = EVP_PKEY_CTX_new_id(EVP_PKEY_RSA, 0); + EVP_PKEY* key = 0; + PKCS8_PRIV_KEY_INFO* info = 0; + unsigned char* publicDer = 0; + unsigned char* privateDer = 0; + unsigned char* blob = 0; + int publicLength = 0, privateLength = 0; + JAVA_OBJECT result = JAVA_NULL; + + if (ctx == 0) { + cn1CryptoFail("RSA keygen context"); + return JAVA_NULL; + } + if (EVP_PKEY_keygen_init(ctx) <= 0 || + EVP_PKEY_CTX_set_rsa_keygen_bits(ctx, bits) <= 0 || + EVP_PKEY_keygen(ctx, &key) <= 0) { + cn1CryptoFail("RSA keygen"); + goto done; + } + publicLength = i2d_PUBKEY(key, &publicDer); + info = EVP_PKEY2PKCS8(key); + if (info != 0) { + privateLength = i2d_PKCS8_PRIV_KEY_INFO(info, &privateDer); + } + if (publicLength <= 0 || privateLength <= 0) { + cn1CryptoFail("RSA key encoding"); + goto done; + } + blob = (unsigned char*) malloc((size_t) publicLength + (size_t) privateLength + 4); + if (blob == 0) { + cn1CryptoFail("out of memory"); + goto done; + } + blob[0] = (unsigned char) ((publicLength >> 24) & 0xff); + blob[1] = (unsigned char) ((publicLength >> 16) & 0xff); + blob[2] = (unsigned char) ((publicLength >> 8) & 0xff); + blob[3] = (unsigned char) (publicLength & 0xff); + memcpy(blob + 4, publicDer, (size_t) publicLength); + memcpy(blob + 4 + publicLength, privateDer, (size_t) privateLength); + result = cn1LinuxNewByteArray(threadStateData, blob, publicLength + privateLength + 4); + +done: + free(blob); + OPENSSL_free(publicDer); + OPENSSL_free(privateDer); + if (info != 0) { + PKCS8_PRIV_KEY_INFO_free(info); + } + if (key != 0) { + EVP_PKEY_free(key); + } + EVP_PKEY_CTX_free(ctx); + return result; +} diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_io.c b/Ports/LinuxPort/nativeSources/cn1_linux_io.c index 1e4ef2ff79d..86c179e5c86 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_io.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_io.c @@ -78,6 +78,21 @@ void cn1LinuxStubOnce(const char* tag) { fflush(stderr); } +char* cn1LinuxJStrDup(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT value) { + const char* utf8 = value == JAVA_NULL ? 0 : stringToUTF8(threadStateData, value); + char* copy; + size_t length; + if (utf8 == 0) { + return 0; + } + length = strlen(utf8); + copy = (char*) malloc(length + 1); + if (copy != 0) { + memcpy(copy, utf8, length + 1); + } + return copy; +} + JAVA_OBJECT cn1LinuxNewByteArray(CODENAME_ONE_THREAD_STATE, const void* src, int n) { JAVA_OBJECT arr; if (n < 0) { @@ -254,15 +269,22 @@ JAVA_VOID com_codename1_impl_linux_LinuxNative_fileMkdir___java_lang_String(CODE } JAVA_VOID com_codename1_impl_linux_LinuxNative_fileRename___java_lang_String_java_lang_String(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT path, JAVA_OBJECT newName) { - const char* p = cn1JStr(threadStateData, path); - const char* n; + /* Two conversions: copy the first, or stringToUTF8's shared per-thread + * buffer repoints it at the new name and the file is renamed onto itself. */ + char* p = cn1LinuxJStrDup(threadStateData, path); + char* n = 0; char dir[4096]; char dest[4096]; char* slash; if (!p || newName == JAVA_NULL) { + free(p); + return; + } + n = cn1LinuxJStrDup(threadStateData, newName); + if (!n) { + free(p); return; } - n = stringToUTF8(threadStateData, newName); /* newName is a leaf name; rename within the same parent directory. */ strncpy(dir, p, sizeof(dir) - 1); dir[sizeof(dir) - 1] = 0; @@ -274,6 +296,8 @@ JAVA_VOID com_codename1_impl_linux_LinuxNative_fileRename___java_lang_String_jav snprintf(dest, sizeof(dest), "%s", n); } rename(p, dest); + free(p); + free(n); } JAVA_OBJECT com_codename1_impl_linux_LinuxNative_fileList___java_lang_String_R_java_lang_String_1ARRAY(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT path) { diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_net.c b/Ports/LinuxPort/nativeSources/cn1_linux_net.c index e755bb3f3f8..ec740cc852f 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_net.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_net.c @@ -197,16 +197,22 @@ JAVA_VOID com_codename1_impl_linux_LinuxNative_httpSetMethod___long_boolean(CODE JAVA_VOID com_codename1_impl_linux_LinuxNative_httpSetHeader___long_java_lang_String_java_lang_String(CODENAME_ONE_THREAD_STATE, JAVA_LONG connection, JAVA_OBJECT key, JAVA_OBJECT value) { CN1Http* c = (CN1Http*) (intptr_t) connection; - const char* k; - const char* v; + char* k; + char* v; char line[8192]; if (!c || key == JAVA_NULL) { return; } - k = stringToUTF8(threadStateData, key); - v = value == JAVA_NULL ? "" : stringToUTF8(threadStateData, value); - snprintf(line, sizeof(line), "%s: %s", k, v); - c->reqHeaders = curl_slist_append(c->reqHeaders, line); + /* Copy the name: converting the value reuses stringToUTF8's per-thread + * buffer, which otherwise sent every header as "value: value". */ + k = cn1LinuxJStrDup(threadStateData, key); + v = value == JAVA_NULL ? 0 : cn1LinuxJStrDup(threadStateData, value); + if (k != 0) { + snprintf(line, sizeof(line), "%s: %s", k, v == 0 ? "" : v); + c->reqHeaders = curl_slist_append(c->reqHeaders, line); + } + free(k); + free(v); } JAVA_INT com_codename1_impl_linux_LinuxNative_httpResponseCode___long_R_int(CODENAME_ONE_THREAD_STATE, JAVA_LONG connection) { diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_print.c b/Ports/LinuxPort/nativeSources/cn1_linux_print.c index b9a6671ab82..2fd8b562478 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_print.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_print.c @@ -123,24 +123,36 @@ static int cn1PrintViaLp(const char* path, const char* job) { JAVA_INT com_codename1_impl_linux_LinuxNative_printDocument___java_lang_String_java_lang_String_java_lang_String_R_int(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT path, JAVA_OBJECT mimeType, JAVA_OBJECT jobName) { CN1PrintReq req; + /* Each conversion overwrites stringToUTF8's per-thread buffer, so all + * three have to be copied before any of them is read. */ + char* pathCopy = cn1LinuxJStrDup(threadStateData, path); + char* mimeCopy = cn1LinuxJStrDup(threadStateData, mimeType); + char* jobCopy = cn1LinuxJStrDup(threadStateData, jobName); + int result; cn1PrintError[0] = 0; - req.path = path == JAVA_NULL ? 0 : stringToUTF8(threadStateData, path); - req.mime = mimeType == JAVA_NULL ? "" : stringToUTF8(threadStateData, mimeType); - req.job = jobName == JAVA_NULL ? 0 : stringToUTF8(threadStateData, jobName); + req.path = pathCopy; + req.mime = mimeCopy == 0 ? "" : mimeCopy; + req.job = jobCopy; req.result = 2; if (!req.path) { snprintf(cn1PrintError, sizeof(cn1PrintError), "null path"); + free(pathCopy); + free(mimeCopy); + free(jobCopy); return 2; } - if (strncmp(req.mime, "image", 5) == 0) { - if (cn1LinuxWindowWidget() == 0) { - return cn1PrintViaLp(req.path, req.job); /* headless: no dialog */ - } + if (strncmp(req.mime, "image", 5) == 0 && cn1LinuxWindowWidget() != 0) { cn1LinuxRunOnMainAndWait(cn1PrintImageOnMain, &req); - return req.result; + result = req.result; + } else { + /* Headless images and everything else: hand to CUPS, which + * rasterizes natively. */ + result = cn1PrintViaLp(req.path, req.job); } - /* PDF and everything else: hand to CUPS, which rasterizes natively. */ - return cn1PrintViaLp(req.path, req.job); + free(pathCopy); + free(mimeCopy); + free(jobCopy); + return result; } JAVA_OBJECT com_codename1_impl_linux_LinuxNative_printLastError___R_java_lang_String(CODENAME_ONE_THREAD_STATE) { diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_services.c b/Ports/LinuxPort/nativeSources/cn1_linux_services.c index 131b22dc06e..d0ea40baac4 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_services.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_services.c @@ -389,10 +389,18 @@ JAVA_VOID com_codename1_impl_linux_LinuxNative_showNotification___java_lang_Stri if (!cn1LoadNotify()) { return; } - r.id = id == JAVA_NULL ? "" : stringToUTF8(threadStateData, id); - r.title = title == JAVA_NULL ? "" : stringToUTF8(threadStateData, title); - r.body = body == JAVA_NULL ? "" : stringToUTF8(threadStateData, body); + /* Copy each: stringToUTF8 reuses one buffer per thread, so converting the + * title would otherwise repoint the id at it, and the body at both. */ + char* idCopy = cn1LinuxJStrDup(threadStateData, id); + char* titleCopy = cn1LinuxJStrDup(threadStateData, title); + char* bodyCopy = cn1LinuxJStrDup(threadStateData, body); + r.id = idCopy == 0 ? "" : idCopy; + r.title = titleCopy == 0 ? "" : titleCopy; + r.body = bodyCopy == 0 ? "" : bodyCopy; cn1LinuxRunOnMainAndWait(cn1ShowNotifyOnMain, &r); + free(idCopy); + free(titleCopy); + free(bodyCopy); } JAVA_OBJECT com_codename1_impl_linux_LinuxNative_notificationPollClicked___R_java_lang_String(CODENAME_ONE_THREAD_STATE) { @@ -553,15 +561,18 @@ JAVA_BOOLEAN com_codename1_impl_linux_LinuxNative_shareText___java_lang_String_j /* No universal share sheet on the Linux desktop (xdg-desktop-portal's Share * is not yet broadly available); fall back to composing a mail draft via the * default mailto handler, which is the closest portable "share". */ - const char* t = text == JAVA_NULL ? "" : stringToUTF8(threadStateData, text); - const char* subj = title == JAVA_NULL ? "" : stringToUTF8(threadStateData, title); - char* body = g_uri_escape_string(t, NULL, FALSE); - char* s = g_uri_escape_string(subj, NULL, FALSE); + /* Copy the text before converting the title -- they share one buffer. */ + char* t = cn1LinuxJStrDup(threadStateData, text); + char* subj = cn1LinuxJStrDup(threadStateData, title); + char* body = g_uri_escape_string(t == 0 ? "" : t, NULL, FALSE); + char* s = g_uri_escape_string(subj == 0 ? "" : subj, NULL, FALSE); char* uri = g_strconcat("mailto:?subject=", s, "&body=", body, NULL); gboolean ok = g_app_info_launch_default_for_uri(uri, NULL, NULL); g_free(body); g_free(s); g_free(uri); + free(t); + free(subj); return ok ? JAVA_TRUE : JAVA_FALSE; } diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java index a8b4cc0f8ce..6bc862cee74 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java @@ -2737,6 +2737,84 @@ public char getFileSystemSeparator() { return '/'; } + /* ------------------------------------------------------------ crypto */ + + /** + * The crypto bridge, backed by OpenSSL. Every failure is reported as a + * RuntimeException carrying the library's own reason, which + * {@code com.codename1.security.Cipher} turns into a CryptoException -- + * an authentication failure has to be an exception rather than an empty + * result, or a tampered message would read as an empty plaintext. + */ + private static byte[] cryptoResult(byte[] value, String operation) { + if (value == null) { + throw new RuntimeException(operation + " failed: " + LinuxNative.lastCryptoError()); + } + return value; + } + + @Override + public void secureRandomBytes(byte[] out) { + if (out != null && out.length > 0) { + LinuxNative.secureRandomBytes(out); + } + } + + @Override + public byte[] aesEncrypt(String transformation, byte[] key, byte[] iv, byte[] aad, byte[] plaintext) { + return cryptoResult(LinuxNative.aesCrypt(transformation, true, key, iv, aad, plaintext), + "AES encrypt"); + } + + @Override + public byte[] aesDecrypt(String transformation, byte[] key, byte[] iv, byte[] aad, byte[] ciphertext) { + return cryptoResult(LinuxNative.aesCrypt(transformation, false, key, iv, aad, ciphertext), + "AES decrypt"); + } + + @Override + public byte[] rsaEncrypt(String transformation, byte[] publicKeyX509, byte[] plaintext) { + return cryptoResult(LinuxNative.rsaCrypt(transformation, true, publicKeyX509, plaintext), + "RSA encrypt"); + } + + @Override + public byte[] rsaDecrypt(String transformation, byte[] privateKeyPkcs8, byte[] ciphertext) { + return cryptoResult(LinuxNative.rsaCrypt(transformation, false, privateKeyPkcs8, ciphertext), + "RSA decrypt"); + } + + @Override + public byte[] cryptoSign(String algorithm, String keyAlgorithm, byte[] privateKeyPkcs8, byte[] data) { + // The digest and the key type both follow from the algorithm name and + // the DER key itself, so keyAlgorithm adds nothing here. + return cryptoResult(LinuxNative.signData(algorithm, privateKeyPkcs8, data), "sign"); + } + + @Override + public boolean cryptoVerify(String algorithm, String keyAlgorithm, byte[] publicKeyX509, + byte[] data, byte[] signature) { + return LinuxNative.verifyData(algorithm, publicKeyX509, data, signature); + } + + @Override + public byte[][] generateRsaKeyPair(int bits) { + byte[] blob = cryptoResult(LinuxNative.generateRsaKeyPair(bits), "RSA key generation"); + if (blob.length < 4) { + throw new RuntimeException("RSA key generation returned a truncated pair"); + } + int publicLength = ((blob[0] & 0xff) << 24) | ((blob[1] & 0xff) << 16) + | ((blob[2] & 0xff) << 8) | (blob[3] & 0xff); + if (publicLength < 0 || publicLength > blob.length - 4) { + throw new RuntimeException("RSA key generation returned a malformed pair"); + } + byte[] publicKey = new byte[publicLength]; + byte[] privateKey = new byte[blob.length - 4 - publicLength]; + System.arraycopy(blob, 4, publicKey, 0, publicKey.length); + System.arraycopy(blob, 4 + publicLength, privateKey, 0, privateKey.length); + return new byte[][] { publicKey, privateKey }; + } + /* ------------------------------------------------------------ platform */ @Override diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java index 2c195321312..79f66a38ff2 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java @@ -391,6 +391,36 @@ public static native long editStringAt(int x, int y, int w, int h, String text, /** Why the most recent open failed, for the exception the port raises. */ public static native String lastIoError(); + /* ---------------------------------------------------------- crypto */ + + public static native void secureRandomBytes(byte[] out); + + /** + * AES in the mode named by {@code transformation}. For GCM the + * authentication tag is appended to the ciphertext, which is the + * convention the portable API documents. + */ + public static native byte[] aesCrypt(String transformation, boolean encrypt, + byte[] key, byte[] iv, byte[] aad, byte[] data); + + /** RSA with an X.509 public key when encrypting, PKCS#8 when decrypting. */ + public static native byte[] rsaCrypt(String transformation, boolean encrypt, + byte[] key, byte[] data); + + public static native byte[] signData(String algorithm, byte[] privateKeyPkcs8, byte[] data); + + public static native boolean verifyData(String algorithm, byte[] publicKeyX509, + byte[] data, byte[] signature); + + /** + * A fresh RSA pair as one array: a four-byte big-endian public-key length, + * the X.509 public key, then the PKCS#8 private key. + */ + public static native byte[] generateRsaKeyPair(int bits); + + /** Why the most recent crypto call failed, for the CryptoException message. */ + public static native String lastCryptoError(); + public static native int fileRead(long handle, byte[] buffer, int offset, int length); public static native int fileWrite(long handle, byte[] buffer, int offset, int length); diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c new file mode 100644 index 00000000000..b278a53f2c9 --- /dev/null +++ b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c @@ -0,0 +1,640 @@ +/* + * Copyright (c) 2026, 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. + */ + +/* + * The crypto half of com.codename1.impl.CodenameOneImplementation on Windows. + * + * CNG (bcrypt) provides the primitives and crypt32 the ASN.1: key material + * crosses this boundary in the encodings the portable API documents -- X.509 + * SubjectPublicKeyInfo and PKCS#8 PrivateKeyInfo -- and CryptDecodeObjectEx / + * CryptEncodeObjectEx translate those to and from the BCRYPT_RSAKEY_BLOB form + * bcrypt wants, so no ASN.1 is parsed here by hand. + * + * Every entry point answers null (or false) on failure and records the status + * for lastCryptoError, which the Java side turns into the CryptoException + * message. A silent empty result would look like a successful encryption of + * nothing. + */ + +#include "cn1_windows.h" +#include +#include +#include +#include +#include +#include + +#ifndef STATUS_SUCCESS +#define STATUS_SUCCESS ((NTSTATUS) 0x00000000L) +#endif +#ifndef STATUS_AUTH_TAG_MISMATCH +#define STATUS_AUTH_TAG_MISMATCH ((NTSTATUS) 0xC000A002L) +#endif + +#define CN1_GCM_TAG_BYTES 16 + +static char cn1WinCryptoError[512]; + +static void cn1CryptoFail(const char* what, NTSTATUS status) { + _snprintf(cn1WinCryptoError, sizeof(cn1WinCryptoError), "%s (status 0x%08lx)", what, + (unsigned long) status); + cn1WinCryptoError[sizeof(cn1WinCryptoError) - 1] = 0; +} + +static void cn1CryptoFailLast(const char* what) { + cn1CryptoFail(what, (NTSTATUS) GetLastError()); +} + +JAVA_OBJECT com_codename1_impl_windows_WindowsNative_lastCryptoError___R_java_lang_String(CODENAME_ONE_THREAD_STATE) { + return newStringFromCString(threadStateData, + cn1WinCryptoError[0] ? cn1WinCryptoError : "unknown crypto error"); +} + +/* The Windows port has no shared byte-array helper, so keep a local one that + * matches how the rest of the port allocates arrays. */ +static JAVA_OBJECT cn1WinNewByteArray(CODENAME_ONE_THREAD_STATE, const void* src, int n) { + JAVA_OBJECT array; + if (n < 0) { + n = 0; + } + array = allocArray(threadStateData, n, &class_array1__JAVA_BYTE, sizeof(JAVA_ARRAY_BYTE), 1); + if (array != JAVA_NULL && n > 0 && src != 0) { + memcpy((*(JAVA_ARRAY) array).data, src, (size_t) n); + } + return array; +} + +static unsigned char* cn1Bytes(JAVA_OBJECT array, int* length) { + if (array == JAVA_NULL) { + *length = 0; + return 0; + } + *length = (int) (*(JAVA_ARRAY) array).length; + return (unsigned char*) (*(JAVA_ARRAY) array).data; +} + +/* ------------------------------------------------------------ random */ + +JAVA_VOID com_codename1_impl_windows_WindowsNative_secureRandomBytes___byte_1ARRAY(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT out) { + int length = 0; + unsigned char* data = cn1Bytes(out, &length); + NTSTATUS status; + if (data == 0 || length <= 0) { + return; + } + status = BCryptGenRandom(NULL, data, (ULONG) length, BCRYPT_USE_SYSTEM_PREFERRED_RNG); + if (status != STATUS_SUCCESS) { + cn1CryptoFail("secure random", status); + memset(data, 0, (size_t) length); + } +} + +/* ------------------------------------------------------------ AES */ + +JAVA_OBJECT com_codename1_impl_windows_WindowsNative_aesCrypt___java_lang_String_boolean_byte_1ARRAY_byte_1ARRAY_byte_1ARRAY_byte_1ARRAY_R_byte_1ARRAY( + CODENAME_ONE_THREAD_STATE, JAVA_OBJECT transformation, JAVA_BOOLEAN encrypt, + JAVA_OBJECT keyArray, JAVA_OBJECT ivArray, JAVA_OBJECT aadArray, JAVA_OBJECT dataArray) { + const char* mode = transformation == JAVA_NULL ? "" : stringToUTF8(threadStateData, transformation); + int keyLength = 0, ivLength = 0, aadLength = 0, dataLength = 0; + unsigned char* key = cn1Bytes(keyArray, &keyLength); + unsigned char* iv = cn1Bytes(ivArray, &ivLength); + unsigned char* aad = cn1Bytes(aadArray, &aadLength); + unsigned char* data = cn1Bytes(dataArray, &dataLength); + int gcm = strstr(mode, "/GCM/") != 0; + int ecb = strstr(mode, "/ECB/") != 0; + int padded = strstr(mode, "NoPadding") == 0; + int bodyLength = dataLength; + BCRYPT_ALG_HANDLE alg = NULL; + BCRYPT_KEY_HANDLE handle = NULL; + NTSTATUS status; + unsigned char* out = 0; + unsigned char* ivCopy = 0; + ULONG outLength = 0, produced = 0; + JAVA_OBJECT result = JAVA_NULL; + BCRYPT_AUTHENTICATED_CIPHER_MODE_INFO auth; + unsigned char tag[CN1_GCM_TAG_BYTES]; + + if (gcm && !encrypt) { + if (dataLength < CN1_GCM_TAG_BYTES) { + cn1CryptoFail("AES-GCM input is shorter than its authentication tag", 0); + return JAVA_NULL; + } + bodyLength = dataLength - CN1_GCM_TAG_BYTES; + } + + status = BCryptOpenAlgorithmProvider(&alg, BCRYPT_AES_ALGORITHM, NULL, 0); + if (status != STATUS_SUCCESS) { + cn1CryptoFail("AES provider", status); + return JAVA_NULL; + } + status = BCryptSetProperty(alg, BCRYPT_CHAINING_MODE, + gcm ? (PUCHAR) BCRYPT_CHAIN_MODE_GCM + : (ecb ? (PUCHAR) BCRYPT_CHAIN_MODE_ECB + : (PUCHAR) BCRYPT_CHAIN_MODE_CBC), + gcm ? sizeof(BCRYPT_CHAIN_MODE_GCM) + : (ecb ? sizeof(BCRYPT_CHAIN_MODE_ECB) + : sizeof(BCRYPT_CHAIN_MODE_CBC)), + 0); + if (status != STATUS_SUCCESS) { + cn1CryptoFail("AES chaining mode", status); + goto done; + } + status = BCryptGenerateSymmetricKey(alg, &handle, NULL, 0, key, (ULONG) keyLength, 0); + if (status != STATUS_SUCCESS) { + cn1CryptoFail("AES key", status); + goto done; + } + + if (gcm) { + BCRYPT_INIT_AUTH_MODE_INFO(auth); + auth.pbNonce = iv; + auth.cbNonce = (ULONG) ivLength; + auth.pbAuthData = aadLength > 0 ? aad : NULL; + auth.cbAuthData = (ULONG) aadLength; + if (encrypt) { + auth.pbTag = tag; + auth.cbTag = CN1_GCM_TAG_BYTES; + } else { + auth.pbTag = data + bodyLength; + auth.cbTag = CN1_GCM_TAG_BYTES; + } + status = encrypt + ? BCryptEncrypt(handle, data, (ULONG) bodyLength, &auth, NULL, 0, NULL, 0, &outLength, 0) + : BCryptDecrypt(handle, data, (ULONG) bodyLength, &auth, NULL, 0, NULL, 0, &outLength, 0); + if (status != STATUS_SUCCESS) { + cn1CryptoFail("AES-GCM size", status); + goto done; + } + out = (unsigned char*) malloc((size_t) outLength + CN1_GCM_TAG_BYTES + 1); + if (out == 0) { + cn1CryptoFail("out of memory", 0); + goto done; + } + status = encrypt + ? BCryptEncrypt(handle, data, (ULONG) bodyLength, &auth, NULL, 0, out, outLength, &produced, 0) + : BCryptDecrypt(handle, data, (ULONG) bodyLength, &auth, NULL, 0, out, outLength, &produced, 0); + if (status != STATUS_SUCCESS) { + /* A tampered message or wrong associated data lands here. */ + cn1CryptoFail(status == STATUS_AUTH_TAG_MISMATCH + ? "AES-GCM authentication failed" : "AES-GCM", status); + goto done; + } + if (encrypt) { + memcpy(out + produced, tag, CN1_GCM_TAG_BYTES); + produced += CN1_GCM_TAG_BYTES; + } + } else { + ULONG flags = padded ? BCRYPT_BLOCK_PADDING : 0; + /* CBC updates the IV in place, so hand the cipher its own copy. */ + if (ivLength > 0) { + ivCopy = (unsigned char*) malloc((size_t) ivLength); + if (ivCopy == 0) { + cn1CryptoFail("out of memory", 0); + goto done; + } + memcpy(ivCopy, iv, (size_t) ivLength); + } + status = encrypt + ? BCryptEncrypt(handle, data, (ULONG) bodyLength, NULL, ivCopy, (ULONG) ivLength, + NULL, 0, &outLength, flags) + : BCryptDecrypt(handle, data, (ULONG) bodyLength, NULL, ivCopy, (ULONG) ivLength, + NULL, 0, &outLength, flags); + if (status != STATUS_SUCCESS) { + cn1CryptoFail("AES size", status); + goto done; + } + out = (unsigned char*) malloc((size_t) outLength + 1); + if (out == 0) { + cn1CryptoFail("out of memory", 0); + goto done; + } + /* The size query above consumed the IV copy; restore it. */ + if (ivLength > 0) { + memcpy(ivCopy, iv, (size_t) ivLength); + } + status = encrypt + ? BCryptEncrypt(handle, data, (ULONG) bodyLength, NULL, ivCopy, (ULONG) ivLength, + out, outLength, &produced, flags) + : BCryptDecrypt(handle, data, (ULONG) bodyLength, NULL, ivCopy, (ULONG) ivLength, + out, outLength, &produced, flags); + if (status != STATUS_SUCCESS) { + cn1CryptoFail("AES", status); + goto done; + } + } + result = cn1WinNewByteArray(threadStateData, out, (int) produced); + +done: + free(out); + free(ivCopy); + if (handle != NULL) { + BCryptDestroyKey(handle); + } + if (alg != NULL) { + BCryptCloseAlgorithmProvider(alg, 0); + } + return result; +} + +/* ------------------------------------------------------------ RSA keys */ + +static BCRYPT_KEY_HANDLE cn1PublicKey(const unsigned char* der, int length) { + CERT_PUBLIC_KEY_INFO* info = 0; + DWORD infoLength = 0; + BCRYPT_KEY_HANDLE key = NULL; + if (!CryptDecodeObjectEx(X509_ASN_ENCODING, X509_PUBLIC_KEY_INFO, der, (DWORD) length, + CRYPT_DECODE_ALLOC_FLAG, NULL, &info, &infoLength)) { + cn1CryptoFailLast("public key is not X.509 SubjectPublicKeyInfo DER"); + return NULL; + } + if (!CryptImportPublicKeyInfoEx2(X509_ASN_ENCODING, info, 0, NULL, &key)) { + cn1CryptoFailLast("public key import"); + key = NULL; + } + LocalFree(info); + return key; +} + +static BCRYPT_KEY_HANDLE cn1PrivateKey(const unsigned char* der, int length, BCRYPT_ALG_HANDLE* algOut) { + CRYPT_PRIVATE_KEY_INFO* info = 0; + DWORD infoLength = 0; + BCRYPT_RSAKEY_BLOB* blob = 0; + DWORD blobLength = 0; + BCRYPT_ALG_HANDLE alg = NULL; + BCRYPT_KEY_HANDLE key = NULL; + NTSTATUS status; + const unsigned char* pkcs1 = der; + DWORD pkcs1Length = (DWORD) length; + + *algOut = NULL; + /* PKCS#8 wraps the PKCS#1 RSAPrivateKey; tolerate a bare PKCS#1 too. */ + if (CryptDecodeObjectEx(X509_ASN_ENCODING, PKCS_PRIVATE_KEY_INFO, der, (DWORD) length, + CRYPT_DECODE_ALLOC_FLAG, NULL, &info, &infoLength)) { + pkcs1 = info->PrivateKey.pbData; + pkcs1Length = info->PrivateKey.cbData; + } + if (!CryptDecodeObjectEx(X509_ASN_ENCODING, CNG_RSA_PRIVATE_KEY_BLOB, pkcs1, pkcs1Length, + CRYPT_DECODE_ALLOC_FLAG, NULL, &blob, &blobLength)) { + cn1CryptoFailLast("private key is not PKCS#8 DER"); + if (info != 0) { + LocalFree(info); + } + return NULL; + } + status = BCryptOpenAlgorithmProvider(&alg, BCRYPT_RSA_ALGORITHM, NULL, 0); + if (status == STATUS_SUCCESS) { + /* The decoder emits either form depending on which primes it recovered. */ + LPCWSTR blobType = blob->Magic == BCRYPT_RSAFULLPRIVATE_MAGIC + ? BCRYPT_RSAFULLPRIVATE_BLOB : BCRYPT_RSAPRIVATE_BLOB; + status = BCryptImportKeyPair(alg, NULL, blobType, &key, (PUCHAR) blob, blobLength, 0); + if (status != STATUS_SUCCESS) { + cn1CryptoFail("private key import", status); + BCryptCloseAlgorithmProvider(alg, 0); + alg = NULL; + key = NULL; + } + } else { + cn1CryptoFail("RSA provider", status); + } + LocalFree(blob); + if (info != 0) { + LocalFree(info); + } + *algOut = alg; + return key; +} + +static LPCWSTR cn1DigestAlgorithm(const char* algorithm) { + if (strstr(algorithm, "SHA512") != 0 || strstr(algorithm, "SHA-512") != 0) { + return BCRYPT_SHA512_ALGORITHM; + } + if (strstr(algorithm, "SHA384") != 0 || strstr(algorithm, "SHA-384") != 0) { + return BCRYPT_SHA384_ALGORITHM; + } + if (strstr(algorithm, "SHA1") != 0 || strstr(algorithm, "SHA-1") != 0) { + return BCRYPT_SHA1_ALGORITHM; + } + return BCRYPT_SHA256_ALGORITHM; +} + +static int cn1DigestLength(LPCWSTR algorithm) { + if (wcscmp(algorithm, BCRYPT_SHA512_ALGORITHM) == 0) { + return 64; + } + if (wcscmp(algorithm, BCRYPT_SHA384_ALGORITHM) == 0) { + return 48; + } + if (wcscmp(algorithm, BCRYPT_SHA1_ALGORITHM) == 0) { + return 20; + } + return 32; +} + +/* Hashes with the named algorithm into caller-provided storage. */ +static int cn1Digest(LPCWSTR algorithm, const unsigned char* data, int length, + unsigned char* digest, int digestLength) { + BCRYPT_ALG_HANDLE alg = NULL; + NTSTATUS status = BCryptOpenAlgorithmProvider(&alg, algorithm, NULL, 0); + if (status != STATUS_SUCCESS) { + cn1CryptoFail("digest provider", status); + return 0; + } + status = BCryptHash(alg, NULL, 0, (PUCHAR) data, (ULONG) length, digest, (ULONG) digestLength); + BCryptCloseAlgorithmProvider(alg, 0); + if (status != STATUS_SUCCESS) { + cn1CryptoFail("digest", status); + return 0; + } + return 1; +} + +JAVA_OBJECT com_codename1_impl_windows_WindowsNative_rsaCrypt___java_lang_String_boolean_byte_1ARRAY_byte_1ARRAY_R_byte_1ARRAY( + CODENAME_ONE_THREAD_STATE, JAVA_OBJECT transformation, JAVA_BOOLEAN encrypt, + JAVA_OBJECT keyArray, JAVA_OBJECT dataArray) { + const char* mode = transformation == JAVA_NULL ? "" : stringToUTF8(threadStateData, transformation); + int keyLength = 0, dataLength = 0; + unsigned char* keyDer = cn1Bytes(keyArray, &keyLength); + unsigned char* data = cn1Bytes(dataArray, &dataLength); + BCRYPT_ALG_HANDLE alg = NULL; + BCRYPT_KEY_HANDLE key = encrypt ? cn1PublicKey(keyDer, keyLength) + : cn1PrivateKey(keyDer, keyLength, &alg); + BCRYPT_OAEP_PADDING_INFO oaep; + int oaepMode = strstr(mode, "OAEP") != 0; + void* padding = 0; + ULONG flags = oaepMode ? BCRYPT_PAD_OAEP : BCRYPT_PAD_PKCS1; + unsigned char* out = 0; + ULONG outLength = 0, produced = 0; + NTSTATUS status; + JAVA_OBJECT result = JAVA_NULL; + + if (key == NULL) { + return JAVA_NULL; + } + if (oaepMode) { + memset(&oaep, 0, sizeof(oaep)); + oaep.pszAlgId = strstr(mode, "SHA-1") != 0 ? BCRYPT_SHA1_ALGORITHM : BCRYPT_SHA256_ALGORITHM; + oaep.pbLabel = NULL; + oaep.cbLabel = 0; + padding = &oaep; + } + status = encrypt + ? BCryptEncrypt(key, data, (ULONG) dataLength, padding, NULL, 0, NULL, 0, &outLength, flags) + : BCryptDecrypt(key, data, (ULONG) dataLength, padding, NULL, 0, NULL, 0, &outLength, flags); + if (status != STATUS_SUCCESS) { + cn1CryptoFail("RSA size", status); + goto done; + } + out = (unsigned char*) malloc((size_t) outLength + 1); + if (out == 0) { + cn1CryptoFail("out of memory", 0); + goto done; + } + status = encrypt + ? BCryptEncrypt(key, data, (ULONG) dataLength, padding, NULL, 0, out, outLength, &produced, flags) + : BCryptDecrypt(key, data, (ULONG) dataLength, padding, NULL, 0, out, outLength, &produced, flags); + if (status != STATUS_SUCCESS) { + cn1CryptoFail(encrypt ? "RSA encrypt" : "RSA decrypt", status); + goto done; + } + result = cn1WinNewByteArray(threadStateData, out, (int) produced); + +done: + free(out); + BCryptDestroyKey(key); + if (alg != NULL) { + BCryptCloseAlgorithmProvider(alg, 0); + } + return result; +} + +JAVA_OBJECT com_codename1_impl_windows_WindowsNative_signData___java_lang_String_byte_1ARRAY_byte_1ARRAY_R_byte_1ARRAY( + CODENAME_ONE_THREAD_STATE, JAVA_OBJECT algorithm, JAVA_OBJECT keyArray, JAVA_OBJECT dataArray) { + const char* name = algorithm == JAVA_NULL ? "" : stringToUTF8(threadStateData, algorithm); + int keyLength = 0, dataLength = 0; + unsigned char* keyDer = cn1Bytes(keyArray, &keyLength); + unsigned char* data = cn1Bytes(dataArray, &dataLength); + BCRYPT_ALG_HANDLE alg = NULL; + BCRYPT_KEY_HANDLE key = cn1PrivateKey(keyDer, keyLength, &alg); + LPCWSTR digestAlgorithm = cn1DigestAlgorithm(name); + unsigned char digest[64]; + int digestLength = cn1DigestLength(digestAlgorithm); + BCRYPT_PKCS1_PADDING_INFO padding; + unsigned char* out = 0; + ULONG outLength = 0, produced = 0; + NTSTATUS status; + JAVA_OBJECT result = JAVA_NULL; + + if (key == NULL) { + return JAVA_NULL; + } + if (!cn1Digest(digestAlgorithm, data, dataLength, digest, digestLength)) { + goto done; + } + padding.pszAlgId = digestAlgorithm; + status = BCryptSignHash(key, &padding, digest, (ULONG) digestLength, NULL, 0, &outLength, + BCRYPT_PAD_PKCS1); + if (status != STATUS_SUCCESS) { + cn1CryptoFail("sign size", status); + goto done; + } + out = (unsigned char*) malloc((size_t) outLength + 1); + if (out == 0) { + cn1CryptoFail("out of memory", 0); + goto done; + } + status = BCryptSignHash(key, &padding, digest, (ULONG) digestLength, out, outLength, &produced, + BCRYPT_PAD_PKCS1); + if (status != STATUS_SUCCESS) { + cn1CryptoFail("sign", status); + goto done; + } + result = cn1WinNewByteArray(threadStateData, out, (int) produced); + +done: + free(out); + BCryptDestroyKey(key); + if (alg != NULL) { + BCryptCloseAlgorithmProvider(alg, 0); + } + return result; +} + +JAVA_BOOLEAN com_codename1_impl_windows_WindowsNative_verifyData___java_lang_String_byte_1ARRAY_byte_1ARRAY_byte_1ARRAY_R_boolean( + CODENAME_ONE_THREAD_STATE, JAVA_OBJECT algorithm, JAVA_OBJECT keyArray, + JAVA_OBJECT dataArray, JAVA_OBJECT signatureArray) { + const char* name = algorithm == JAVA_NULL ? "" : stringToUTF8(threadStateData, algorithm); + int keyLength = 0, dataLength = 0, signatureLength = 0; + unsigned char* keyDer = cn1Bytes(keyArray, &keyLength); + unsigned char* data = cn1Bytes(dataArray, &dataLength); + unsigned char* signature = cn1Bytes(signatureArray, &signatureLength); + BCRYPT_KEY_HANDLE key = cn1PublicKey(keyDer, keyLength); + LPCWSTR digestAlgorithm = cn1DigestAlgorithm(name); + unsigned char digest[64]; + int digestLength = cn1DigestLength(digestAlgorithm); + BCRYPT_PKCS1_PADDING_INFO padding; + JAVA_BOOLEAN result = JAVA_FALSE; + + if (key == NULL) { + return JAVA_FALSE; + } + if (cn1Digest(digestAlgorithm, data, dataLength, digest, digestLength)) { + padding.pszAlgId = digestAlgorithm; + /* A rejected signature is a normal answer here, not a fault. */ + if (BCryptVerifySignature(key, &padding, digest, (ULONG) digestLength, signature, + (ULONG) signatureLength, BCRYPT_PAD_PKCS1) == STATUS_SUCCESS) { + result = JAVA_TRUE; + } + } + BCryptDestroyKey(key); + return result; +} + +/* ------------------------------------------------------------ key pairs */ + +/* Returns the pair as one array: a four-byte big-endian public-key length, + * the X.509 public key, then the PKCS#8 private key. A pair has to come from + * a single call -- two calls would produce two unrelated keys. */ +JAVA_OBJECT com_codename1_impl_windows_WindowsNative_generateRsaKeyPair___int_R_byte_1ARRAY(CODENAME_ONE_THREAD_STATE, JAVA_INT bits) { + BCRYPT_ALG_HANDLE alg = NULL; + BCRYPT_KEY_HANDLE key = NULL; + CERT_PUBLIC_KEY_INFO* publicInfo = 0; + DWORD publicInfoLength = 0; + unsigned char* publicDer = 0; + DWORD publicLength = 0; + unsigned char* privateBlob = 0; + ULONG privateBlobLength = 0; + unsigned char* pkcs1 = 0; + DWORD pkcs1Length = 0; + unsigned char* privateDer = 0; + DWORD privateLength = 0; + unsigned char* blob = 0; + CRYPT_PRIVATE_KEY_INFO keyInfo; + unsigned char derNull[2]; + NTSTATUS status; + JAVA_OBJECT result = JAVA_NULL; + + status = BCryptOpenAlgorithmProvider(&alg, BCRYPT_RSA_ALGORITHM, NULL, 0); + if (status != STATUS_SUCCESS) { + cn1CryptoFail("RSA provider", status); + return JAVA_NULL; + } + status = BCryptGenerateKeyPair(alg, &key, (ULONG) bits, 0); + if (status == STATUS_SUCCESS) { + status = BCryptFinalizeKeyPair(key, 0); + } + if (status != STATUS_SUCCESS) { + cn1CryptoFail("RSA keygen", status); + goto done; + } + + /* Public half: BCrypt handle -> CERT_PUBLIC_KEY_INFO -> X.509 SPKI DER. */ + if (!CryptExportPublicKeyInfoFromBCryptKeyHandle(key, X509_ASN_ENCODING, NULL, 0, NULL, + NULL, &publicInfoLength)) { + cn1CryptoFailLast("public key export size"); + goto done; + } + publicInfo = (CERT_PUBLIC_KEY_INFO*) malloc(publicInfoLength); + if (publicInfo == 0) { + cn1CryptoFail("out of memory", 0); + goto done; + } + if (!CryptExportPublicKeyInfoFromBCryptKeyHandle(key, X509_ASN_ENCODING, NULL, 0, NULL, + publicInfo, &publicInfoLength) || + !CryptEncodeObjectEx(X509_ASN_ENCODING, X509_PUBLIC_KEY_INFO, publicInfo, + CRYPT_ENCODE_ALLOC_FLAG, NULL, &publicDer, &publicLength)) { + cn1CryptoFailLast("public key encode"); + goto done; + } + + /* Private half: BCrypt blob -> PKCS#1 DER -> PKCS#8 PrivateKeyInfo DER. */ + status = BCryptExportKey(key, NULL, BCRYPT_RSAFULLPRIVATE_BLOB, NULL, 0, &privateBlobLength, 0); + if (status != STATUS_SUCCESS) { + cn1CryptoFail("private key export size", status); + goto done; + } + privateBlob = (unsigned char*) malloc(privateBlobLength); + if (privateBlob == 0) { + cn1CryptoFail("out of memory", 0); + goto done; + } + status = BCryptExportKey(key, NULL, BCRYPT_RSAFULLPRIVATE_BLOB, privateBlob, privateBlobLength, + &privateBlobLength, 0); + if (status != STATUS_SUCCESS) { + cn1CryptoFail("private key export", status); + goto done; + } + if (!CryptEncodeObjectEx(X509_ASN_ENCODING, CNG_RSA_PRIVATE_KEY_BLOB, privateBlob, + CRYPT_ENCODE_ALLOC_FLAG, NULL, &pkcs1, &pkcs1Length)) { + cn1CryptoFailLast("private key encode"); + goto done; + } + memset(&keyInfo, 0, sizeof(keyInfo)); + keyInfo.Version = 0; + keyInfo.Algorithm.pszObjId = (LPSTR) szOID_RSA_RSA; + /* rsaEncryption takes an explicit ASN.1 NULL parameter. */ + derNull[0] = 0x05; + derNull[1] = 0x00; + keyInfo.Algorithm.Parameters.cbData = sizeof(derNull); + keyInfo.Algorithm.Parameters.pbData = derNull; + keyInfo.PrivateKey.cbData = pkcs1Length; + keyInfo.PrivateKey.pbData = pkcs1; + if (!CryptEncodeObjectEx(X509_ASN_ENCODING, PKCS_PRIVATE_KEY_INFO, &keyInfo, + CRYPT_ENCODE_ALLOC_FLAG, NULL, &privateDer, &privateLength)) { + cn1CryptoFailLast("PKCS#8 encode"); + goto done; + } + + blob = (unsigned char*) malloc((size_t) publicLength + (size_t) privateLength + 4); + if (blob == 0) { + cn1CryptoFail("out of memory", 0); + goto done; + } + blob[0] = (unsigned char) ((publicLength >> 24) & 0xff); + blob[1] = (unsigned char) ((publicLength >> 16) & 0xff); + blob[2] = (unsigned char) ((publicLength >> 8) & 0xff); + blob[3] = (unsigned char) (publicLength & 0xff); + memcpy(blob + 4, publicDer, publicLength); + memcpy(blob + 4 + publicLength, privateDer, privateLength); + result = cn1WinNewByteArray(threadStateData, blob, (int) (publicLength + privateLength + 4)); + +done: + free(blob); + free(publicInfo); + free(privateBlob); + if (publicDer != 0) { + LocalFree(publicDer); + } + if (pkcs1 != 0) { + LocalFree(pkcs1); + } + if (privateDer != 0) { + LocalFree(privateDer); + } + if (key != NULL) { + BCryptDestroyKey(key); + } + if (alg != NULL) { + BCryptCloseAlgorithmProvider(alg, 0); + } + return result; +} diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java index d278f5f15e5..50ab8c478bc 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java @@ -2742,6 +2742,84 @@ public char getFileSystemSeparator() { return '\\'; } + /* ------------------------------------------------------------ crypto */ + + /** + * The crypto bridge, backed by CNG. Every failure is reported as a + * RuntimeException carrying the provider status, which + * {@code com.codename1.security.Cipher} turns into a CryptoException -- + * an authentication failure has to be an exception rather than an empty + * result, or a tampered message would read as an empty plaintext. + */ + private static byte[] cryptoResult(byte[] value, String operation) { + if (value == null) { + throw new RuntimeException(operation + " failed: " + WindowsNative.lastCryptoError()); + } + return value; + } + + @Override + public void secureRandomBytes(byte[] out) { + if (out != null && out.length > 0) { + WindowsNative.secureRandomBytes(out); + } + } + + @Override + public byte[] aesEncrypt(String transformation, byte[] key, byte[] iv, byte[] aad, byte[] plaintext) { + return cryptoResult(WindowsNative.aesCrypt(transformation, true, key, iv, aad, plaintext), + "AES encrypt"); + } + + @Override + public byte[] aesDecrypt(String transformation, byte[] key, byte[] iv, byte[] aad, byte[] ciphertext) { + return cryptoResult(WindowsNative.aesCrypt(transformation, false, key, iv, aad, ciphertext), + "AES decrypt"); + } + + @Override + public byte[] rsaEncrypt(String transformation, byte[] publicKeyX509, byte[] plaintext) { + return cryptoResult(WindowsNative.rsaCrypt(transformation, true, publicKeyX509, plaintext), + "RSA encrypt"); + } + + @Override + public byte[] rsaDecrypt(String transformation, byte[] privateKeyPkcs8, byte[] ciphertext) { + return cryptoResult(WindowsNative.rsaCrypt(transformation, false, privateKeyPkcs8, ciphertext), + "RSA decrypt"); + } + + @Override + public byte[] cryptoSign(String algorithm, String keyAlgorithm, byte[] privateKeyPkcs8, byte[] data) { + // The digest and the key type both follow from the algorithm name and + // the DER key itself, so keyAlgorithm adds nothing here. + return cryptoResult(WindowsNative.signData(algorithm, privateKeyPkcs8, data), "sign"); + } + + @Override + public boolean cryptoVerify(String algorithm, String keyAlgorithm, byte[] publicKeyX509, + byte[] data, byte[] signature) { + return WindowsNative.verifyData(algorithm, publicKeyX509, data, signature); + } + + @Override + public byte[][] generateRsaKeyPair(int bits) { + byte[] blob = cryptoResult(WindowsNative.generateRsaKeyPair(bits), "RSA key generation"); + if (blob.length < 4) { + throw new RuntimeException("RSA key generation returned a truncated pair"); + } + int publicLength = ((blob[0] & 0xff) << 24) | ((blob[1] & 0xff) << 16) + | ((blob[2] & 0xff) << 8) | (blob[3] & 0xff); + if (publicLength < 0 || publicLength > blob.length - 4) { + throw new RuntimeException("RSA key generation returned a malformed pair"); + } + byte[] publicKey = new byte[publicLength]; + byte[] privateKey = new byte[blob.length - 4 - publicLength]; + System.arraycopy(blob, 4, publicKey, 0, publicKey.length); + System.arraycopy(blob, 4 + publicLength, privateKey, 0, privateKey.length); + return new byte[][] { publicKey, privateKey }; + } + /* ------------------------------------------------------------ platform */ @Override diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java index 9d4bad3ebbb..e5b7633e83f 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java @@ -399,6 +399,36 @@ public static native long editStringAt(int x, int y, int w, int h, String text, /** Why the most recent open failed, for the exception the port raises. */ public static native String lastIoError(); + /* ---------------------------------------------------------- crypto */ + + public static native void secureRandomBytes(byte[] out); + + /** + * AES in the mode named by {@code transformation}. For GCM the + * authentication tag is appended to the ciphertext, which is the + * convention the portable API documents. + */ + public static native byte[] aesCrypt(String transformation, boolean encrypt, + byte[] key, byte[] iv, byte[] aad, byte[] data); + + /** RSA with an X.509 public key when encrypting, PKCS#8 when decrypting. */ + public static native byte[] rsaCrypt(String transformation, boolean encrypt, + byte[] key, byte[] data); + + public static native byte[] signData(String algorithm, byte[] privateKeyPkcs8, byte[] data); + + public static native boolean verifyData(String algorithm, byte[] publicKeyX509, + byte[] data, byte[] signature); + + /** + * A fresh RSA pair as one array: a four-byte big-endian public-key length, + * the X.509 public key, then the PKCS#8 private key. + */ + public static native byte[] generateRsaKeyPair(int bits); + + /** Why the most recent crypto call failed, for the CryptoException message. */ + public static native String lastCryptoError(); + public static native int fileRead(long handle, byte[] buffer, int offset, int length); public static native int fileWrite(long handle, byte[] buffer, int offset, int length); diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java index d133e96c35d..f12947f3a97 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java @@ -911,7 +911,7 @@ private static void writeCmakeProject(File projectRoot, File srcRoot, String app // dbghelp: lets the last-resort unhandled-exception handler symbolize its // own native backtrace in-process (SymFromAddr against the /Zi .pdb), so a // native crash logs Java/C function names instead of bare RVAs. - writer.append(" target_link_libraries(${PROJECT_NAME} d2d1 dwrite dxgi windowscodecs winhttp ws2_32 user32 gdi32 ole32 oleaut32 uuid mf mfplat mfreadwrite mfuuid shell32 comdlg32 crypt32 winmm runtimeobject dbghelp)\n"); + writer.append(" target_link_libraries(${PROJECT_NAME} d2d1 dwrite dxgi windowscodecs winhttp ws2_32 user32 gdi32 ole32 oleaut32 uuid mf mfplat mfreadwrite mfuuid shell32 comdlg32 crypt32 bcrypt winmm runtimeobject dbghelp)\n"); // BrowserComponent is backed by WebView2 (cn1_windows_browser.cpp), // gated on the SDK being present: when WEBVIEW2_SDK_DIR points at a // Microsoft.Web.WebView2 build/native folder we link the static @@ -1041,7 +1041,10 @@ private static void writeLinuxLinkSet(Writer writer) throws IOException { writer.append("pkg_check_modules(CN1DEPS REQUIRED\n"); writer.append(" gtk+-3.0 cairo pango pangocairo gdk-pixbuf-2.0 glib-2.0 gobject-2.0 gio-2.0\n"); writer.append(" fontconfig freetype2\n"); - writer.append(" libcurl)\n"); + // libcrypto backs the port's crypto bridge (cn1_linux_crypto.c). It is + // already present wherever libcurl is: the OpenSSL-flavoured curl links + // it, and the -dev package pulls in the headers. + writer.append(" libcurl libcrypto)\n"); writer.append("pkg_check_modules(CN1GL REQUIRED epoxy egl glesv2)\n"); // Optional feature libs (browser/media/secure-storage/notifications/ // location). The port dlopen()s these lazily at first use (see From 7cb8f3edeccde577b9fd9694ca13fadf59dda55f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:25:48 +0300 Subject: [PATCH 05/91] Name the test that wedges the suite instead of letting the run die quietly A test that blocks the event dispatch thread outright can never be timed out by the runner, because the per-test deadline is itself enforced by an EDT callback. The suite simply stopped: the log ended mid-line, every later test was published as "never run", and nothing said which test was responsible -- that is how Media360PanoramaScreenshotTest on Linux and CalendarApiTest on Windows have been going unattributed. A watchdog thread now tracks the running test and, thirty seconds past its deadline, reports it by name, emits a CN1SS:SUITE:WEDGED marker and exits. The normalized report then records that test as failed and the rest as unreached, which is what actually happened, and the harness stops burning its forty-minute cap on a thread that is not coming back. Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/Cn1ssDeviceRunner.java | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java index 3b31c5bb407..cfb90839b9d 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java @@ -491,9 +491,59 @@ public void runSuite() { logThrowable("EDT", (Throwable)e.getSource()); }); }); + startWedgeWatchdog(); runNextTest(0); } + /// Name of the test the event dispatch thread is inside, and the wall clock + /// at which it stops being plausible that it is still working. Written on + /// the EDT, read by the watchdog thread. + private volatile String activeTestName; + private volatile long activeTestDeadline; + + /// Grace on top of a test's own timeout before the watchdog concludes the + /// EDT is not coming back. The per-test timeout is itself enforced by an + /// EDT callback, so a test that blocks the thread outright can never be + /// timed out by it -- the suite simply stops, and every remaining test is + /// published as "never run" with nothing saying why. + private static final long WEDGE_GRACE_MS = 30000L; + + private void startWedgeWatchdog() { + Thread watchdog = new Thread(() -> { + while (true) { + try { + Thread.sleep(1000L); + } catch (InterruptedException interrupted) { + return; + } + String name = activeTestName; + long deadline = activeTestDeadline; + if (name == null || deadline <= 0L) { + continue; + } + long overrun = System.currentTimeMillis() - (deadline + WEDGE_GRACE_MS); + if (overrun < 0L) { + continue; + } + // Report against the test rather than the suite: this is the + // one line that says which test stopped the run. + log("CN1SS:ERR:suite test=" + name + " failed: the event dispatch thread has not" + + " returned from this test " + overrun + "ms past its deadline; the suite" + + " cannot continue and every later test is unreached"); + log("CN1SS:SUITE:WEDGED test=" + name); + try { + Thread.sleep(250L); + } catch (InterruptedException ignored) { + // fall through to the exit below + } + Runtime.getRuntime().exit(70); + } + }); + watchdog.setName("cn1ss-wedge-watchdog"); + watchdog.setDaemon(true); + watchdog.start(); + } + private void runNextTest(int index) { int offset = prependedTest != null ? 1 : 0; boolean includeJavaSeReferences = "SE".equals( @@ -529,6 +579,8 @@ private void runNextTest(int index) { CN.callSerially(() -> { Cn1ssDeviceRunnerHelper.clearTransportFailure(); log("CN1SS:INFO:suite starting test=" + testName); + activeTestName = testName; + activeTestDeadline = System.currentTimeMillis() + testTimeoutMs(testClass); try { testClass.prepare(); testClass.runTest(); @@ -566,6 +618,7 @@ private void awaitTestCompletion(int index, BaseTest testClass, String testName, } private void finalizeTest(int index, BaseTest testClass, String testName, boolean timedOut) { + activeTestName = null; final Runnable continueToNext = () -> { log("CN1SS:INFO:suite finished test=" + testName); runNextTest(index + 1); From 0c6c9701c8b903c2704973c0c6a35fd32bdf64f5 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:29:05 +0300 Subject: [PATCH 06/91] Give the Windows port real named-time-zone offsets The shared POSIX implementation sets TZ and reads tm_gmtoff back. Neither half exists on Windows: the Microsoft C runtime only parses the "EST5EDT" form of TZ, not an IANA identifier, and its struct tm carries no GMT offset at all. Every named zone therefore resolved to an offset of zero, which is why TimeApiTest read America/New_York as UTC. Windows has shipped ICU since Windows 10 1703, and its calendar speaks IANA identifiers and knows the daylight rules for the instant being asked about. The three time zone natives now go through it and fall back to the previous behaviour if it is unavailable. The POSIX path is untouched and still passes the same probe through the ParparVM clean target. Co-Authored-By: Claude Opus 5 (1M context) --- .../tools/translator/ByteCodeTranslator.java | 2 +- vm/ByteCodeTranslator/src/nativeMethods.m | 100 ++++++++++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java index f12947f3a97..f4de6cd14e0 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java @@ -911,7 +911,7 @@ private static void writeCmakeProject(File projectRoot, File srcRoot, String app // dbghelp: lets the last-resort unhandled-exception handler symbolize its // own native backtrace in-process (SymFromAddr against the /Zi .pdb), so a // native crash logs Java/C function names instead of bare RVAs. - writer.append(" target_link_libraries(${PROJECT_NAME} d2d1 dwrite dxgi windowscodecs winhttp ws2_32 user32 gdi32 ole32 oleaut32 uuid mf mfplat mfreadwrite mfuuid shell32 comdlg32 crypt32 bcrypt winmm runtimeobject dbghelp)\n"); + writer.append(" target_link_libraries(${PROJECT_NAME} d2d1 dwrite dxgi windowscodecs winhttp ws2_32 user32 gdi32 ole32 oleaut32 uuid mf mfplat mfreadwrite mfuuid shell32 comdlg32 crypt32 bcrypt icu winmm runtimeobject dbghelp)\n"); // BrowserComponent is backed by WebView2 (cn1_windows_browser.cpp), // gated on the SDK being present: when WEBVIEW2_SDK_DIR points at a // Microsoft.Web.WebView2 build/native folder we link the static diff --git a/vm/ByteCodeTranslator/src/nativeMethods.m b/vm/ByteCodeTranslator/src/nativeMethods.m index 6b40f077b39..41794a66d44 100644 --- a/vm/ByteCodeTranslator/src/nativeMethods.m +++ b/vm/ByteCodeTranslator/src/nativeMethods.m @@ -2603,6 +2603,70 @@ static void cn1_with_timezone(const char* zoneId, void (*func)(void*), void* ctx pthread_mutex_unlock(&cn1_timezone_mutex); } +/* + * Windows named-zone support. + * + * The POSIX path below sets TZ and reads tm_gmtoff back. Neither half works + * here: the Microsoft C runtime only understands the "EST5EDT" form of TZ, not + * an IANA identifier, and its struct tm carries no GMT offset at all -- so + * every named zone resolved to an offset of zero and, for instance, + * America/New_York reported UTC. Windows ships ICU (icu.dll, Windows 10 1703 + * and later), whose calendar speaks IANA identifiers directly and knows the + * daylight rules for the instant being asked about. + * + * cn1WinZoneOffsetMillis answers the total offset (zone + daylight) at an + * instant, or reports failure so the caller can fall back. + */ +#ifdef _WIN32 +#include + +static int cn1WinZoneOffsetMillis(const char* zoneId, long long millis, int* offsetOut, int* dstOut) { + UErrorCode status = U_ZERO_ERROR; + UChar zone[128]; + UCalendar* cal; + int32_t zoneOffset, dstOffset; + if (zoneId == 0 || zoneId[0] == 0) { + return 0; + } + u_strFromUTF8(zone, (int32_t) (sizeof(zone) / sizeof(zone[0])), NULL, zoneId, -1, &status); + if (U_FAILURE(status)) { + return 0; + } + cal = ucal_open(zone, -1, "en_US", UCAL_GREGORIAN, &status); + if (U_FAILURE(status) || cal == 0) { + return 0; + } + ucal_setMillis(cal, (UDate) millis, &status); + zoneOffset = ucal_get(cal, UCAL_ZONE_OFFSET, &status); + dstOffset = ucal_get(cal, UCAL_DST_OFFSET, &status); + ucal_close(cal); + if (U_FAILURE(status)) { + return 0; + } + if (offsetOut != 0) { + *offsetOut = (int) (zoneOffset + dstOffset); + } + if (dstOut != 0) { + *dstOut = dstOffset != 0; + } + return 1; +} + +/* Milliseconds since the epoch for a set of UTC calendar fields. */ +static long long cn1WinUtcMillis(int year, int month, int day, int millisOfDay) { + struct tm utc; + memset(&utc, 0, sizeof(utc)); + utc.tm_year = year - 1900; + utc.tm_mon = month - 1; + utc.tm_mday = day; + utc.tm_hour = millisOfDay / 3600000; + utc.tm_min = (millisOfDay / 60000) % 60; + utc.tm_sec = (millisOfDay / 1000) % 60; + utc.tm_isdst = 0; + return (long long) timegm(&utc) * 1000LL; +} +#endif + typedef struct { int year; int month; @@ -2709,6 +2773,15 @@ JAVA_OBJECT java_util_TimeZone_getTimezoneId___R_java_lang_String(CODENAME_ONE_T JAVA_INT java_util_TimeZone_getTimezoneOffset___java_lang_String_int_int_int_int_R_int(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT name, JAVA_INT year, JAVA_INT month, JAVA_INT day, JAVA_INT timeOfDayMillis) { const char* buffer = stringToUTF8(threadStateData, name); cn1_timezone_offset_ctx ctx; +#ifdef _WIN32 + { + int offset = 0; + if (cn1WinZoneOffsetMillis(buffer, cn1WinUtcMillis(year, month, day, timeOfDayMillis), + &offset, 0)) { + return offset; + } + } +#endif ctx.year = year; ctx.month = month; ctx.day = day; @@ -2721,6 +2794,25 @@ JAVA_INT java_util_TimeZone_getTimezoneOffset___java_lang_String_int_int_int_int JAVA_INT java_util_TimeZone_getTimezoneRawOffset___java_lang_String_R_int(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT name) { const char* buffer = stringToUTF8(threadStateData, name); cn1_timezone_raw_ctx ctx; +#ifdef _WIN32 + { + /* The raw offset is the standard-time one: sample both solstices and + * take whichever is not in daylight saving (either hemisphere). */ + int januaryOffset = 0, januaryDst = 0, julyOffset = 0, julyDst = 0; + if (cn1WinZoneOffsetMillis(buffer, cn1WinUtcMillis(2024, 1, 1, 43200000), + &januaryOffset, &januaryDst) && + cn1WinZoneOffsetMillis(buffer, cn1WinUtcMillis(2024, 7, 1, 43200000), + &julyOffset, &julyDst)) { + if (!januaryDst) { + return januaryOffset; + } + if (!julyDst) { + return julyOffset; + } + return januaryOffset < julyOffset ? januaryOffset : julyOffset; + } + } +#endif ctx.januaryOffset = 0; ctx.januaryIsDst = 0; ctx.julyOffset = 0; @@ -2738,6 +2830,14 @@ JAVA_INT java_util_TimeZone_getTimezoneRawOffset___java_lang_String_R_int(CODENA JAVA_BOOLEAN java_util_TimeZone_isTimezoneDST___java_lang_String_long_R_boolean(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT name, JAVA_LONG millis) { const char* buffer = stringToUTF8(threadStateData, name); cn1_timezone_dst_ctx ctx; +#ifdef _WIN32 + { + int dst = 0; + if (cn1WinZoneOffsetMillis(buffer, (long long) millis, 0, &dst)) { + return dst ? JAVA_TRUE : JAVA_FALSE; + } + } +#endif ctx.millis = millis; ctx.result = JAVA_FALSE; cn1_with_timezone(buffer, cn1_compute_timezone_dst, &ctx); From 19255ebe822bbea34490d3e577bf224c2e8aa9f4 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:20:04 +0300 Subject: [PATCH 07/91] Report the wedging test through the harness, not a forbidden exit call The watchdog reached for Runtime.exit, which the bytecode compliance gate rejects along with System.exit -- both are outside the API the ports support, and exitApplication would have to run on the very thread that is stuck. That broke the suite build, and with it every job that compiles the suite. The watchdog now only reports: it names the test and emits CN1SS:SUITE:WEDGED. Both capture harnesses watch for that marker, stop waiting as soon as it appears and fail with the test name, which is ordinary JUnit code under no such restriction. It also starts through Display.startThread rather than configuring a raw Thread, and stops itself once the suite finishes. Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/Cn1ssDeviceRunner.java | 30 +++++++++++-------- .../CleanTargetIntegrationTest.java | 12 ++++++++ .../CleanTargetLinuxIntegrationTest.java | 11 +++++++ 3 files changed, 41 insertions(+), 12 deletions(-) diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java index cfb90839b9d..eba4b56d72f 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java @@ -509,8 +509,11 @@ public void runSuite() { private static final long WEDGE_GRACE_MS = 30000L; private void startWedgeWatchdog() { - Thread watchdog = new Thread(() -> { - while (true) { + // Display.startThread rather than a bare Thread: the ports only support + // the thread surface the bytecode compliance gate allows, and this hands + // back a CodenameOneThread that the platform names and reaps for us. + Display.getInstance().startThread(() -> { + while (!suiteFinished) { try { Thread.sleep(1000L); } catch (InterruptedException interrupted) { @@ -531,19 +534,21 @@ private void startWedgeWatchdog() { + " returned from this test " + overrun + "ms past its deadline; the suite" + " cannot continue and every later test is unreached"); log("CN1SS:SUITE:WEDGED test=" + name); - try { - Thread.sleep(250L); - } catch (InterruptedException ignored) { - // fall through to the exit below - } - Runtime.getRuntime().exit(70); + // Nothing here can end the process: exitApplication would have + // to run on the very thread that is stuck, and the raw exit + // calls are not part of the API the ports support. The marker + // above is the contract instead -- the capture harness watches + // for it and stops the run, having been told which test to + // blame. + return; } - }); - watchdog.setName("cn1ss-wedge-watchdog"); - watchdog.setDaemon(true); - watchdog.start(); + }, "cn1ss-wedge-watchdog").start(); } + /// Set once the suite is over so the watchdog thread returns instead of + /// outliving the run. + private volatile boolean suiteFinished; + private void runNextTest(int index) { int offset = prependedTest != null ? 1 : 0; boolean includeJavaSeReferences = "SE".equals( @@ -728,6 +733,7 @@ private void finishSuite() { } log("CN1SS:INFO:swift_diag_status=" + status); } finally { + suiteFinished = true; log("CN1SS:SUITE:FINISHED"); } try { diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetIntegrationTest.java index 8f1a5eb56af..60c049e3812 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetIntegrationTest.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetIntegrationTest.java @@ -1225,6 +1225,10 @@ public void run() { final java.util.concurrent.atomic.AtomicInteger finishedTests = new java.util.concurrent.atomic.AtomicInteger(0); final java.util.concurrent.atomic.AtomicReference lastLine = new java.util.concurrent.atomic.AtomicReference(""); + // Set by the suite watchdog when a test blocks the event dispatch + // thread; the run cannot progress past that point, so stop waiting. + final java.util.concurrent.atomic.AtomicReference wedged = + new java.util.concurrent.atomic.AtomicReference(); // The real shared benchmark emits "CN1SS:STAT:: " lines // (Base64NativePerformanceTest: base64 native/CN1/SIMD + image // createMask/applyMask/modifyAlpha/PNG/JPEG, plus the SIMD kernel tally), @@ -1255,6 +1259,7 @@ public void run() { performanceFinished.set(true); } } + if (line.contains("CN1SS:SUITE:WEDGED")) { wedged.set(line); } int suite = line.indexOf("CN1SS:"); if (suite >= 0) { suiteLog.add(line.substring(suite)); } if (line.contains("CN1SS:") || line.contains("suite ")) { lastLine.set(line); } @@ -1286,6 +1291,10 @@ public void run() { long lastChange = System.currentTimeMillis(); while (System.currentTimeMillis() < deadline) { if (finished.get()) { break; } + if (wedged.get() != null) { + System.out.println("CN1SS:HARNESS: " + wedged.get()); + break; + } pngs = countPngFiles(outDir); if (pngs != lastPngs) { lastPngs = pngs; lastChange = System.currentTimeMillis(); } if (!requireSuite && pngs >= minPngs && (System.currentTimeMillis() - lastChange) >= stableMs @@ -1293,6 +1302,9 @@ public void run() { Thread.sleep(3000); } pngs = countPngFiles(outDir); + assertTrue(wedged.get() == null, + "the suite stopped because a test blocked the event dispatch thread: " + + wedged.get()); assertTrue(finished.get() || (!requireSuite && pngs >= minPngs && (!requirePerformance || performanceFinished.get())), "hello suite capture incomplete: pngs=" + pngs + " (need " + minPngs + ")" diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java index f374f19a818..0d304694a61 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java @@ -373,6 +373,9 @@ void capturesHelloSuiteOverWebSocketLinux() throws Exception { appPb.redirectErrorStream(true); app = appPb.start(); final AtomicBoolean finished = new AtomicBoolean(false); + // Set by the suite watchdog when a test blocks the event dispatch thread; + // the run cannot progress past that point, so stop instead of waiting. + final java.util.concurrent.atomic.AtomicReference wedged = new java.util.concurrent.atomic.AtomicReference<>(); final Process appF = app; Thread areader = new Thread(() -> { // Tee the app's merged stdout/stderr to CN1_APP_LOG_TEE when @@ -395,6 +398,7 @@ void capturesHelloSuiteOverWebSocketLinux() throws Exception { while ((line = r.readLine()) != null) { if (tee != null) { tee.println(line); } if (line.contains("CN1SS:SUITE:FINISHED")) { finished.set(true); } + if (line.contains("CN1SS:SUITE:WEDGED")) { wedged.set(line); } } } catch (IOException ignore) { } @@ -429,6 +433,10 @@ void capturesHelloSuiteOverWebSocketLinux() throws Exception { long lastChange = System.currentTimeMillis(); while (System.currentTimeMillis() < deadline) { if (finished.get()) { break; } + if (wedged.get() != null) { + System.out.println("CN1SS:HARNESS: " + wedged.get()); + break; + } if (!app.isAlive()) { // The suite intermittently DIES mid-run with no output (the // tee cuts mid-line): surface the exit status -- 128+N means @@ -449,6 +457,9 @@ void capturesHelloSuiteOverWebSocketLinux() throws Exception { System.out.println("CN1SS:HARNESS: suite never emitted CN1SS:SUITE:FINISHED; pngs=" + pngs + " -- every test after the last logged one is reported as never run."); } + assertTrue(wedged.get() == null, + "the suite stopped because a test blocked the event dispatch thread: " + + wedged.get()); assertTrue(finished.get() || (!requireSuite && pngs >= minPngs), "hello suite capture incomplete: pngs=" + pngs + " (need " + minPngs + ")" + " suiteFinished=" + finished.get() + "\n" + serverLog); From 902b535c8051a953a9e3dbc99f368f34172bdd64 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:41:50 +0300 Subject: [PATCH 08/91] Address the second review round across crypto, time zones and the sweep Crypto - The RNG now fails closed. RAND_bytes and BCryptGenRandom report failure to Java, which throws, instead of leaving a zeroed buffer that KeyGenerator would hand out as a key. - OAEP masks with SHA-1 even when the digest is SHA-256, matching the JCE providers behind the JavaSE and Android ports. Naming the digest for both halves made anything sealed on a desktop port undecryptable elsewhere. - Initialization vectors are checked before they reach the platform library: a missing GCM nonce silently repeated across messages under one key, and a short CBC IV was read as a whole block past the Java array. - Windows imports private keys through NCrypt, which takes PKCS#8 for both RSA and EC, so the ECDSA signature APIs work instead of decoding every key as RSA. Sign and verify pick their padding from the key's own algorithm. Time zones - Custom IDs split their last two digits as minutes for the three-digit form too, so GMT+012 is UTC+00:12 rather than UTC+12. - The Windows raw offset samples the current year and prefers the later standard-time reading. A zone whose base offset changed mid-year with neither sample flagged as daylight saving -- Asia/Almaty in 2024 -- would otherwise report its retired offset forever. - The UWP native reads its fields as UTC like the POSIX, JavaScript and iOS implementations, rather than as host-local time. Port status - A report whose generated_at cannot be parsed is unusable rather than publishable; it would otherwise poison the sweep and the page's own rendering. - The sweep merges artifacts across candidate runs until every port a workflow owns is covered, so one failed matrix leg no longer hides the others, and compares exact elapsed seconds rather than whole days. - A feature whose tests all passed or were documented skips keeps its noted pass even when the suite run stopped early; the completion fallback now runs after that case rather than before it. Co-Authored-By: Claude Opus 5 (1M context) --- .../nativeSources/cn1_linux_crypto.c | 28 ++- .../impl/linux/LinuxImplementation.java | 23 ++- .../com/codename1/impl/linux/LinuxNative.java | 3 +- .../UWP/VSProjectTemplate/UWPApp/App.xaml.cs | 8 +- .../nativeSources/cn1_windows_crypto.c | 195 +++++++++++------- .../impl/windows/WindowsImplementation.java | 28 ++- .../codename1/impl/windows/WindowsNative.java | 3 +- .../partials/port-status-feature-status.html | 8 +- .../conformance/backfill_port_status.sh | 44 +++- .../conformance/port_status.py | 11 + .../tools/translator/ByteCodeTranslator.java | 2 +- vm/ByteCodeTranslator/src/nativeMethods.m | 28 ++- vm/JavaAPI/src/java/util/TimeZone.java | 14 +- 13 files changed, 281 insertions(+), 114 deletions(-) diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c b/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c index db518bf43a0..9025e45e099 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c @@ -81,16 +81,21 @@ static const unsigned char* cn1Bytes(JAVA_OBJECT array, int* length) { /* ------------------------------------------------------------ random */ -JAVA_VOID com_codename1_impl_linux_LinuxNative_secureRandomBytes___byte_1ARRAY(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT out) { +JAVA_BOOLEAN com_codename1_impl_linux_LinuxNative_secureRandomBytes___byte_1ARRAY_R_boolean(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT out) { int length = 0; unsigned char* data = (unsigned char*) cn1Bytes(out, &length); if (data == 0 || length <= 0) { - return; + return JAVA_TRUE; } if (RAND_bytes(data, length) != 1) { + // Report the failure rather than leaving the buffer as it stands: + // KeyGenerator hands this straight back as key material, so a quiet + // return would mint a predictable key. cn1CryptoFail("secure random"); memset(data, 0, (size_t) length); + return JAVA_FALSE; } + return JAVA_TRUE; } /* ------------------------------------------------------------ AES */ @@ -120,6 +125,7 @@ JAVA_OBJECT com_codename1_impl_linux_LinuxNative_aesCrypt___java_lang_String_boo const unsigned char* aad = cn1Bytes(aadArray, &aadLength); const unsigned char* data = cn1Bytes(dataArray, &dataLength); int gcm = strstr(mode, "/GCM/") != 0; + int ecb = strstr(mode, "/ECB/") != 0; int padded = strstr(mode, "NoPadding") == 0; const EVP_CIPHER* cipher = cn1AesCipher(mode, keyLength); EVP_CIPHER_CTX* ctx = 0; @@ -133,6 +139,17 @@ JAVA_OBJECT com_codename1_impl_linux_LinuxNative_aesCrypt___java_lang_String_boo cn1CryptoFail("unsupported AES key length"); return JAVA_NULL; } + // OpenSSL would otherwise silently keep the context's zeroed default IV for + // a missing GCM nonce -- repeating a nonce under one key destroys GCM -- + // and read a whole block past a short CBC IV. + if (gcm && ivLength <= 0) { + cn1CryptoFail("AES-GCM requires a nonce"); + return JAVA_NULL; + } + if (!gcm && !ecb && ivLength != 16) { + cn1CryptoFail("AES-CBC requires a 16 byte initialization vector"); + return JAVA_NULL; + } if (gcm && !encrypt) { if (dataLength < CN1_GCM_TAG_BYTES) { cn1CryptoFail("AES-GCM input is shorter than its authentication tag"); @@ -238,9 +255,14 @@ static EVP_PKEY* cn1PrivateKey(const unsigned char* der, int length) { static int cn1ApplyRsaPadding(EVP_PKEY_CTX* ctx, const char* transformation) { if (strstr(transformation, "OAEP") != 0) { const EVP_MD* md = strstr(transformation, "SHA-1") != 0 ? EVP_sha1() : EVP_sha256(); + // The mask function stays on SHA-1 even when the OAEP digest is + // SHA-256. That is what the JCE providers behind the JavaSE and + // Android ports do for this transformation name, and ciphertext has to + // stay readable across ports; naming the digest for both halves would + // make anything sealed here undecryptable there. if (EVP_PKEY_CTX_set_rsa_padding(ctx, RSA_PKCS1_OAEP_PADDING) <= 0 || EVP_PKEY_CTX_set_rsa_oaep_md(ctx, md) <= 0 || - EVP_PKEY_CTX_set_rsa_mgf1_md(ctx, md) <= 0) { + EVP_PKEY_CTX_set_rsa_mgf1_md(ctx, EVP_sha1()) <= 0) { return 0; } return 1; diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java index 6bc862cee74..07fd63b57e0 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java @@ -2755,19 +2755,38 @@ private static byte[] cryptoResult(byte[] value, String operation) { @Override public void secureRandomBytes(byte[] out) { - if (out != null && out.length > 0) { - LinuxNative.secureRandomBytes(out); + // Fail loudly: KeyGenerator hands this buffer straight back as key + // material, so a quiet return after the platform RNG failed would mint + // a predictable key. + if (out != null && out.length > 0 && !LinuxNative.secureRandomBytes(out)) { + throw new RuntimeException("secure random failed: " + LinuxNative.lastCryptoError()); + } + } + + /// Rejects an initialization vector the mode cannot use. A GCM nonce that + /// is absent repeats across messages under one key, and a short CBC IV is + /// read as a whole block by the platform library. + private static void checkIv(String transformation, byte[] iv) { + String mode = transformation == null ? "" : transformation; + if (mode.indexOf("/GCM/") >= 0) { + if (iv == null || iv.length == 0) { + throw new RuntimeException("AES-GCM requires a nonce"); + } + } else if (mode.indexOf("/ECB/") < 0 && (iv == null || iv.length != 16)) { + throw new RuntimeException("AES-CBC requires a 16 byte initialization vector"); } } @Override public byte[] aesEncrypt(String transformation, byte[] key, byte[] iv, byte[] aad, byte[] plaintext) { + checkIv(transformation, iv); return cryptoResult(LinuxNative.aesCrypt(transformation, true, key, iv, aad, plaintext), "AES encrypt"); } @Override public byte[] aesDecrypt(String transformation, byte[] key, byte[] iv, byte[] aad, byte[] ciphertext) { + checkIv(transformation, iv); return cryptoResult(LinuxNative.aesCrypt(transformation, false, key, iv, aad, ciphertext), "AES decrypt"); } diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java index 79f66a38ff2..641dd1c4a9c 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java @@ -393,7 +393,8 @@ public static native long editStringAt(int x, int y, int w, int h, String text, /* ---------------------------------------------------------- crypto */ - public static native void secureRandomBytes(byte[] out); + /** Fills {@code out} with fresh entropy; false when the platform RNG failed. */ + public static native boolean secureRandomBytes(byte[] out); /** * AES in the mode named by {@code transformation}. For GCM the diff --git a/Ports/UWP/VSProjectTemplate/UWPApp/App.xaml.cs b/Ports/UWP/VSProjectTemplate/UWPApp/App.xaml.cs index c67db7bbdd2..5786dc36acb 100644 --- a/Ports/UWP/VSProjectTemplate/UWPApp/App.xaml.cs +++ b/Ports/UWP/VSProjectTemplate/UWPApp/App.xaml.cs @@ -391,7 +391,13 @@ public override int getTimezoneOffset(string name, int year, int month, int day, int minutes = timeOfDayMillis / 1000 / 60 - hours * 60; int seconds = timeOfDayMillis / 1000 - (hours * 60 * 60) - (minutes * 60); int millis = timeOfDayMillis % 1000; - return (int)TimeZoneInfo.FindSystemTimeZoneById(name).GetUtcOffset(new DateTime(year, month, day, hours, minutes, seconds, DateTimeKind.Local)).TotalMilliseconds; + // The caller passes UTC fields -- the POSIX implementation of this + // native resolves them with timegm and the JavaScript one with + // Date.UTC -- so read them as UTC here too. DateTimeKind.Local + // shifted the instant by the host offset, which lands on the wrong + // side of a transition when the requested zone changes offset + // inside that window. + return (int)TimeZoneInfo.FindSystemTimeZoneById(name).GetUtcOffset(new DateTime(year, month, day, hours, minutes, seconds, DateTimeKind.Utc)).TotalMilliseconds; } public override int getTimezoneRawOffset(string name) diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c index b278a53f2c9..a15924c0ecc 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c +++ b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c @@ -40,6 +40,7 @@ #include #include #include +#include #include #include #include @@ -95,18 +96,23 @@ static unsigned char* cn1Bytes(JAVA_OBJECT array, int* length) { /* ------------------------------------------------------------ random */ -JAVA_VOID com_codename1_impl_windows_WindowsNative_secureRandomBytes___byte_1ARRAY(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT out) { +JAVA_BOOLEAN com_codename1_impl_windows_WindowsNative_secureRandomBytes___byte_1ARRAY_R_boolean(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT out) { int length = 0; unsigned char* data = cn1Bytes(out, &length); NTSTATUS status; if (data == 0 || length <= 0) { - return; + return JAVA_TRUE; } status = BCryptGenRandom(NULL, data, (ULONG) length, BCRYPT_USE_SYSTEM_PREFERRED_RNG); if (status != STATUS_SUCCESS) { + /* Report the failure rather than leaving the buffer as it stands: + * KeyGenerator hands this straight back as key material, so a quiet + * return would mint a predictable key. */ cn1CryptoFail("secure random", status); memset(data, 0, (size_t) length); + return JAVA_FALSE; } + return JAVA_TRUE; } /* ------------------------------------------------------------ AES */ @@ -134,6 +140,17 @@ JAVA_OBJECT com_codename1_impl_windows_WindowsNative_aesCrypt___java_lang_String BCRYPT_AUTHENTICATED_CIPHER_MODE_INFO auth; unsigned char tag[CN1_GCM_TAG_BYTES]; + /* A missing GCM nonce would otherwise repeat across messages under one + * key, which destroys the mode, and a short CBC IV is read as a whole + * block. */ + if (gcm && ivLength <= 0) { + cn1CryptoFail("AES-GCM requires a nonce", 0); + return JAVA_NULL; + } + if (!gcm && !ecb && ivLength != 16) { + cn1CryptoFail("AES-CBC requires a 16 byte initialization vector", 0); + return JAVA_NULL; + } if (gcm && !encrypt) { if (dataLength < CN1_GCM_TAG_BYTES) { cn1CryptoFail("AES-GCM input is shorter than its authentication tag", 0); @@ -275,55 +292,69 @@ static BCRYPT_KEY_HANDLE cn1PublicKey(const unsigned char* der, int length) { return key; } -static BCRYPT_KEY_HANDLE cn1PrivateKey(const unsigned char* der, int length, BCRYPT_ALG_HANDLE* algOut) { - CRYPT_PRIVATE_KEY_INFO* info = 0; - DWORD infoLength = 0; - BCRYPT_RSAKEY_BLOB* blob = 0; - DWORD blobLength = 0; - BCRYPT_ALG_HANDLE alg = NULL; - BCRYPT_KEY_HANDLE key = NULL; - NTSTATUS status; - const unsigned char* pkcs1 = der; - DWORD pkcs1Length = (DWORD) length; - - *algOut = NULL; - /* PKCS#8 wraps the PKCS#1 RSAPrivateKey; tolerate a bare PKCS#1 too. */ - if (CryptDecodeObjectEx(X509_ASN_ENCODING, PKCS_PRIVATE_KEY_INFO, der, (DWORD) length, - CRYPT_DECODE_ALLOC_FLAG, NULL, &info, &infoLength)) { - pkcs1 = info->PrivateKey.pbData; - pkcs1Length = info->PrivateKey.cbData; - } - if (!CryptDecodeObjectEx(X509_ASN_ENCODING, CNG_RSA_PRIVATE_KEY_BLOB, pkcs1, pkcs1Length, - CRYPT_DECODE_ALLOC_FLAG, NULL, &blob, &blobLength)) { - cn1CryptoFailLast("private key is not PKCS#8 DER"); - if (info != 0) { - LocalFree(info); - } - return NULL; +/* Imports a PKCS#8 private key of either supported kind. + * + * The earlier version always decoded CNG_RSA_PRIVATE_KEY_BLOB, so an EC key + * failed to import and ECDSA signing could never work. NCrypt takes PKCS#8 + * directly and reads the algorithm out of the key itself, which covers RSA and + * EC with one path; *isEc reports which arrived so the caller can pick the + * matching padding. + */ +static NCRYPT_KEY_HANDLE cn1PrivateKey(const unsigned char* der, int length, int* isEc) { + NCRYPT_PROV_HANDLE provider = 0; + NCRYPT_KEY_HANDLE key = 0; + SECURITY_STATUS status; + WCHAR algorithm[64]; + DWORD algorithmBytes = 0; + + if (isEc != 0) { + *isEc = 0; + } + status = NCryptOpenStorageProvider(&provider, MS_KEY_STORAGE_PROVIDER, 0); + if (status != ERROR_SUCCESS) { + cn1CryptoFail("key storage provider", (NTSTATUS) status); + return 0; } - status = BCryptOpenAlgorithmProvider(&alg, BCRYPT_RSA_ALGORITHM, NULL, 0); - if (status == STATUS_SUCCESS) { - /* The decoder emits either form depending on which primes it recovered. */ - LPCWSTR blobType = blob->Magic == BCRYPT_RSAFULLPRIVATE_MAGIC - ? BCRYPT_RSAFULLPRIVATE_BLOB : BCRYPT_RSAPRIVATE_BLOB; - status = BCryptImportKeyPair(alg, NULL, blobType, &key, (PUCHAR) blob, blobLength, 0); - if (status != STATUS_SUCCESS) { - cn1CryptoFail("private key import", status); - BCryptCloseAlgorithmProvider(alg, 0); - alg = NULL; - key = NULL; - } + status = NCryptImportKey(provider, 0, NCRYPT_PKCS8_PRIVATE_KEY_BLOB, NULL, &key, + (PBYTE) der, (DWORD) length, NCRYPT_DO_NOT_FINALIZE_FLAG); + if (status != ERROR_SUCCESS) { + /* Retry without the no-finalize hint: ephemeral keys import directly. */ + status = NCryptImportKey(provider, 0, NCRYPT_PKCS8_PRIVATE_KEY_BLOB, NULL, &key, + (PBYTE) der, (DWORD) length, 0); } else { - cn1CryptoFail("RSA provider", status); + status = NCryptFinalizeKey(key, 0); } - LocalFree(blob); - if (info != 0) { - LocalFree(info); + NCryptFreeObject(provider); + if (status != ERROR_SUCCESS || key == 0) { + cn1CryptoFail("private key is not PKCS#8 DER", (NTSTATUS) status); + if (key != 0) { + NCryptFreeObject(key); + } + return 0; + } + if (isEc != 0 && + NCryptGetProperty(key, NCRYPT_ALGORITHM_GROUP_PROPERTY, (PBYTE) algorithm, + sizeof(algorithm), &algorithmBytes, 0) == ERROR_SUCCESS) { + *isEc = wcscmp(algorithm, NCRYPT_ECDSA_ALGORITHM_GROUP) == 0 + || wcscmp(algorithm, NCRYPT_ECDH_ALGORITHM_GROUP) == 0; } - *algOut = alg; return key; } +/* True when an X.509 SubjectPublicKeyInfo carries an elliptic-curve key. */ +static int cn1PublicKeyIsEc(const unsigned char* der, int length) { + CERT_PUBLIC_KEY_INFO* info = 0; + DWORD infoLength = 0; + int isEc = 0; + if (CryptDecodeObjectEx(X509_ASN_ENCODING, X509_PUBLIC_KEY_INFO, der, (DWORD) length, + CRYPT_DECODE_ALLOC_FLAG, NULL, &info, &infoLength)) { + isEc = info->Algorithm.pszObjId != 0 + && strcmp(info->Algorithm.pszObjId, szOID_ECC_PUBLIC_KEY) == 0; + LocalFree(info); + } + return isEc; +} + static LPCWSTR cn1DigestAlgorithm(const char* algorithm) { if (strstr(algorithm, "SHA512") != 0 || strstr(algorithm, "SHA-512") != 0) { return BCRYPT_SHA512_ALGORITHM; @@ -375,9 +406,8 @@ JAVA_OBJECT com_codename1_impl_windows_WindowsNative_rsaCrypt___java_lang_String int keyLength = 0, dataLength = 0; unsigned char* keyDer = cn1Bytes(keyArray, &keyLength); unsigned char* data = cn1Bytes(dataArray, &dataLength); - BCRYPT_ALG_HANDLE alg = NULL; - BCRYPT_KEY_HANDLE key = encrypt ? cn1PublicKey(keyDer, keyLength) - : cn1PrivateKey(keyDer, keyLength, &alg); + BCRYPT_KEY_HANDLE publicKey = encrypt ? cn1PublicKey(keyDer, keyLength) : NULL; + NCRYPT_KEY_HANDLE privateKey = encrypt ? 0 : cn1PrivateKey(keyDer, keyLength, 0); BCRYPT_OAEP_PADDING_INFO oaep; int oaepMode = strstr(mode, "OAEP") != 0; void* padding = 0; @@ -387,19 +417,24 @@ JAVA_OBJECT com_codename1_impl_windows_WindowsNative_rsaCrypt___java_lang_String NTSTATUS status; JAVA_OBJECT result = JAVA_NULL; - if (key == NULL) { + if (encrypt ? (publicKey == NULL) : (privateKey == 0)) { return JAVA_NULL; } if (oaepMode) { memset(&oaep, 0, sizeof(oaep)); - oaep.pszAlgId = strstr(mode, "SHA-1") != 0 ? BCRYPT_SHA1_ALGORITHM : BCRYPT_SHA256_ALGORITHM; + /* CNG derives the mask function from this same digest, and the JCE + * providers behind the JavaSE and Android ports mask with SHA-1 for + * this transformation name. Naming SHA-256 here would make ciphertext + * sealed on those ports undecryptable, so keep the SHA-1 mask. */ + oaep.pszAlgId = BCRYPT_SHA1_ALGORITHM; oaep.pbLabel = NULL; oaep.cbLabel = 0; padding = &oaep; } status = encrypt - ? BCryptEncrypt(key, data, (ULONG) dataLength, padding, NULL, 0, NULL, 0, &outLength, flags) - : BCryptDecrypt(key, data, (ULONG) dataLength, padding, NULL, 0, NULL, 0, &outLength, flags); + ? BCryptEncrypt(publicKey, data, (ULONG) dataLength, padding, NULL, 0, NULL, 0, &outLength, flags) + : (NTSTATUS) NCryptDecrypt(privateKey, (PBYTE) data, (DWORD) dataLength, padding, + NULL, 0, (DWORD*) &outLength, flags); if (status != STATUS_SUCCESS) { cn1CryptoFail("RSA size", status); goto done; @@ -410,8 +445,9 @@ JAVA_OBJECT com_codename1_impl_windows_WindowsNative_rsaCrypt___java_lang_String goto done; } status = encrypt - ? BCryptEncrypt(key, data, (ULONG) dataLength, padding, NULL, 0, out, outLength, &produced, flags) - : BCryptDecrypt(key, data, (ULONG) dataLength, padding, NULL, 0, out, outLength, &produced, flags); + ? BCryptEncrypt(publicKey, data, (ULONG) dataLength, padding, NULL, 0, out, outLength, &produced, flags) + : (NTSTATUS) NCryptDecrypt(privateKey, (PBYTE) data, (DWORD) dataLength, padding, + out, outLength, (DWORD*) &produced, flags); if (status != STATUS_SUCCESS) { cn1CryptoFail(encrypt ? "RSA encrypt" : "RSA decrypt", status); goto done; @@ -420,9 +456,11 @@ JAVA_OBJECT com_codename1_impl_windows_WindowsNative_rsaCrypt___java_lang_String done: free(out); - BCryptDestroyKey(key); - if (alg != NULL) { - BCryptCloseAlgorithmProvider(alg, 0); + if (publicKey != NULL) { + BCryptDestroyKey(publicKey); + } + if (privateKey != 0) { + NCryptFreeObject(privateKey); } return result; } @@ -430,31 +468,35 @@ JAVA_OBJECT com_codename1_impl_windows_WindowsNative_rsaCrypt___java_lang_String JAVA_OBJECT com_codename1_impl_windows_WindowsNative_signData___java_lang_String_byte_1ARRAY_byte_1ARRAY_R_byte_1ARRAY( CODENAME_ONE_THREAD_STATE, JAVA_OBJECT algorithm, JAVA_OBJECT keyArray, JAVA_OBJECT dataArray) { const char* name = algorithm == JAVA_NULL ? "" : stringToUTF8(threadStateData, algorithm); - int keyLength = 0, dataLength = 0; + int keyLength = 0, dataLength = 0, isEc = 0; unsigned char* keyDer = cn1Bytes(keyArray, &keyLength); unsigned char* data = cn1Bytes(dataArray, &dataLength); - BCRYPT_ALG_HANDLE alg = NULL; - BCRYPT_KEY_HANDLE key = cn1PrivateKey(keyDer, keyLength, &alg); + NCRYPT_KEY_HANDLE key = cn1PrivateKey(keyDer, keyLength, &isEc); LPCWSTR digestAlgorithm = cn1DigestAlgorithm(name); unsigned char digest[64]; int digestLength = cn1DigestLength(digestAlgorithm); BCRYPT_PKCS1_PADDING_INFO padding; + /* ECDSA carries no padding parameters; RSA signs with PKCS#1. */ + void* paddingInfo; + DWORD flags; unsigned char* out = 0; - ULONG outLength = 0, produced = 0; - NTSTATUS status; + DWORD outLength = 0, produced = 0; + SECURITY_STATUS status; JAVA_OBJECT result = JAVA_NULL; - if (key == NULL) { + if (key == 0) { return JAVA_NULL; } if (!cn1Digest(digestAlgorithm, data, dataLength, digest, digestLength)) { goto done; } padding.pszAlgId = digestAlgorithm; - status = BCryptSignHash(key, &padding, digest, (ULONG) digestLength, NULL, 0, &outLength, - BCRYPT_PAD_PKCS1); - if (status != STATUS_SUCCESS) { - cn1CryptoFail("sign size", status); + paddingInfo = isEc ? NULL : (void*) &padding; + flags = isEc ? 0 : BCRYPT_PAD_PKCS1; + status = NCryptSignHash(key, paddingInfo, digest, (DWORD) digestLength, NULL, 0, + &outLength, flags); + if (status != ERROR_SUCCESS) { + cn1CryptoFail("sign size", (NTSTATUS) status); goto done; } out = (unsigned char*) malloc((size_t) outLength + 1); @@ -462,20 +504,17 @@ JAVA_OBJECT com_codename1_impl_windows_WindowsNative_signData___java_lang_String cn1CryptoFail("out of memory", 0); goto done; } - status = BCryptSignHash(key, &padding, digest, (ULONG) digestLength, out, outLength, &produced, - BCRYPT_PAD_PKCS1); - if (status != STATUS_SUCCESS) { - cn1CryptoFail("sign", status); + status = NCryptSignHash(key, paddingInfo, digest, (DWORD) digestLength, out, outLength, + &produced, flags); + if (status != ERROR_SUCCESS) { + cn1CryptoFail("sign", (NTSTATUS) status); goto done; } result = cn1WinNewByteArray(threadStateData, out, (int) produced); done: free(out); - BCryptDestroyKey(key); - if (alg != NULL) { - BCryptCloseAlgorithmProvider(alg, 0); - } + NCryptFreeObject(key); return result; } @@ -487,6 +526,9 @@ JAVA_BOOLEAN com_codename1_impl_windows_WindowsNative_verifyData___java_lang_Str unsigned char* keyDer = cn1Bytes(keyArray, &keyLength); unsigned char* data = cn1Bytes(dataArray, &dataLength); unsigned char* signature = cn1Bytes(signatureArray, &signatureLength); + /* CryptImportPublicKeyInfoEx2 handles both key kinds; only the padding + * differs, so read the algorithm out of the SubjectPublicKeyInfo. */ + int isEc = cn1PublicKeyIsEc(keyDer, keyLength); BCRYPT_KEY_HANDLE key = cn1PublicKey(keyDer, keyLength); LPCWSTR digestAlgorithm = cn1DigestAlgorithm(name); unsigned char digest[64]; @@ -500,8 +542,9 @@ JAVA_BOOLEAN com_codename1_impl_windows_WindowsNative_verifyData___java_lang_Str if (cn1Digest(digestAlgorithm, data, dataLength, digest, digestLength)) { padding.pszAlgId = digestAlgorithm; /* A rejected signature is a normal answer here, not a fault. */ - if (BCryptVerifySignature(key, &padding, digest, (ULONG) digestLength, signature, - (ULONG) signatureLength, BCRYPT_PAD_PKCS1) == STATUS_SUCCESS) { + if (BCryptVerifySignature(key, isEc ? NULL : &padding, digest, (ULONG) digestLength, + signature, (ULONG) signatureLength, + isEc ? 0 : BCRYPT_PAD_PKCS1) == STATUS_SUCCESS) { result = JAVA_TRUE; } } diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java index 50ab8c478bc..ea7a06919e3 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java @@ -2674,7 +2674,10 @@ public String getAppHomePath() { if (!dir.endsWith("\\") && !dir.endsWith("/")) { dir += getFileSystemSeparator(); } - return "file://" + dir; + // com.codename1.io.File splits paths on '/' only, so a URL carrying + // backslashes would report the whole native path as a file's name and + // "file:/" as its parent. Native I/O accepts either separator. + return "file://" + dir.replace('\\', '/'); } @Override @@ -2760,19 +2763,38 @@ private static byte[] cryptoResult(byte[] value, String operation) { @Override public void secureRandomBytes(byte[] out) { - if (out != null && out.length > 0) { - WindowsNative.secureRandomBytes(out); + // Fail loudly: KeyGenerator hands this buffer straight back as key + // material, so a quiet return after the platform RNG failed would mint + // a predictable key. + if (out != null && out.length > 0 && !WindowsNative.secureRandomBytes(out)) { + throw new RuntimeException("secure random failed: " + WindowsNative.lastCryptoError()); + } + } + + /// Rejects an initialization vector the mode cannot use. A GCM nonce that + /// is absent repeats across messages under one key, and a short CBC IV is + /// read as a whole block by the platform library. + private static void checkIv(String transformation, byte[] iv) { + String mode = transformation == null ? "" : transformation; + if (mode.indexOf("/GCM/") >= 0) { + if (iv == null || iv.length == 0) { + throw new RuntimeException("AES-GCM requires a nonce"); + } + } else if (mode.indexOf("/ECB/") < 0 && (iv == null || iv.length != 16)) { + throw new RuntimeException("AES-CBC requires a 16 byte initialization vector"); } } @Override public byte[] aesEncrypt(String transformation, byte[] key, byte[] iv, byte[] aad, byte[] plaintext) { + checkIv(transformation, iv); return cryptoResult(WindowsNative.aesCrypt(transformation, true, key, iv, aad, plaintext), "AES encrypt"); } @Override public byte[] aesDecrypt(String transformation, byte[] key, byte[] iv, byte[] aad, byte[] ciphertext) { + checkIv(transformation, iv); return cryptoResult(WindowsNative.aesCrypt(transformation, false, key, iv, aad, ciphertext), "AES decrypt"); } diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java index e5b7633e83f..5843a0eded8 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java @@ -401,7 +401,8 @@ public static native long editStringAt(int x, int y, int w, int h, String text, /* ---------------------------------------------------------- crypto */ - public static native void secureRandomBytes(byte[] out); + /** Fills {@code out} with fresh entropy; false when the platform RNG failed. */ + public static native boolean secureRandomBytes(byte[] out); /** * AES in the mode named by {@code transformation}. For GCM the diff --git a/docs/website/layouts/partials/port-status-feature-status.html b/docs/website/layouts/partials/port-status-feature-status.html index e33765bb811..32153e94ed1 100644 --- a/docs/website/layouts/partials/port-status-feature-status.html +++ b/docs/website/layouts/partials/port-status-feature-status.html @@ -61,17 +61,17 @@ {{- $state = "pass" -}} {{- $mark = "✓" -}} {{- $label = printf "All %d mapped test%s passed%s" $total (cond (eq $total 1) "" "s") $incomplete -}} - {{- else if not $complete -}} - {{- $label = printf "Suite did not finish; %d passed, %d skipped, %d not run" $passed $skipped $notRun -}} {{- else if and $documented (eq (add $passed $skipped) $total) -}} {{- $state = "pass" -}} {{- $mark = "✓" -}} {{- $documentedSkips = $skippedTests -}} {{- if eq $passed 0 -}} - {{- $label = printf "%s skipped by the CI environment, see the skipped-test errata" (delimit $skippedTests ", ") -}} + {{- $label = printf "%s skipped by the CI environment, see the skipped-test errata%s" (delimit $skippedTests ", ") $incomplete -}} {{- else -}} - {{- $label = printf "%d of %d mapped tests passed; %s skipped by the CI environment, see the skipped-test errata" $passed $total (delimit $skippedTests ", ") -}} + {{- $label = printf "%d of %d mapped tests passed; %s skipped by the CI environment, see the skipped-test errata%s" $passed $total (delimit $skippedTests ", ") $incomplete -}} {{- end -}} + {{- else if not $complete -}} + {{- $label = printf "Suite did not finish; %d passed, %d skipped, %d not run" $passed $skipped $notRun -}} {{- else if eq $skipped $total -}} {{- $label = "All mapped tests skipped" -}} {{- end -}} diff --git a/scripts/hellocodenameone/conformance/backfill_port_status.sh b/scripts/hellocodenameone/conformance/backfill_port_status.sh index 2f0ded77ee2..660db3f9d70 100755 --- a/scripts/hellocodenameone/conformance/backfill_port_status.sh +++ b/scripts/hellocodenameone/conformance/backfill_port_status.sh @@ -80,13 +80,30 @@ while IFS= read -r workflow; do run_id="" download_dir="${tmp_dir}/${workflow}" mkdir -p "${download_dir}" - # A run that died before the suite reported uploads no artifact at all, and - # artifacts expire; walk back until one of the recent runs still has reports. + # Merge across candidate runs rather than stopping at the first with any + # artifact: a failed matrix run can upload the report for one leg only, and + # the other ports that workflow owns would then never be considered. + owned="$(jq -r --arg workflow "${workflow}" '.ports[] | select(.workflow == $workflow) | .id' "${MANIFEST}")" for candidate in ${candidates}; do - if gh run download "${candidate}" --pattern 'port-status-*' --dir "${download_dir}" >/dev/null 2>&1; then - run_id="${candidate}" + missing=0 + for port in ${owned}; do + if [ ! -f "${download_dir}/covered-${port}" ]; then + missing=1 + fi + done + if [ "${missing}" -eq 0 ]; then break fi + if gh run download "${candidate}" --pattern 'port-status-*' --dir "${download_dir}/run-${candidate}" >/dev/null 2>&1; then + run_id="${candidate}" + while IFS= read -r downloaded; do + found="$(jq -r '.port // empty' "${downloaded}" 2>/dev/null || true)" + if [ -n "${found}" ] && [ ! -f "${download_dir}/covered-${found}" ]; then + cp "${downloaded}" "${download_dir}/port-status-${found}.json" + : > "${download_dir}/covered-${found}" + fi + done < <(find "${download_dir}/run-${candidate}" -type f -name 'port-status-*.json' | sort) + fi done if [ -z "${run_id}" ]; then echo "No recent ${workflow} run has a port status artifact." >&2 @@ -120,7 +137,7 @@ while IFS= read -r workflow; do echo "Publishing ${port} from run ${run_id} of ${workflow} (${generated})." PORT_STATUS_PUBLISH=1 "${SCRIPT_DIR}/publish_port_status.sh" "${report}" published=$((published + 1)) - done < <(find "${download_dir}" -type f -name 'port-status-*.json' | sort) + done < <(find "${download_dir}" -maxdepth 1 -type f -name 'port-status-*.json' | sort) done < <(jq -r '[.ports[].workflow] | unique | .[]' "${MANIFEST}") echo "Port status sweep: published ${published} report(s), ${skipped} already current." @@ -144,7 +161,10 @@ while IFS= read -r port; do continue fi generated="$(jq -r '.generated_at // empty' "${tmp_dir}/check.json" 2>/dev/null || true)" - age_days="$(python3 - "${generated}" <<'PY' + # Compare elapsed seconds, not whole days: the page marks a report stale the + # moment its exact age passes the window, so flooring to days would keep this + # green for almost another day after the column had already gone stale. + age_seconds="$(python3 - "${generated}" <<'AGE' import sys from datetime import datetime, timezone @@ -154,13 +174,15 @@ try: except ValueError: print(-1) else: - print(int((datetime.now(timezone.utc) - stamp).total_seconds() // 86400)) -PY + print(-1 if stamp.tzinfo is None + else int((datetime.now(timezone.utc) - stamp).total_seconds())) +AGE )" - if [ "${age_days}" -lt 0 ]; then + stale_seconds=$((stale_days * 86400)) + if [ "${age_seconds}" -lt 0 ]; then problems+=("${port}: unreadable generated_at ${generated:-}") - elif [ "${age_days}" -gt "${stale_days}" ]; then - problems+=("${port}: last report is ${age_days} days old (limit ${stale_days})") + elif [ "${age_seconds}" -gt "${stale_seconds}" ]; then + problems+=("${port}: last report is $((age_seconds / 3600)) hours old (limit ${stale_days} days)") fi done < <(jq -r '.ports[].id' "${MANIFEST}") diff --git a/scripts/hellocodenameone/conformance/port_status.py b/scripts/hellocodenameone/conformance/port_status.py index 70e6d382c15..e8a347613c9 100755 --- a/scripts/hellocodenameone/conformance/port_status.py +++ b/scripts/hellocodenameone/conformance/port_status.py @@ -611,6 +611,17 @@ def publishable_report_problems( generated_at = report.get("generated_at") if not isinstance(generated_at, str) or not generated_at: malformed.append("report has no generated_at timestamp") + else: + # Anything unparseable ("unknown") would sail through publication and + # then break both the freshness sweep and the page's own time + # rendering, so classify it as unusable here instead. + try: + stamp = datetime.fromisoformat(generated_at.replace("Z", "+00:00")) + except ValueError: + malformed.append(f"generated_at {generated_at!r} is not a timestamp") + else: + if stamp.tzinfo is None: + malformed.append(f"generated_at {generated_at!r} has no time zone") mapped = test_to_feature(manifest) tests = report.get("tests") diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java index f4de6cd14e0..f5057c3233a 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java @@ -911,7 +911,7 @@ private static void writeCmakeProject(File projectRoot, File srcRoot, String app // dbghelp: lets the last-resort unhandled-exception handler symbolize its // own native backtrace in-process (SymFromAddr against the /Zi .pdb), so a // native crash logs Java/C function names instead of bare RVAs. - writer.append(" target_link_libraries(${PROJECT_NAME} d2d1 dwrite dxgi windowscodecs winhttp ws2_32 user32 gdi32 ole32 oleaut32 uuid mf mfplat mfreadwrite mfuuid shell32 comdlg32 crypt32 bcrypt icu winmm runtimeobject dbghelp)\n"); + writer.append(" target_link_libraries(${PROJECT_NAME} d2d1 dwrite dxgi windowscodecs winhttp ws2_32 user32 gdi32 ole32 oleaut32 uuid mf mfplat mfreadwrite mfuuid shell32 comdlg32 crypt32 bcrypt ncrypt icu winmm runtimeobject dbghelp)\n"); // BrowserComponent is backed by WebView2 (cn1_windows_browser.cpp), // gated on the SDK being present: when WEBVIEW2_SDK_DIR points at a // Microsoft.Web.WebView2 build/native folder we link the static diff --git a/vm/ByteCodeTranslator/src/nativeMethods.m b/vm/ByteCodeTranslator/src/nativeMethods.m index 41794a66d44..f51ac8e1280 100644 --- a/vm/ByteCodeTranslator/src/nativeMethods.m +++ b/vm/ByteCodeTranslator/src/nativeMethods.m @@ -2796,19 +2796,35 @@ JAVA_INT java_util_TimeZone_getTimezoneRawOffset___java_lang_String_R_int(CODENA cn1_timezone_raw_ctx ctx; #ifdef _WIN32 { - /* The raw offset is the standard-time one: sample both solstices and - * take whichever is not in daylight saving (either hemisphere). */ + /* The raw offset is the current standard-time one. Sample both + * solstices of the current year rather than a fixed past year: a zone + * whose base offset changes (Asia/Almaty moved from UTC+6 to UTC+5 + * during 2024, with neither sample flagged as daylight saving) would + * otherwise report its retired offset forever. When neither sample is + * in daylight saving they can still differ, so prefer the later one -- + * that is the rule in force now. */ int januaryOffset = 0, januaryDst = 0, julyOffset = 0, julyDst = 0; - if (cn1WinZoneOffsetMillis(buffer, cn1WinUtcMillis(2024, 1, 1, 43200000), + time_t nowSeconds = time(NULL); + struct tm nowUtc; + int currentYear = 2024; +#ifdef _WIN32 + if (gmtime_s(&nowUtc, &nowSeconds) == 0) { + currentYear = nowUtc.tm_year + 1900; + } +#endif + if (cn1WinZoneOffsetMillis(buffer, cn1WinUtcMillis(currentYear, 1, 1, 43200000), &januaryOffset, &januaryDst) && - cn1WinZoneOffsetMillis(buffer, cn1WinUtcMillis(2024, 7, 1, 43200000), + cn1WinZoneOffsetMillis(buffer, cn1WinUtcMillis(currentYear, 7, 1, 43200000), &julyOffset, &julyDst)) { - if (!januaryDst) { - return januaryOffset; + if (!januaryDst && !julyDst) { + return julyOffset; } if (!julyDst) { return julyOffset; } + if (!januaryDst) { + return januaryOffset; + } return januaryOffset < julyOffset ? januaryOffset : julyOffset; } } diff --git a/vm/JavaAPI/src/java/util/TimeZone.java b/vm/JavaAPI/src/java/util/TimeZone.java index 2fbbaa0cb46..39e6e048c8b 100644 --- a/vm/JavaAPI/src/java/util/TimeZone.java +++ b/vm/JavaAPI/src/java/util/TimeZone.java @@ -254,11 +254,15 @@ private static TimeZone customTimeZone(String ID) { String minutePart = "0"; String secondPart = "0"; if (colon < 0) { - // The colon-less forms are hh, hhmm and hhmmss. - if (digits.length() == 4 || digits.length() == 6) { - hourPart = digits.substring(0, 2); - minutePart = digits.substring(2, 4); - secondPart = digits.length() == 6 ? digits.substring(4, 6) : "0"; + // The colon-less forms are h, hh, hmm, hhmm and hhmmss: the last two + // digits are always the minutes once there are more than two, so a + // one-digit hour ("GMT+012" is UTC+00:12) splits the same way. + int length = digits.length(); + if (length == 3 || length == 4 || length == 6) { + int hourDigits = length == 6 ? 2 : length - 2; + hourPart = digits.substring(0, hourDigits); + minutePart = digits.substring(hourDigits, hourDigits + 2); + secondPart = length == 6 ? digits.substring(4, 6) : "0"; } } else { int secondColon = rest.indexOf(':'); From 81fe244c37f8f8062679ce678b76a021eb69b25c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 31 Jul 2026 19:13:43 +0300 Subject: [PATCH 09/91] Pad OAEP in the Windows port and encode ECDSA signatures as DER Two defects the previous round introduced. CNG's BCRYPT_OAEP_PADDING_INFO names one digest, and CNG uses it for the label hash as well as the mask, so naming SHA-1 there to match the JCE mask downgraded the whole transformation to OAEP-SHA1 -- weaker than documented, and still not interoperable. The padding is now built in the port (SHA-256 label hash, SHA-1 mask, the pairing the JCE providers and the Linux port use) and the key operation runs unpadded, which is the only way CNG can express that combination. NCryptSignHash answers the fixed-width r||s of P1363 while the portable Signature contract, and Jwt.derToJoseEcdsa with it, expects ASN.1 DER. Sign converts to DER and verify converts back, so ECDSA signatures cross between Windows and the other ports. Both encodings were verified against OpenSSL off-device rather than reasoned about: our OAEP block is accepted by OpenSSL's own SHA-256/SHA-1 unpadder and ours accepts theirs, and our DER matches i2d_ECDSA_SIG byte for byte, including the leading-zero trim and the high-bit pad. Co-Authored-By: Claude Opus 5 (1M context) --- .../nativeSources/cn1_windows_crypto.c | 365 ++++++++++++++++-- 1 file changed, 328 insertions(+), 37 deletions(-) diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c index a15924c0ecc..8a29158113f 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c +++ b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c @@ -399,6 +399,209 @@ static int cn1Digest(LPCWSTR algorithm, const unsigned char* data, int length, return 1; } + +/* ------------------------------------------------- OAEP and ECDSA encodings + * + * Two shapes CNG cannot produce on its own: + * + * OAEP -- BCRYPT_OAEP_PADDING_INFO carries one digest, which CNG uses for both + * the label hash and the mask function. The JCE providers behind the JavaSE and + * Android ports pair a SHA-256 label hash with a SHA-1 mask for + * "OAEPWithSHA-256AndMGF1Padding", and the Linux port matches them, so + * ciphertext has to use that pairing to stay readable across ports. Naming one + * digest for both halves either weakens the label hash or breaks interop, so + * the padding is built here and the key operation runs unpadded. + * + * ECDSA -- NCryptSignHash answers the fixed-width r||s of P1363, while the + * portable Signature contract (and Jwt.derToJoseEcdsa) expects ASN.1 DER, so + * signatures are converted in both directions. + */ + +static int cn1Mgf1(LPCWSTR digestAlgorithm, const unsigned char* seed, int seedLength, + unsigned char* mask, int maskLength) { + int digestLength = cn1DigestLength(digestAlgorithm); + unsigned char counted[256]; + unsigned char digest[64]; + int produced = 0; + unsigned int counter = 0; + if (seedLength + 4 > (int) sizeof(counted)) { + return 0; + } + memcpy(counted, seed, (size_t) seedLength); + while (produced < maskLength) { + int chunk = maskLength - produced; + counted[seedLength] = (unsigned char) ((counter >> 24) & 0xff); + counted[seedLength + 1] = (unsigned char) ((counter >> 16) & 0xff); + counted[seedLength + 2] = (unsigned char) ((counter >> 8) & 0xff); + counted[seedLength + 3] = (unsigned char) (counter & 0xff); + if (!cn1Digest(digestAlgorithm, counted, seedLength + 4, digest, digestLength)) { + return 0; + } + if (chunk > digestLength) { + chunk = digestLength; + } + memcpy(mask + produced, digest, (size_t) chunk); + produced += chunk; + counter++; + } + return 1; +} + +/* EME-OAEP encoding of `message` into a `blockLength`-byte block. */ +static int cn1OaepEncode(LPCWSTR labelDigest, LPCWSTR maskDigest, const unsigned char* message, + int messageLength, unsigned char* block, int blockLength) { + int hashLength = cn1DigestLength(labelDigest); + int dbLength = blockLength - hashLength - 1; + unsigned char seed[64]; + unsigned char mask[512]; + int i; + if (dbLength <= 0 || messageLength > dbLength - hashLength - 1 || dbLength > (int) sizeof(mask)) { + cn1CryptoFail("RSA-OAEP message is too long for the key", 0); + return 0; + } + memset(block, 0, (size_t) blockLength); + /* DB = lHash || PS || 0x01 || M, with an empty label. */ + if (!cn1Digest(labelDigest, (const unsigned char*) "", 0, block + 1 + hashLength, hashLength)) { + return 0; + } + block[blockLength - messageLength - 1] = 0x01; + if (messageLength > 0) { + memcpy(block + blockLength - messageLength, message, (size_t) messageLength); + } + if (BCryptGenRandom(NULL, seed, (ULONG) hashLength, BCRYPT_USE_SYSTEM_PREFERRED_RNG) + != STATUS_SUCCESS) { + cn1CryptoFail("RSA-OAEP seed", 0); + return 0; + } + if (!cn1Mgf1(maskDigest, seed, hashLength, mask, dbLength)) { + return 0; + } + for (i = 0; i < dbLength; i++) { + block[1 + hashLength + i] ^= mask[i]; + } + if (!cn1Mgf1(maskDigest, block + 1 + hashLength, dbLength, mask, hashLength)) { + return 0; + } + for (i = 0; i < hashLength; i++) { + block[1 + i] = (unsigned char) (seed[i] ^ mask[i]); + } + return 1; +} + +/* Reverses cn1OaepEncode, writing the recovered message and its length. */ +static int cn1OaepDecode(LPCWSTR labelDigest, LPCWSTR maskDigest, unsigned char* block, + int blockLength, unsigned char* message, int* messageLength) { + int hashLength = cn1DigestLength(labelDigest); + int dbLength = blockLength - hashLength - 1; + unsigned char mask[512]; + unsigned char labelHash[64]; + unsigned char seed[64]; + int i, index; + if (dbLength <= 0 || dbLength > (int) sizeof(mask) || block[0] != 0x00) { + cn1CryptoFail("RSA-OAEP block is malformed", 0); + return 0; + } + if (!cn1Mgf1(maskDigest, block + 1 + hashLength, dbLength, mask, hashLength)) { + return 0; + } + for (i = 0; i < hashLength; i++) { + seed[i] = (unsigned char) (block[1 + i] ^ mask[i]); + } + if (!cn1Mgf1(maskDigest, seed, hashLength, mask, dbLength)) { + return 0; + } + for (i = 0; i < dbLength; i++) { + block[1 + hashLength + i] ^= mask[i]; + } + if (!cn1Digest(labelDigest, (const unsigned char*) "", 0, labelHash, hashLength)) { + return 0; + } + if (memcmp(labelHash, block + 1 + hashLength, (size_t) hashLength) != 0) { + cn1CryptoFail("RSA-OAEP label hash does not match", 0); + return 0; + } + index = 1 + hashLength + hashLength; + while (index < blockLength && block[index] == 0x00) { + index++; + } + if (index >= blockLength || block[index] != 0x01) { + cn1CryptoFail("RSA-OAEP padding is malformed", 0); + return 0; + } + index++; + *messageLength = blockLength - index; + if (*messageLength > 0) { + memcpy(message, block + index, (size_t) *messageLength); + } + return 1; +} + +/* One DER INTEGER holding an unsigned big-endian value. */ +static int cn1DerInteger(const unsigned char* value, int length, unsigned char* out) { + int start = 0; + int written = 0; + int pad; + while (start < length - 1 && value[start] == 0) { + start++; + } + pad = (value[start] & 0x80) != 0 ? 1 : 0; + out[written++] = 0x02; + out[written++] = (unsigned char) (length - start + pad); + if (pad) { + out[written++] = 0x00; + } + memcpy(out + written, value + start, (size_t) (length - start)); + return written + length - start; +} + +/* P1363 r||s (as CNG produces) to the ASN.1 DER sequence the API expects. */ +static int cn1EcdsaToDer(const unsigned char* raw, int rawLength, unsigned char* der) { + int half = rawLength / 2; + unsigned char body[160]; + int bodyLength = 0; + if (rawLength <= 0 || (rawLength & 1) != 0 || half > 66) { + return 0; + } + bodyLength = cn1DerInteger(raw, half, body); + bodyLength += cn1DerInteger(raw + half, half, body + bodyLength); + der[0] = 0x30; + der[1] = (unsigned char) bodyLength; + memcpy(der + 2, body, (size_t) bodyLength); + return bodyLength + 2; +} + +/* Inverse of cn1EcdsaToDer, padding each half back to `half` bytes. */ +static int cn1EcdsaFromDer(const unsigned char* der, int derLength, unsigned char* raw, int half) { + int index = 2; + int part; + if (derLength < 8 || der[0] != 0x30) { + return 0; + } + memset(raw, 0, (size_t) (half * 2)); + for (part = 0; part < 2; part++) { + int length, start, copy; + if (index + 2 > derLength || der[index] != 0x02) { + return 0; + } + length = der[index + 1]; + index += 2; + if (index + length > derLength) { + return 0; + } + start = 0; + while (start < length - 1 && der[index + start] == 0) { + start++; + } + copy = length - start; + if (copy > half) { + return 0; + } + memcpy(raw + part * half + (half - copy), der + index + start, (size_t) copy); + index += length; + } + return 1; +} + JAVA_OBJECT com_codename1_impl_windows_WindowsNative_rsaCrypt___java_lang_String_boolean_byte_1ARRAY_byte_1ARRAY_R_byte_1ARRAY( CODENAME_ONE_THREAD_STATE, JAVA_OBJECT transformation, JAVA_BOOLEAN encrypt, JAVA_OBJECT keyArray, JAVA_OBJECT dataArray) { @@ -408,54 +611,113 @@ JAVA_OBJECT com_codename1_impl_windows_WindowsNative_rsaCrypt___java_lang_String unsigned char* data = cn1Bytes(dataArray, &dataLength); BCRYPT_KEY_HANDLE publicKey = encrypt ? cn1PublicKey(keyDer, keyLength) : NULL; NCRYPT_KEY_HANDLE privateKey = encrypt ? 0 : cn1PrivateKey(keyDer, keyLength, 0); - BCRYPT_OAEP_PADDING_INFO oaep; int oaepMode = strstr(mode, "OAEP") != 0; - void* padding = 0; - ULONG flags = oaepMode ? BCRYPT_PAD_OAEP : BCRYPT_PAD_PKCS1; + LPCWSTR labelDigest = strstr(mode, "SHA-1") != 0 ? BCRYPT_SHA1_ALGORITHM : BCRYPT_SHA256_ALGORITHM; unsigned char* out = 0; + unsigned char* block = 0; ULONG outLength = 0, produced = 0; + DWORD modulusBytes = 0, propertyBytes = 0; NTSTATUS status; JAVA_OBJECT result = JAVA_NULL; if (encrypt ? (publicKey == NULL) : (privateKey == 0)) { return JAVA_NULL; } + if (oaepMode) { - memset(&oaep, 0, sizeof(oaep)); - /* CNG derives the mask function from this same digest, and the JCE - * providers behind the JavaSE and Android ports mask with SHA-1 for - * this transformation name. Naming SHA-256 here would make ciphertext - * sealed on those ports undecryptable, so keep the SHA-1 mask. */ - oaep.pszAlgId = BCRYPT_SHA1_ALGORITHM; - oaep.pbLabel = NULL; - oaep.cbLabel = 0; - padding = &oaep; - } - status = encrypt - ? BCryptEncrypt(publicKey, data, (ULONG) dataLength, padding, NULL, 0, NULL, 0, &outLength, flags) - : (NTSTATUS) NCryptDecrypt(privateKey, (PBYTE) data, (DWORD) dataLength, padding, - NULL, 0, (DWORD*) &outLength, flags); - if (status != STATUS_SUCCESS) { - cn1CryptoFail("RSA size", status); - goto done; - } - out = (unsigned char*) malloc((size_t) outLength + 1); - if (out == 0) { - cn1CryptoFail("out of memory", 0); - goto done; - } - status = encrypt - ? BCryptEncrypt(publicKey, data, (ULONG) dataLength, padding, NULL, 0, out, outLength, &produced, flags) - : (NTSTATUS) NCryptDecrypt(privateKey, (PBYTE) data, (DWORD) dataLength, padding, - out, outLength, (DWORD*) &produced, flags); - if (status != STATUS_SUCCESS) { - cn1CryptoFail(encrypt ? "RSA encrypt" : "RSA decrypt", status); - goto done; + /* CNG's padding info names one digest for both the label hash and the + * mask, so it cannot express the SHA-256 label with the SHA-1 mask that + * the JCE providers -- and therefore the JavaSE, Android and Linux + * ports -- use for this transformation. Pad here and run the key + * operation raw so ciphertext stays readable across ports. */ + ULONG bits = 0; + if (encrypt) { + status = BCryptGetProperty(publicKey, BCRYPT_KEY_STRENGTH, (PUCHAR) &bits, + sizeof(bits), &propertyBytes, 0); + if (status != STATUS_SUCCESS) { + cn1CryptoFail("RSA key size", status); + goto done; + } + modulusBytes = bits / 8; + } else { + if (NCryptGetProperty(privateKey, NCRYPT_LENGTH_PROPERTY, (PBYTE) &bits, + sizeof(bits), &propertyBytes, 0) != ERROR_SUCCESS) { + cn1CryptoFail("RSA key size", 0); + goto done; + } + modulusBytes = bits / 8; + } + block = (unsigned char*) malloc((size_t) modulusBytes + 1); + if (block == 0) { + cn1CryptoFail("out of memory", 0); + goto done; + } + if (encrypt) { + if (!cn1OaepEncode(labelDigest, BCRYPT_SHA1_ALGORITHM, data, dataLength, block, + (int) modulusBytes)) { + goto done; + } + out = (unsigned char*) malloc((size_t) modulusBytes + 1); + if (out == 0) { + cn1CryptoFail("out of memory", 0); + goto done; + } + status = BCryptEncrypt(publicKey, block, modulusBytes, NULL, NULL, 0, out, + modulusBytes, &produced, BCRYPT_PAD_NONE); + if (status != STATUS_SUCCESS) { + cn1CryptoFail("RSA encrypt", status); + goto done; + } + } else { + DWORD recovered = 0; + int messageLength = 0; + if (NCryptDecrypt(privateKey, data, (DWORD) dataLength, NULL, block, modulusBytes, + &recovered, NCRYPT_NO_PADDING_FLAG) != ERROR_SUCCESS) { + cn1CryptoFail("RSA decrypt", 0); + goto done; + } + out = (unsigned char*) malloc((size_t) modulusBytes + 1); + if (out == 0) { + cn1CryptoFail("out of memory", 0); + goto done; + } + if (!cn1OaepDecode(labelDigest, BCRYPT_SHA1_ALGORITHM, block, (int) modulusBytes, + out, &messageLength)) { + goto done; + } + produced = (ULONG) messageLength; + } + } else { + ULONG flags = BCRYPT_PAD_PKCS1; + status = encrypt + ? BCryptEncrypt(publicKey, data, (ULONG) dataLength, NULL, NULL, 0, NULL, 0, + &outLength, flags) + : (NTSTATUS) NCryptDecrypt(privateKey, data, (DWORD) dataLength, NULL, NULL, 0, + (DWORD*) &outLength, flags); + if (status != STATUS_SUCCESS) { + cn1CryptoFail("RSA size", status); + goto done; + } + out = (unsigned char*) malloc((size_t) outLength + 1); + if (out == 0) { + cn1CryptoFail("out of memory", 0); + goto done; + } + status = encrypt + ? BCryptEncrypt(publicKey, data, (ULONG) dataLength, NULL, NULL, 0, out, + outLength, &produced, flags) + : (NTSTATUS) NCryptDecrypt(privateKey, data, (DWORD) dataLength, NULL, out, + outLength, (DWORD*) &produced, flags); + if (status != STATUS_SUCCESS) { + cn1CryptoFail(encrypt ? "RSA encrypt" : "RSA decrypt", status); + goto done; + } } result = cn1WinNewByteArray(threadStateData, out, (int) produced); done: free(out); + free(block); if (publicKey != NULL) { BCryptDestroyKey(publicKey); } @@ -510,7 +772,19 @@ JAVA_OBJECT com_codename1_impl_windows_WindowsNative_signData___java_lang_String cn1CryptoFail("sign", (NTSTATUS) status); goto done; } - result = cn1WinNewByteArray(threadStateData, out, (int) produced); + if (isEc) { + /* NCrypt answers the fixed-width r||s of P1363; the portable Signature + * contract, and Jwt.derToJoseEcdsa with it, expects ASN.1 DER. */ + unsigned char der[160]; + int derLength = cn1EcdsaToDer(out, (int) produced, der); + if (derLength <= 0) { + cn1CryptoFail("ECDSA signature encoding", 0); + goto done; + } + result = cn1WinNewByteArray(threadStateData, der, derLength); + } else { + result = cn1WinNewByteArray(threadStateData, out, (int) produced); + } done: free(out); @@ -540,11 +814,28 @@ JAVA_BOOLEAN com_codename1_impl_windows_WindowsNative_verifyData___java_lang_Str return JAVA_FALSE; } if (cn1Digest(digestAlgorithm, data, dataLength, digest, digestLength)) { + unsigned char raw[132]; + const unsigned char* toVerify = signature; + ULONG toVerifyLength = (ULONG) signatureLength; + int usable = 1; padding.pszAlgId = digestAlgorithm; + if (isEc) { + /* Signatures arrive as DER; CNG verifies the P1363 pair. The half + * width follows the key size, which for the supported curves is + * the digest the caller named. */ + int half = cn1DigestLength(digestAlgorithm); + if (half == 20) { + half = 32; + } + usable = cn1EcdsaFromDer(signature, signatureLength, raw, half); + toVerify = raw; + toVerifyLength = (ULONG) (half * 2); + } /* A rejected signature is a normal answer here, not a fault. */ - if (BCryptVerifySignature(key, isEc ? NULL : &padding, digest, (ULONG) digestLength, - signature, (ULONG) signatureLength, - isEc ? 0 : BCRYPT_PAD_PKCS1) == STATUS_SUCCESS) { + if (usable && BCryptVerifySignature(key, isEc ? NULL : &padding, digest, + (ULONG) digestLength, (PUCHAR) toVerify, + toVerifyLength, + isEc ? 0 : BCRYPT_PAD_PKCS1) == STATUS_SUCCESS) { result = JAVA_TRUE; } } From 9ef1a7bb019f8e3a3c250a3aead0974266917597 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 31 Jul 2026 19:18:33 +0300 Subject: [PATCH 10/91] Pad OAEP in the Windows port and encode ECDSA signatures as DER Two defects the previous round introduced. CNG's BCRYPT_OAEP_PADDING_INFO names one digest, and CNG uses it for the label hash as well as the mask, so naming SHA-1 there to match the JCE mask downgraded the whole transformation to OAEP-SHA1 -- weaker than documented, and still not interoperable. The padding is now built in the port (SHA-256 label hash, SHA-1 mask, the pairing the JCE providers and the Linux port use) and the key operation runs unpadded, which is the only way CNG can express that combination. NCryptSignHash answers the fixed-width r||s of P1363 while the portable Signature contract, and Jwt.derToJoseEcdsa with it, expects ASN.1 DER. Sign converts to DER and verify converts back, so ECDSA signatures cross between Windows and the other ports. Both encodings were verified against OpenSSL off-device rather than reasoned about: our OAEP block is accepted by OpenSSL's own SHA-256/SHA-1 unpadder and ours accepts theirs, and our DER matches i2d_ECDSA_SIG byte for byte, including the leading-zero trim and the high-bit pad. The UWP template file picked up the project header, which the gate requires of any file this branch touches. Co-Authored-By: Claude Opus 5 (1M context) --- .../UWP/VSProjectTemplate/UWPApp/App.xaml.cs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/Ports/UWP/VSProjectTemplate/UWPApp/App.xaml.cs b/Ports/UWP/VSProjectTemplate/UWPApp/App.xaml.cs index 5786dc36acb..46dbd125738 100644 --- a/Ports/UWP/VSProjectTemplate/UWPApp/App.xaml.cs +++ b/Ports/UWP/VSProjectTemplate/UWPApp/App.xaml.cs @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2026, 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. + */ using IKVM.Attributes; using IKVM.Internal; using System; From 879f159fa7ee2e701b41cc4a99291eed354792bf Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 1 Aug 2026 05:05:30 +0300 Subject: [PATCH 11/91] Resolve Windows ICU at runtime so the clean target still links The clean-target build compiles nativeMethods.m for Windows but links only its own library set, so naming the ICU entry points directly left u_strFromUTF8, ucal_open, ucal_setMillis, ucal_get and ucal_close undefined and took ten of its integration tests down with it. icu.dll is now opened with LoadLibrary and the four calendar entry points resolved through GetProcAddress, with the UTF-16 conversion done by MultiByteToWideChar instead of ICU's own. Nothing includes or links icu.lib any more, so the minimal SDK layout the clean target builds against is enough, and a host without ICU falls back to the C runtime rather than failing to load. The POSIX path is unchanged and still passes its probe through the clean target, custom offset IDs included. Co-Authored-By: Claude Opus 5 (1M context) --- .../tools/translator/ByteCodeTranslator.java | 2 +- vm/ByteCodeTranslator/src/nativeMethods.m | 74 +++++++++++++++---- 2 files changed, 61 insertions(+), 15 deletions(-) diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java index f5057c3233a..bdd68811d6e 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java @@ -911,7 +911,7 @@ private static void writeCmakeProject(File projectRoot, File srcRoot, String app // dbghelp: lets the last-resort unhandled-exception handler symbolize its // own native backtrace in-process (SymFromAddr against the /Zi .pdb), so a // native crash logs Java/C function names instead of bare RVAs. - writer.append(" target_link_libraries(${PROJECT_NAME} d2d1 dwrite dxgi windowscodecs winhttp ws2_32 user32 gdi32 ole32 oleaut32 uuid mf mfplat mfreadwrite mfuuid shell32 comdlg32 crypt32 bcrypt ncrypt icu winmm runtimeobject dbghelp)\n"); + writer.append(" target_link_libraries(${PROJECT_NAME} d2d1 dwrite dxgi windowscodecs winhttp ws2_32 user32 gdi32 ole32 oleaut32 uuid mf mfplat mfreadwrite mfuuid shell32 comdlg32 crypt32 bcrypt ncrypt winmm runtimeobject dbghelp)\n"); // BrowserComponent is backed by WebView2 (cn1_windows_browser.cpp), // gated on the SDK being present: when WEBVIEW2_SDK_DIR points at a // Microsoft.Web.WebView2 build/native folder we link the static diff --git a/vm/ByteCodeTranslator/src/nativeMethods.m b/vm/ByteCodeTranslator/src/nativeMethods.m index f51ac8e1280..4c9e166fdfe 100644 --- a/vm/ByteCodeTranslator/src/nativeMethods.m +++ b/vm/ByteCodeTranslator/src/nativeMethods.m @@ -2618,29 +2618,75 @@ static void cn1_with_timezone(const char* zoneId, void (*func)(void*), void* ctx * instant, or reports failure so the caller can fall back. */ #ifdef _WIN32 -#include +/* + * ICU ships with Windows 10 1703 and later as icu.dll, and its calendar speaks + * IANA identifiers and knows the daylight rules for a given instant. It is + * resolved at runtime rather than linked: the clean target builds against a + * minimal SDK layout that need not carry icu.lib or , and a host + * without ICU degrades to the fallback below instead of failing to load. + * + * The two enum values used here are fixed by ICU's stable C API -- + * UCAL_GREGORIAN, and the ZONE_OFFSET / DST_OFFSET calendar fields. + */ +#define CN1_UCAL_GREGORIAN 1 +#define CN1_UCAL_ZONE_OFFSET 15 +#define CN1_UCAL_DST_OFFSET 16 + +typedef void* CN1UCalendar; + +static CN1UCalendar (__cdecl *cn1_ucal_open)(const WCHAR*, int32_t, const char*, int32_t, int32_t*); +static void (__cdecl *cn1_ucal_setMillis)(CN1UCalendar, double, int32_t*); +static int32_t (__cdecl *cn1_ucal_get)(const CN1UCalendar, int32_t, int32_t*); +static void (__cdecl *cn1_ucal_close)(CN1UCalendar); +static int cn1IcuResolved; + +static int cn1IcuAvailable(void) { + HMODULE icu; + if (cn1IcuResolved != 0) { + return cn1IcuResolved > 0; + } + cn1IcuResolved = -1; + icu = LoadLibraryA("icu.dll"); + if (icu == NULL) { + return 0; + } + cn1_ucal_open = (CN1UCalendar (__cdecl *)(const WCHAR*, int32_t, const char*, int32_t, int32_t*)) + GetProcAddress(icu, "ucal_open"); + cn1_ucal_setMillis = (void (__cdecl *)(CN1UCalendar, double, int32_t*)) + GetProcAddress(icu, "ucal_setMillis"); + cn1_ucal_get = (int32_t (__cdecl *)(const CN1UCalendar, int32_t, int32_t*)) + GetProcAddress(icu, "ucal_get"); + cn1_ucal_close = (void (__cdecl *)(CN1UCalendar)) GetProcAddress(icu, "ucal_close"); + if (cn1_ucal_open == 0 || cn1_ucal_setMillis == 0 || cn1_ucal_get == 0 || cn1_ucal_close == 0) { + return 0; + } + cn1IcuResolved = 1; + return 1; +} +/* Total offset (zone plus daylight) for a zone at an instant, 0 when ICU + * cannot answer -- the caller then keeps the C runtime's reply. */ static int cn1WinZoneOffsetMillis(const char* zoneId, long long millis, int* offsetOut, int* dstOut) { - UErrorCode status = U_ZERO_ERROR; - UChar zone[128]; - UCalendar* cal; + WCHAR zone[128]; + CN1UCalendar cal; + int32_t status = 0; int32_t zoneOffset, dstOffset; - if (zoneId == 0 || zoneId[0] == 0) { + if (zoneId == 0 || zoneId[0] == 0 || !cn1IcuAvailable()) { return 0; } - u_strFromUTF8(zone, (int32_t) (sizeof(zone) / sizeof(zone[0])), NULL, zoneId, -1, &status); - if (U_FAILURE(status)) { + if (MultiByteToWideChar(CP_UTF8, 0, zoneId, -1, zone, + (int) (sizeof(zone) / sizeof(zone[0]))) == 0) { return 0; } - cal = ucal_open(zone, -1, "en_US", UCAL_GREGORIAN, &status); - if (U_FAILURE(status) || cal == 0) { + cal = cn1_ucal_open(zone, -1, "en_US", CN1_UCAL_GREGORIAN, &status); + if (status > 0 || cal == 0) { return 0; } - ucal_setMillis(cal, (UDate) millis, &status); - zoneOffset = ucal_get(cal, UCAL_ZONE_OFFSET, &status); - dstOffset = ucal_get(cal, UCAL_DST_OFFSET, &status); - ucal_close(cal); - if (U_FAILURE(status)) { + cn1_ucal_setMillis(cal, (double) millis, &status); + zoneOffset = cn1_ucal_get(cal, CN1_UCAL_ZONE_OFFSET, &status); + dstOffset = cn1_ucal_get(cal, CN1_UCAL_DST_OFFSET, &status); + cn1_ucal_close(cal); + if (status > 0) { return 0; } if (offsetOut != 0) { From e04582e5dc1dea5f96d251844164d0d02dab9dd1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 1 Aug 2026 05:08:35 +0300 Subject: [PATCH 12/91] Keep the wedge watchdog off the JavaScript port That port schedules its threads cooperatively on the browser's single thread, so the watchdog waking every second to poll competed with the suite it was meant to be watching: the JavaScript job stopped finishing and burned its whole forty-minute budget, exiting 5 on the harness timeout. The watchdog now returns immediately on HTML5. Its harness already bounds the run, and the wedges it exists to name -- Media360Panorama on Linux, CalendarApiTest on Windows -- are on the native desktop ports. Co-Authored-By: Claude Opus 5 (1M context) --- .../hellocodenameone/tests/Cn1ssDeviceRunner.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java index eba4b56d72f..9b5fcc41dd0 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java @@ -509,6 +509,15 @@ public void runSuite() { private static final long WEDGE_GRACE_MS = 30000L; private void startWedgeWatchdog() { + // Not on HTML5. That port schedules its threads cooperatively on the + // browser's single thread, so a second thread waking every second to + // poll competes with the suite it is supposed to be watching -- it cost + // the JavaScript job its whole 40 minute budget. Its harness already + // bounds the run, and the wedges this watchdog exists to name are on + // the native desktop ports. + if ("HTML5".equals(Display.getInstance().getPlatformName())) { + return; + } // Display.startThread rather than a bare Thread: the ports only support // the thread surface the bytecode compliance gate allows, and this hands // back a CodenameOneThread that the platform names and reaps for us. From 887e31cce7a626fd8cafe20e0c1a90639078432e Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:18:37 +0700 Subject: [PATCH 13/91] Keep the Windows natives and the runner's lambda numbering intact Two self-inflicted CI breakages from the previous round, both in shared plumbing that the local checks did not exercise. Windows natives did not compile The ICU lookup added for named time zones used WCHAR, HMODULE, LoadLibraryA and MultiByteToWideChar directly in nativeMethods, which never sees : cn1_win_compat.h is deliberately free of it, because cn1_globals.h pulls that header into every translated compilation unit and leaking the Win32 macro soup that broadly collides with generated symbol names. The lookup now lives in cn1_win_compat.c, the one translation unit that does include and whose stated job is mapping the runtime's API onto Win32, and nativeMethods calls it through a single declaration. This is what took down clean-target on both architectures, the x64 PE cross-compile, the screenshot capture and the suite exe build. Verified by running the cross-compile locally rather than in CI: CleanTargetIntegrationTest#crossCompilesWindowsExeWithXwin now passes on this machine against an xwin-laid-out SDK with clang-cl and lld-link, which is the same test and toolchain the Linux cross-compile job runs. The wedge watchdog stopped the JavaScript suite after one test The JavaScript port hand-binds three of Cn1ssDeviceRunner's Runnable lambdas by translated id -- lambda_1_run through _3_run in port.js -- and the translator numbers lambdas in declaration order within the class. The watchdog was written as a lambda ahead of the ones in runNextTest, so every id shifted by one: javap confirms lambda$startWedgeWatchdog$2 displacing runNextTest$2 to $3, awaitTestCompletion$3 to $4 and finalizeTest$4 to $5. The ids still resolved, so nothing reported an error -- the bridge that polls for a test's completion was simply handed the lambda that starts a test, and the suite stopped advancing after its first one. The run log shows it plainly: on master lambda1RunBridge takes index 0 and lambda2RunBridge index 1, while on the broken build lambda2RunBridge takes index 0. The watchdog body is now a named inner class, which has its own method namespace and leaves that numbering alone, with a comment on it saying why it must not be turned back into a lambda. Compiling the runner against master and against this change emits an identical set of seven synthetic lambdas. The HTML5 early return stays, but the reasoning in its comment was wrong and has been corrected: the previous round blamed the watchdog thread for competing with the cooperative scheduler, and the symptom was unchanged by skipping it -- one screenshot, then a forty minute timeout -- because the renumbering, not the thread, was the cause. Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/Cn1ssDeviceRunner.java | 44 ++++++---- vm/ByteCodeTranslator/src/cn1_win_compat.c | 84 +++++++++++++++++++ vm/ByteCodeTranslator/src/cn1_win_compat.h | 9 ++ vm/ByteCodeTranslator/src/nativeMethods.m | 83 ++---------------- 4 files changed, 128 insertions(+), 92 deletions(-) diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java index 9b5fcc41dd0..9c2a546a782 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java @@ -508,20 +508,20 @@ public void runSuite() { /// published as "never run" with nothing saying why. private static final long WEDGE_GRACE_MS = 30000L; - private void startWedgeWatchdog() { - // Not on HTML5. That port schedules its threads cooperatively on the - // browser's single thread, so a second thread waking every second to - // poll competes with the suite it is supposed to be watching -- it cost - // the JavaScript job its whole 40 minute budget. Its harness already - // bounds the run, and the wedges this watchdog exists to name are on - // the native desktop ports. - if ("HTML5".equals(Display.getInstance().getPlatformName())) { - return; - } - // Display.startThread rather than a bare Thread: the ports only support - // the thread surface the bytecode compliance gate allows, and this hands - // back a CodenameOneThread that the platform names and reaps for us. - Display.getInstance().startThread(() -> { + /// The watchdog body. + /// + /// This is a named class and MUST NOT be rewritten as a lambda. The + /// JavaScript port hand-binds three of this class's Runnable lambdas by + /// translated id -- Cn1ssDeviceRunner_lambda_1_run through _3_run, see + /// bindCiFallback in port.js -- and the translator numbers lambdas in their + /// declaration order within the class. A lambda declared here, ahead of the + /// ones in runNextTest, shifts every one of those ids by one, so the bridge + /// that polls for a test's completion gets handed the lambda that starts a + /// test instead. The ids still resolve, so nothing reports an error: the + /// suite simply stops advancing after its first test. A named class has its + /// own method namespace and leaves that numbering alone. + private final class WedgeWatchdog implements Runnable { + public void run() { while (!suiteFinished) { try { Thread.sleep(1000L); @@ -551,7 +551,21 @@ private void startWedgeWatchdog() { // blame. return; } - }, "cn1ss-wedge-watchdog").start(); + } + } + + private void startWedgeWatchdog() { + // Not on HTML5. That port drives the suite from the browser's single + // thread through the bridges described on WedgeWatchdog, and its harness + // already bounds the run and force-advances a stalled dispatch. The + // wedges this watchdog exists to name are on the native desktop ports. + if ("HTML5".equals(Display.getInstance().getPlatformName())) { + return; + } + // Display.startThread rather than a bare Thread: the ports only support + // the thread surface the bytecode compliance gate allows, and this hands + // back a CodenameOneThread that the platform names and reaps for us. + Display.getInstance().startThread(new WedgeWatchdog(), "cn1ss-wedge-watchdog").start(); } /// Set once the suite is over so the watchdog thread returns instead of diff --git a/vm/ByteCodeTranslator/src/cn1_win_compat.c b/vm/ByteCodeTranslator/src/cn1_win_compat.c index efcfd12fee2..20527ae1945 100644 --- a/vm/ByteCodeTranslator/src/cn1_win_compat.c +++ b/vm/ByteCodeTranslator/src/cn1_win_compat.c @@ -288,4 +288,88 @@ int gettimeofday(struct timeval* tv, void* tz) { return 0; } +/* + * IANA time zone offsets. + * + * The Microsoft C runtime only understands the "EST5EDT" form of TZ, not an + * IANA identifier, and its struct tm carries no GMT offset at all -- so the + * POSIX path the runtime uses elsewhere reports zero for every named zone. + * Windows ships ICU as icu.dll (Windows 10 1703 and later), whose calendar + * speaks IANA identifiers directly and knows the daylight rules for the + * instant being asked about. + * + * ICU is resolved at runtime rather than linked: the clean target builds + * against a minimal SDK layout that need not carry icu.lib or , and a + * host without ICU degrades to the caller's fallback instead of failing to + * load. The two enum values used here are fixed by ICU's stable C API -- + * UCAL_GREGORIAN, and the ZONE_OFFSET / DST_OFFSET calendar fields. + */ +#define CN1_UCAL_GREGORIAN 1 +#define CN1_UCAL_ZONE_OFFSET 15 +#define CN1_UCAL_DST_OFFSET 16 + +typedef void* CN1UCalendar; + +static CN1UCalendar (__cdecl *cn1_ucal_open)(const WCHAR*, int32_t, const char*, int32_t, int32_t*); +static void (__cdecl *cn1_ucal_setMillis)(CN1UCalendar, double, int32_t*); +static int32_t (__cdecl *cn1_ucal_get)(const CN1UCalendar, int32_t, int32_t*); +static void (__cdecl *cn1_ucal_close)(CN1UCalendar); +static int cn1IcuResolved; + +static int cn1IcuAvailable(void) { + HMODULE icu; + if (cn1IcuResolved != 0) { + return cn1IcuResolved > 0; + } + cn1IcuResolved = -1; + icu = LoadLibraryA("icu.dll"); + if (icu == NULL) { + return 0; + } + cn1_ucal_open = (CN1UCalendar (__cdecl *)(const WCHAR*, int32_t, const char*, int32_t, int32_t*)) + GetProcAddress(icu, "ucal_open"); + cn1_ucal_setMillis = (void (__cdecl *)(CN1UCalendar, double, int32_t*)) + GetProcAddress(icu, "ucal_setMillis"); + cn1_ucal_get = (int32_t (__cdecl *)(const CN1UCalendar, int32_t, int32_t*)) + GetProcAddress(icu, "ucal_get"); + cn1_ucal_close = (void (__cdecl *)(CN1UCalendar)) GetProcAddress(icu, "ucal_close"); + if (cn1_ucal_open == 0 || cn1_ucal_setMillis == 0 || cn1_ucal_get == 0 || cn1_ucal_close == 0) { + return 0; + } + cn1IcuResolved = 1; + return 1; +} + +int cn1_win_zone_offset_millis(const char* zoneId, long long millis, int* offsetOut, int* dstOut) { + WCHAR zone[128]; + CN1UCalendar cal; + int32_t status = 0; + int32_t zoneOffset, dstOffset; + if (zoneId == 0 || zoneId[0] == 0 || !cn1IcuAvailable()) { + return 0; + } + if (MultiByteToWideChar(CP_UTF8, 0, zoneId, -1, zone, + (int) (sizeof(zone) / sizeof(zone[0]))) == 0) { + return 0; + } + cal = cn1_ucal_open(zone, -1, "en_US", CN1_UCAL_GREGORIAN, &status); + if (status > 0 || cal == 0) { + return 0; + } + cn1_ucal_setMillis(cal, (double) millis, &status); + zoneOffset = cn1_ucal_get(cal, CN1_UCAL_ZONE_OFFSET, &status); + dstOffset = cn1_ucal_get(cal, CN1_UCAL_DST_OFFSET, &status); + cn1_ucal_close(cal); + if (status > 0) { + return 0; + } + if (offsetOut != 0) { + *offsetOut = (int) (zoneOffset + dstOffset); + } + if (dstOut != 0) { + *dstOut = dstOffset != 0; + } + return 1; +} + #endif /* _WIN32 */ diff --git a/vm/ByteCodeTranslator/src/cn1_win_compat.h b/vm/ByteCodeTranslator/src/cn1_win_compat.h index 599e02f5446..1ff1db0c903 100644 --- a/vm/ByteCodeTranslator/src/cn1_win_compat.h +++ b/vm/ByteCodeTranslator/src/cn1_win_compat.h @@ -143,6 +143,15 @@ int gettimeofday(struct timeval* tv, void* tz); system clock must not stretch or cut the remaining sleep. */ long long cn1_monotonic_micros(void); +/* --- IANA time zone offsets --- + Answers the total offset (zone plus daylight) in milliseconds for an IANA + zone identifier at an instant, writing the offset to offsetOut and whether + daylight time is in effect to dstOut. Returns non-zero on success, and zero + when the platform cannot answer -- the caller then keeps whatever the C + runtime reported. Lives in cn1_win_compat.c because resolving it needs + , which this header keeps out of translated units. */ +int cn1_win_zone_offset_millis(const char* zoneId, long long millis, int* offsetOut, int* dstOut); + /* --- environment / time.h POSIX helpers absent from MSVC --- Thin static-inline wrappers over the MSVC equivalents; used by the date / timezone runtime in nativeMethods. */ diff --git a/vm/ByteCodeTranslator/src/nativeMethods.m b/vm/ByteCodeTranslator/src/nativeMethods.m index 4c9e166fdfe..26394d14cf4 100644 --- a/vm/ByteCodeTranslator/src/nativeMethods.m +++ b/vm/ByteCodeTranslator/src/nativeMethods.m @@ -2618,84 +2618,13 @@ static void cn1_with_timezone(const char* zoneId, void (*func)(void*), void* ctx * instant, or reports failure so the caller can fall back. */ #ifdef _WIN32 -/* - * ICU ships with Windows 10 1703 and later as icu.dll, and its calendar speaks - * IANA identifiers and knows the daylight rules for a given instant. It is - * resolved at runtime rather than linked: the clean target builds against a - * minimal SDK layout that need not carry icu.lib or , and a host - * without ICU degrades to the fallback below instead of failing to load. - * - * The two enum values used here are fixed by ICU's stable C API -- - * UCAL_GREGORIAN, and the ZONE_OFFSET / DST_OFFSET calendar fields. - */ -#define CN1_UCAL_GREGORIAN 1 -#define CN1_UCAL_ZONE_OFFSET 15 -#define CN1_UCAL_DST_OFFSET 16 - -typedef void* CN1UCalendar; - -static CN1UCalendar (__cdecl *cn1_ucal_open)(const WCHAR*, int32_t, const char*, int32_t, int32_t*); -static void (__cdecl *cn1_ucal_setMillis)(CN1UCalendar, double, int32_t*); -static int32_t (__cdecl *cn1_ucal_get)(const CN1UCalendar, int32_t, int32_t*); -static void (__cdecl *cn1_ucal_close)(CN1UCalendar); -static int cn1IcuResolved; - -static int cn1IcuAvailable(void) { - HMODULE icu; - if (cn1IcuResolved != 0) { - return cn1IcuResolved > 0; - } - cn1IcuResolved = -1; - icu = LoadLibraryA("icu.dll"); - if (icu == NULL) { - return 0; - } - cn1_ucal_open = (CN1UCalendar (__cdecl *)(const WCHAR*, int32_t, const char*, int32_t, int32_t*)) - GetProcAddress(icu, "ucal_open"); - cn1_ucal_setMillis = (void (__cdecl *)(CN1UCalendar, double, int32_t*)) - GetProcAddress(icu, "ucal_setMillis"); - cn1_ucal_get = (int32_t (__cdecl *)(const CN1UCalendar, int32_t, int32_t*)) - GetProcAddress(icu, "ucal_get"); - cn1_ucal_close = (void (__cdecl *)(CN1UCalendar)) GetProcAddress(icu, "ucal_close"); - if (cn1_ucal_open == 0 || cn1_ucal_setMillis == 0 || cn1_ucal_get == 0 || cn1_ucal_close == 0) { - return 0; - } - cn1IcuResolved = 1; - return 1; -} - -/* Total offset (zone plus daylight) for a zone at an instant, 0 when ICU - * cannot answer -- the caller then keeps the C runtime's reply. */ +/* Total offset (zone plus daylight) for a zone at an instant, 0 when the + * platform cannot answer -- the caller then keeps the C runtime's reply. + * The lookup itself lives in cn1_win_compat.c, the one translation unit that + * may include ; keeping it out of here is what lets the clean + * target compile this file against a minimal SDK layout. */ static int cn1WinZoneOffsetMillis(const char* zoneId, long long millis, int* offsetOut, int* dstOut) { - WCHAR zone[128]; - CN1UCalendar cal; - int32_t status = 0; - int32_t zoneOffset, dstOffset; - if (zoneId == 0 || zoneId[0] == 0 || !cn1IcuAvailable()) { - return 0; - } - if (MultiByteToWideChar(CP_UTF8, 0, zoneId, -1, zone, - (int) (sizeof(zone) / sizeof(zone[0]))) == 0) { - return 0; - } - cal = cn1_ucal_open(zone, -1, "en_US", CN1_UCAL_GREGORIAN, &status); - if (status > 0 || cal == 0) { - return 0; - } - cn1_ucal_setMillis(cal, (double) millis, &status); - zoneOffset = cn1_ucal_get(cal, CN1_UCAL_ZONE_OFFSET, &status); - dstOffset = cn1_ucal_get(cal, CN1_UCAL_DST_OFFSET, &status); - cn1_ucal_close(cal); - if (status > 0) { - return 0; - } - if (offsetOut != 0) { - *offsetOut = (int) (zoneOffset + dstOffset); - } - if (dstOut != 0) { - *dstOut = dstOffset != 0; - } - return 1; + return cn1_win_zone_offset_millis(zoneId, millis, offsetOut, dstOut); } /* Milliseconds since the epoch for a set of UTC calendar fields. */ From 3eee8ab37a03ffd96551f5c85cfc6d218ffa1afd Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:27:16 +0700 Subject: [PATCH 14/91] Address the third review round: OAEP, ECDSA DER and shared error state Windows OAEP could not use the larger RSA keys MGF1 staged seed || counter in a 256-byte buffer, but its second call seeds from the whole masked DB -- 351 bytes for a 3072-bit key and 479 for a 4096-bit one, both of which KeyGenerator.rsa() offers -- so OAEP simply failed on the two higher-security sizes. The seed and counter now go to the hash object in two BCryptHashData calls, so nothing has to hold them together. Windows OAEP unpadding was a padding oracle cn1OaepDecode returned early with a distinct message for a nonzero leading byte, a bad label hash and a missing delimiter, and cryptoResult puts that text in the CryptoException an application may surface. Distinguishing those causes is what the adaptive attacks OAEP exists to stop rely on. Every check on the decrypted block now folds into one accumulator, the delimiter scan runs the full block instead of stopping at the first hit, and one generic "decryption failed" is reported. Only the block geometry, which follows the key rather than the ciphertext, still bails early. ECDSA DER was wrong for P-521 A P-521 signature body is about 138 bytes, so DER requires the long form (0x81 then the length); a single byte there sets the high bit, which Jwt.derToJoseEcdsa and every conforming parser read as a long-form marker. Encoding now emits the long form and parsing accepts it. Verification also took the coordinate width from the named digest, making ES512 64 bytes when P-521 needs 66, so a valid signature reached CNG as a 128-byte pair instead of 132; the width now comes off the key via BCRYPT_KEY_STRENGTH. Verified off-device against OpenSSL as an oracle, with the padding and encoding helpers extracted from the shipped source by the harness rather than copied: OAEP round trips at 2048/3072/4096 bits, our padding accepted by RSA_padding_check_PKCS1_OAEP_mgf1 and OpenSSL's accepted by ours, tampering rejected with the generic message, and P-256/384/521 signatures parsed by d2i_ECDSA_SIG with i2d output read back. The same harness run against the previous code reproduces all four defects. Error buffers were shared across threads cn1LastIoError, cn1WinLastIoError, cn1CryptoError and cn1WinCryptoError were process-wide, so two threads failing at once could overwrite each other and lastIoError/lastCryptoError could report an unrelated call's reason. All four are now per-thread. ICU resolution raced cn1IcuAvailable published "resolved" before loading the DLL, so a second thread asking for a zone at startup would conclude ICU was unavailable and fall through to the CRT, which cannot read IANA identifiers -- that query intermittently answered UTC. Resolution now runs under a lock and the flag is written only once the function pointers are in place. The page validator failed when nothing was skipped Requiring at least one documented-skip cell tied the website build to what the live reports happened to skip, so a round in which every port ran everything -- the best possible outcome -- would have failed it. The assertion is now that the marker and the cell's own label agree, which still catches a renderer that drops one (verified by stripping the class from a built page) without depending on skips existing. Co-Authored-By: Claude Opus 5 (1M context) --- .../nativeSources/cn1_linux_crypto.c | 5 +- Ports/LinuxPort/nativeSources/cn1_linux_io.c | 5 +- .../nativeSources/cn1_windows_crypto.c | 203 +++++++++++++----- .../nativeSources/cn1_windows_io.c | 4 +- scripts/website/validate_port_status.mjs | 15 +- vm/ByteCodeTranslator/src/cn1_win_compat.c | 47 ++-- 6 files changed, 206 insertions(+), 73 deletions(-) diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c b/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c index 9025e45e099..424810157e4 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c @@ -51,7 +51,10 @@ #define CN1_GCM_TAG_BYTES 16 -static char cn1CryptoError[512]; +/* Per-thread: crypto failures on different threads would otherwise overwrite + * each other and lastCryptoError() could answer with an unrelated call's + * message. */ +static __thread char cn1CryptoError[512]; static void cn1CryptoFail(const char* what) { unsigned long code = ERR_get_error(); diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_io.c b/Ports/LinuxPort/nativeSources/cn1_linux_io.c index 86c179e5c86..2afadd4168d 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_io.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_io.c @@ -171,7 +171,10 @@ static const char* cn1JStr(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT s) { /* Reason the last open failed. The port reports "could not open X" from Java, * where errno is long gone; without this the only way to tell a missing * directory from a permission problem was another CI round trip. */ -static char cn1LastIoError[512]; +/* Per-thread: two threads failing an open at once would otherwise overwrite + * each other and lastIoError() could report the wrong reason, or a torn + * mixture of both. */ +static __thread char cn1LastIoError[512]; static void cn1RecordIoError(const char* path) { snprintf(cn1LastIoError, sizeof(cn1LastIoError), "%s", strerror(errno)); diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c index 8a29158113f..8a0995db236 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c +++ b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c @@ -54,7 +54,10 @@ #define CN1_GCM_TAG_BYTES 16 -static char cn1WinCryptoError[512]; +/* Per-thread: crypto failures on different threads would otherwise overwrite + * each other and lastCryptoError() could answer with an unrelated call's + * message. */ +static __declspec(thread) char cn1WinCryptoError[512]; static void cn1CryptoFail(const char* what, NTSTATUS status) { _snprintf(cn1WinCryptoError, sizeof(cn1WinCryptoError), "%s (status 0x%08lx)", what, @@ -417,24 +420,57 @@ static int cn1Digest(LPCWSTR algorithm, const unsigned char* data, int length, * signatures are converted in both directions. */ +/* One digest over two buffers in sequence, without joining them first. + * + * MGF1's second call seeds from the whole masked DB -- 351 bytes for a + * 3072-bit key and 479 for a 4096-bit one, both of which KeyGenerator.rsa() + * supports -- so the seed cannot be staged in a buffer sized for a hash. This + * feeds the seed and the counter to the hash object directly instead. */ +static int cn1DigestPair(LPCWSTR algorithm, const unsigned char* first, int firstLength, + const unsigned char* second, int secondLength, + unsigned char* digest, int digestLength) { + BCRYPT_ALG_HANDLE alg = NULL; + BCRYPT_HASH_HANDLE hash = NULL; + NTSTATUS status = BCryptOpenAlgorithmProvider(&alg, algorithm, NULL, 0); + if (status != STATUS_SUCCESS) { + cn1CryptoFail("digest provider", status); + return 0; + } + status = BCryptCreateHash(alg, &hash, NULL, 0, NULL, 0, 0); + if (status == STATUS_SUCCESS) { + status = BCryptHashData(hash, (PUCHAR) first, (ULONG) firstLength, 0); + } + if (status == STATUS_SUCCESS) { + status = BCryptHashData(hash, (PUCHAR) second, (ULONG) secondLength, 0); + } + if (status == STATUS_SUCCESS) { + status = BCryptFinishHash(hash, digest, (ULONG) digestLength, 0); + } + if (hash != NULL) { + BCryptDestroyHash(hash); + } + BCryptCloseAlgorithmProvider(alg, 0); + if (status != STATUS_SUCCESS) { + cn1CryptoFail("digest", status); + return 0; + } + return 1; +} + static int cn1Mgf1(LPCWSTR digestAlgorithm, const unsigned char* seed, int seedLength, unsigned char* mask, int maskLength) { int digestLength = cn1DigestLength(digestAlgorithm); - unsigned char counted[256]; + unsigned char counter[4]; unsigned char digest[64]; int produced = 0; - unsigned int counter = 0; - if (seedLength + 4 > (int) sizeof(counted)) { - return 0; - } - memcpy(counted, seed, (size_t) seedLength); + unsigned int count = 0; while (produced < maskLength) { int chunk = maskLength - produced; - counted[seedLength] = (unsigned char) ((counter >> 24) & 0xff); - counted[seedLength + 1] = (unsigned char) ((counter >> 16) & 0xff); - counted[seedLength + 2] = (unsigned char) ((counter >> 8) & 0xff); - counted[seedLength + 3] = (unsigned char) (counter & 0xff); - if (!cn1Digest(digestAlgorithm, counted, seedLength + 4, digest, digestLength)) { + counter[0] = (unsigned char) ((count >> 24) & 0xff); + counter[1] = (unsigned char) ((count >> 16) & 0xff); + counter[2] = (unsigned char) ((count >> 8) & 0xff); + counter[3] = (unsigned char) (count & 0xff); + if (!cn1DigestPair(digestAlgorithm, seed, seedLength, counter, 4, digest, digestLength)) { return 0; } if (chunk > digestLength) { @@ -442,7 +478,7 @@ static int cn1Mgf1(LPCWSTR digestAlgorithm, const unsigned char* seed, int seedL } memcpy(mask + produced, digest, (size_t) chunk); produced += chunk; - counter++; + count++; } return 1; } @@ -453,9 +489,13 @@ static int cn1OaepEncode(LPCWSTR labelDigest, LPCWSTR maskDigest, const unsigned int hashLength = cn1DigestLength(labelDigest); int dbLength = blockLength - hashLength - 1; unsigned char seed[64]; - unsigned char mask[512]; + unsigned char mask[1024]; int i; - if (dbLength <= 0 || messageLength > dbLength - hashLength - 1 || dbLength > (int) sizeof(mask)) { + if (dbLength <= 0 || dbLength > (int) sizeof(mask)) { + cn1CryptoFail("RSA-OAEP block does not fit the key", 0); + return 0; + } + if (messageLength > dbLength - hashLength - 1) { cn1CryptoFail("RSA-OAEP message is too long for the key", 0); return 0; } @@ -488,19 +528,43 @@ static int cn1OaepEncode(LPCWSTR labelDigest, LPCWSTR maskDigest, const unsigned return 1; } -/* Reverses cn1OaepEncode, writing the recovered message and its length. */ +/* All ones when a == b, zero otherwise, without branching on the values. */ +static unsigned int cn1CtEqMask(unsigned int a, unsigned int b) { + unsigned int diff = a ^ b; + /* 1 when diff is nonzero, 0 when it is zero; minus one turns that into a + * full-width mask without a comparison the compiler can branch on. */ + unsigned int nonZero = (diff | (0u - diff)) >> 31; + return nonZero - 1u; +} + +/* Reverses cn1OaepEncode, writing the recovered message and its length. + * + * Every check on the decrypted block feeds one accumulator and the function + * reports a single generic failure, rather than returning early with a + * distinct message per cause. An application that decrypts attacker-chosen + * ciphertext and surfaces the exception (WindowsImplementation.cryptoResult + * puts this text in it) would otherwise hand back which of the leading byte, + * the label hash or the delimiter was wrong -- and telling those apart is + * enough to mount the adaptive attacks OAEP exists to prevent. Only the + * block geometry, which follows the key and not the ciphertext, is allowed to + * bail early. */ static int cn1OaepDecode(LPCWSTR labelDigest, LPCWSTR maskDigest, unsigned char* block, int blockLength, unsigned char* message, int* messageLength) { int hashLength = cn1DigestLength(labelDigest); int dbLength = blockLength - hashLength - 1; - unsigned char mask[512]; + unsigned char mask[1024]; unsigned char labelHash[64]; unsigned char seed[64]; - int i, index; - if (dbLength <= 0 || dbLength > (int) sizeof(mask) || block[0] != 0x00) { - cn1CryptoFail("RSA-OAEP block is malformed", 0); + int i; + unsigned int bad = 0; + unsigned int seenDelimiter = 0; + unsigned int messageStart = 0; + if (dbLength <= 0 || dbLength > (int) sizeof(mask)) { + cn1CryptoFail("RSA-OAEP block does not fit the key", 0); return 0; } + /* The leading byte must be zero; fold it in rather than returning here. */ + bad |= (unsigned int) block[0]; if (!cn1Mgf1(maskDigest, block + 1 + hashLength, dbLength, mask, hashLength)) { return 0; } @@ -516,22 +580,29 @@ static int cn1OaepDecode(LPCWSTR labelDigest, LPCWSTR maskDigest, unsigned char* if (!cn1Digest(labelDigest, (const unsigned char*) "", 0, labelHash, hashLength)) { return 0; } - if (memcmp(labelHash, block + 1 + hashLength, (size_t) hashLength) != 0) { - cn1CryptoFail("RSA-OAEP label hash does not match", 0); - return 0; - } - index = 1 + hashLength + hashLength; - while (index < blockLength && block[index] == 0x00) { - index++; - } - if (index >= blockLength || block[index] != 0x01) { - cn1CryptoFail("RSA-OAEP padding is malformed", 0); + for (i = 0; i < hashLength; i++) { + bad |= (unsigned int) (labelHash[i] ^ block[1 + hashLength + i]); + } + /* Walk the whole padding: zeros until one 0x01, then the message. The loop + * never stops early, so its timing follows the key size alone. */ + for (i = 1 + hashLength + hashLength; i < blockLength; i++) { + unsigned int value = block[i]; + unsigned int isDelimiter = cn1CtEqMask(value, 0x01); + unsigned int isZero = cn1CtEqMask(value, 0x00); + unsigned int firstDelimiter = isDelimiter & ~seenDelimiter; + messageStart |= ((unsigned int) (i + 1)) & firstDelimiter; + /* Ahead of the delimiter nothing but zeros is allowed. */ + bad |= ~seenDelimiter & ~isDelimiter & ~isZero; + seenDelimiter |= isDelimiter; + } + bad |= ~seenDelimiter; /* no delimiter anywhere in the block */ + if (bad != 0) { + cn1CryptoFail("RSA-OAEP decryption failed", 0); return 0; } - index++; - *messageLength = blockLength - index; + *messageLength = blockLength - (int) messageStart; if (*messageLength > 0) { - memcpy(message, block + index, (size_t) *messageLength); + memcpy(message, block + messageStart, (size_t) *messageLength); } return 1; } @@ -554,29 +625,62 @@ static int cn1DerInteger(const unsigned char* value, int length, unsigned char* return written + length - start; } -/* P1363 r||s (as CNG produces) to the ASN.1 DER sequence the API expects. */ +/* P1363 r||s (as CNG produces) to the ASN.1 DER sequence the API expects. + * + * P-521 coordinates are 66 bytes each, so the sequence body runs to about 138 + * bytes and DER requires the long form (0x81 followed by the length) for + * anything over 127. A single length byte there sets the high bit, which + * Jwt.derToJoseEcdsa and every conforming parser read as a long-form marker, + * and the ES512 signature is rejected. */ static int cn1EcdsaToDer(const unsigned char* raw, int rawLength, unsigned char* der) { int half = rawLength / 2; unsigned char body[160]; int bodyLength = 0; + int written = 0; if (rawLength <= 0 || (rawLength & 1) != 0 || half > 66) { return 0; } bodyLength = cn1DerInteger(raw, half, body); bodyLength += cn1DerInteger(raw + half, half, body + bodyLength); - der[0] = 0x30; - der[1] = (unsigned char) bodyLength; - memcpy(der + 2, body, (size_t) bodyLength); - return bodyLength + 2; + der[written++] = 0x30; + if (bodyLength > 127) { + der[written++] = 0x81; + } + der[written++] = (unsigned char) bodyLength; + memcpy(der + written, body, (size_t) bodyLength); + return written + bodyLength; +} + +/* Coordinate width of an EC key in bytes: 66 for P-521, whose 521 bits do not + * fill a whole byte count that any digest length happens to match. */ +static int cn1EcCoordinateBytes(BCRYPT_KEY_HANDLE key) { + DWORD bits = 0; + ULONG copied = 0; + if (BCryptGetProperty(key, BCRYPT_KEY_STRENGTH, (PUCHAR) &bits, sizeof(bits), &copied, 0) + != STATUS_SUCCESS || bits == 0) { + return 0; + } + return (int) ((bits + 7) / 8); } /* Inverse of cn1EcdsaToDer, padding each half back to `half` bytes. */ static int cn1EcdsaFromDer(const unsigned char* der, int derLength, unsigned char* raw, int half) { - int index = 2; + int index = 1; int part; if (derLength < 8 || der[0] != 0x30) { return 0; } + /* Accept the long form the P-521 body needs, and only that one extra + * length byte -- a sequence of two integers never runs past 255 bytes. */ + if (der[index] == 0x81) { + index++; + if (index >= derLength) { + return 0; + } + } else if ((der[index] & 0x80) != 0) { + return 0; + } + index++; memset(raw, 0, (size_t) (half * 2)); for (part = 0; part < 2; part++) { int length, start, copy; @@ -585,7 +689,7 @@ static int cn1EcdsaFromDer(const unsigned char* der, int derLength, unsigned cha } length = der[index + 1]; index += 2; - if (index + length > derLength) { + if (length <= 0 || index + length > derLength) { return 0; } start = 0; @@ -821,15 +925,18 @@ JAVA_BOOLEAN com_codename1_impl_windows_WindowsNative_verifyData___java_lang_Str padding.pszAlgId = digestAlgorithm; if (isEc) { /* Signatures arrive as DER; CNG verifies the P1363 pair. The half - * width follows the key size, which for the supported curves is - * the digest the caller named. */ - int half = cn1DigestLength(digestAlgorithm); - if (half == 20) { - half = 32; + * width is the curve's, read off the key -- deriving it from the + * named digest gets P-521 wrong, whose coordinates are 66 bytes + * while SHA-512 is 64, so a valid ES512 signature would be handed + * to CNG as a 128-byte pair instead of the required 132. */ + int half = cn1EcCoordinateBytes(key); + if (half <= 0 || half * 2 > (int) sizeof(raw)) { + usable = 0; + } else { + usable = cn1EcdsaFromDer(signature, signatureLength, raw, half); + toVerify = raw; + toVerifyLength = (ULONG) (half * 2); } - usable = cn1EcdsaFromDer(signature, signatureLength, raw, half); - toVerify = raw; - toVerifyLength = (ULONG) (half * 2); } /* A rejected signature is a normal answer here, not a fault. */ if (usable && BCryptVerifySignature(key, isEc ? NULL : &padding, digest, diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_io.c b/Ports/WindowsPort/nativeSources/cn1_windows_io.c index a3a0528d6cb..96b000d487c 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_io.c +++ b/Ports/WindowsPort/nativeSources/cn1_windows_io.c @@ -85,7 +85,9 @@ static JAVA_OBJECT cn1WinWideToJavaString(CODENAME_ONE_THREAD_STATE, const WCHAR /* Reason the last open failed. The port reports "could not open X" from Java, * where the thread's last-error value is long gone; without this the only way * to tell a missing directory from a sharing violation was another CI run. */ -static DWORD cn1WinLastIoError; +/* Per-thread: two threads failing an open at once would otherwise overwrite + * each other and lastIoError() could report the wrong reason. */ +static __declspec(thread) DWORD cn1WinLastIoError; JAVA_OBJECT com_codename1_impl_windows_WindowsNative_lastIoError___R_java_lang_String(CODENAME_ONE_THREAD_STATE) { char buffer[256]; diff --git a/scripts/website/validate_port_status.mjs b/scripts/website/validate_port_status.mjs index 62418bb169c..41a4cd68877 100644 --- a/scripts/website/validate_port_status.mjs +++ b/scripts/website/validate_port_status.mjs @@ -169,9 +169,20 @@ function validate() { // A cell may only read as a pass while carrying a skip when the errata name // that exact test, so a green mark can never outrun its explanation. + // + // Whether any such cell exists at all depends on what the live reports + // skipped this round: a round in which every port ran everything is a good + // outcome, not a page defect, so requiring at least one would fail the + // website build for the best possible reason. What must hold instead is + // that the marker and the cell's own label agree. Stale cells are exempt + // because staleness drops the marker while keeping the label it replaced. const notedCells = primaryCellTags.filter((cell) => /\bhas-documented-skip\b/.test(cell)); - if (notedCells.length === 0) { - fail("no cell reports a documented skip; the errata and the table disagree"); + const labelledSkipCells = primaryCellTags.filter((cell) => + !/\bis-stale\b/.test(attribute(cell, "class")) && + /skipped by the CI environment/i.test(attribute(cell, "title"))); + if (notedCells.length !== labelledSkipCells.length) { + fail(`documented-skip markers and cell labels disagree: ${notedCells.length} marked, ` + + `${labelledSkipCells.length} labelled`); } for (const cell of notedCells) { const skips = attribute(cell, "data-documented-skip").split(/\s+/).filter(Boolean); diff --git a/vm/ByteCodeTranslator/src/cn1_win_compat.c b/vm/ByteCodeTranslator/src/cn1_win_compat.c index 20527ae1945..829b337295b 100644 --- a/vm/ByteCodeTranslator/src/cn1_win_compat.c +++ b/vm/ByteCodeTranslator/src/cn1_win_compat.c @@ -315,29 +315,36 @@ static void (__cdecl *cn1_ucal_setMillis)(CN1UCalendar, double, int32_t*); static int32_t (__cdecl *cn1_ucal_get)(const CN1UCalendar, int32_t, int32_t*); static void (__cdecl *cn1_ucal_close)(CN1UCalendar); static int cn1IcuResolved; +/* Resolution runs under a lock, and cn1IcuResolved is written only once the + * function pointers are in place. Publishing "in progress" first, as a plain + * flag test would, lets a second thread asking for a zone at startup see a + * nonzero value, conclude ICU is unavailable and fall through to the CRT -- + * which cannot read IANA identifiers, so that one query intermittently + * answers UTC. */ +static SRWLOCK cn1IcuLock = SRWLOCK_INIT; static int cn1IcuAvailable(void) { - HMODULE icu; - if (cn1IcuResolved != 0) { - return cn1IcuResolved > 0; - } - cn1IcuResolved = -1; - icu = LoadLibraryA("icu.dll"); - if (icu == NULL) { - return 0; - } - cn1_ucal_open = (CN1UCalendar (__cdecl *)(const WCHAR*, int32_t, const char*, int32_t, int32_t*)) - GetProcAddress(icu, "ucal_open"); - cn1_ucal_setMillis = (void (__cdecl *)(CN1UCalendar, double, int32_t*)) - GetProcAddress(icu, "ucal_setMillis"); - cn1_ucal_get = (int32_t (__cdecl *)(const CN1UCalendar, int32_t, int32_t*)) - GetProcAddress(icu, "ucal_get"); - cn1_ucal_close = (void (__cdecl *)(CN1UCalendar)) GetProcAddress(icu, "ucal_close"); - if (cn1_ucal_open == 0 || cn1_ucal_setMillis == 0 || cn1_ucal_get == 0 || cn1_ucal_close == 0) { - return 0; + int resolved; + AcquireSRWLockExclusive(&cn1IcuLock); + if (cn1IcuResolved == 0) { + HMODULE icu = LoadLibraryA("icu.dll"); + int ok = 0; + if (icu != NULL) { + cn1_ucal_open = (CN1UCalendar (__cdecl *)(const WCHAR*, int32_t, const char*, int32_t, int32_t*)) + GetProcAddress(icu, "ucal_open"); + cn1_ucal_setMillis = (void (__cdecl *)(CN1UCalendar, double, int32_t*)) + GetProcAddress(icu, "ucal_setMillis"); + cn1_ucal_get = (int32_t (__cdecl *)(const CN1UCalendar, int32_t, int32_t*)) + GetProcAddress(icu, "ucal_get"); + cn1_ucal_close = (void (__cdecl *)(CN1UCalendar)) GetProcAddress(icu, "ucal_close"); + ok = cn1_ucal_open != 0 && cn1_ucal_setMillis != 0 + && cn1_ucal_get != 0 && cn1_ucal_close != 0; + } + cn1IcuResolved = ok ? 1 : -1; } - cn1IcuResolved = 1; - return 1; + resolved = cn1IcuResolved; + ReleaseSRWLockExclusive(&cn1IcuLock); + return resolved > 0; } int cn1_win_zone_offset_millis(const char* zoneId, long long millis, int* offsetOut, int* dstOut) { From 4244c0c680635a9a3b18a33212511cf16f4b21fb Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:10:05 +0700 Subject: [PATCH 15/91] Deliver a health result on the EDT even to a listener that arrives late HealthEdtDeliveryTest.aFacadeActionDeliversOnTheEdt was failing on this PR. The bug is not this branch's -- EdtResult.java is byte-identical to master -- but it fails the branch's CI, and a result that lands on a different thread from one run to the next is a real defect rather than a flaky test. EdtResult promises one outcome delivered on the EDT, and delivers on half of it: it hops completion to the EDT, but AsyncResource runs a callback that is registered against an already-finished resource immediately, on whichever thread registered it. Health.openHealthSettings() completes inside the call, so which thread the callback saw came down to whether the EDT had drained the hop before the caller reached onResult. Same call, either answer. Every callback is now wrapped so it runs on the EDT wherever it is reached from. A callback already on the EDT sees isEdt() and runs inline, so this costs a branch and never an extra queued runnable. A caller that names an EasyThread is asking for delivery there specifically, which is the point of that overload, so those are left alone. The existing test only caught this when it lost the race. The new one waits for isDone() before it listens, making the late case the only case, so it fails every time against the bug rather than occasionally. Test helpers that assumed inline delivery Five test classes each carried a copy of an errorOf helper that registered an except callback and read the error straight back, which the change makes return nothing. They now share one implementation in HealthAwait, which waits for the delivery the same way settled() waits for the outcome. It registers on both sides: plenty of callers ask errorOf about a resource that succeeded and expect null, and exactly one of ready/except ever fires, so waiting on except alone burned the full limit on every successful call -- which is also what made concurrentStartsYieldOneSession miss its own deadline with eight threads each paying it. The waiting flags are atomics because the callback runs on the EDT and the value is read from the test thread. Verified with the whole core-unittests suite rather than a health subset: 4677 tests, no failures, through `verify` so the static analysis gates ran too. The narrower -Dtest='Health*' selection I used first does not match LocalHealthPersistenceTest, LocalHealthStoreTest or WorkoutAndNutritionTest, which is how the first two attempts at this looked green while breaking 11 and then 14 tests. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/impl/health/EdtResult.java | 70 +++++++++++++++++++ .../com/codename1/health/HealthAwait.java | 67 ++++++++++++++++++ .../health/HealthEdtDeliveryTest.java | 50 +++++++++++++ .../codename1/health/HealthFallbackTest.java | 14 +--- .../com/codename1/health/HealthWireTest.java | 12 +--- .../health/LocalHealthPersistenceTest.java | 13 +--- .../health/LocalHealthStoreTest.java | 17 +---- .../health/WorkoutAndNutritionTest.java | 13 +--- 8 files changed, 198 insertions(+), 58 deletions(-) diff --git a/CodenameOne/src/com/codename1/impl/health/EdtResult.java b/CodenameOne/src/com/codename1/impl/health/EdtResult.java index 780a1d2c79e..29800dc6370 100644 --- a/CodenameOne/src/com/codename1/impl/health/EdtResult.java +++ b/CodenameOne/src/com/codename1/impl/health/EdtResult.java @@ -23,6 +23,9 @@ package com.codename1.impl.health; import com.codename1.ui.Display; +import com.codename1.util.AsyncResource; +import com.codename1.util.EasyThread; +import com.codename1.util.SuccessCallback; /// The resource every public health operation hands back: one outcome, /// delivered on the EDT. @@ -65,6 +68,73 @@ public void error(Throwable t) { Display.getInstance().callSerially(new Deliver(this, null, t)); } + /// Completing on the EDT is only half of the guarantee. `AsyncResource` + /// runs a callback registered against an already-finished resource + /// immediately, on whichever thread registered it, so the outcome landing + /// on the EDT does not mean the callback does. + /// + /// That is the ordinary case for the operations that resolve before they + /// return -- the facade's `openHealthSettings` and `openProviderSetup` + /// complete inside the call -- where the delivery thread came down to + /// whether the EDT had drained the hop above before the caller got as far + /// as `onResult`. The same call arrived on the EDT or off it from one run + /// to the next. + /// + /// Wrapping every callback closes that half. A callback already reached on + /// the EDT sees `isEdt()` and runs inline, so this costs a branch and + /// never an extra queued runnable. Both arities funnel through the + /// `EasyThread` overloads, so overriding these two covers `ready`, + /// `except` and `onResult` alike. + /// + /// A caller who names an `EasyThread` is asking for delivery there + /// specifically, which is the point of that overload, so those are left + /// alone -- the default is the EDT, not an override of an explicit choice. + @Override + public AsyncResource ready(SuccessCallback callback, EasyThread t) { + return super.ready(t == null ? new OnEdt(callback) : callback, t); + } + + @Override + public AsyncResource except(SuccessCallback callback, EasyThread t) { + return super.except(t == null ? new OnEdt(callback) : callback, t); + } + + /// Named rather than anonymous so the hop carries no synthetic reference + /// to anything enclosing (SpotBugs `SIC_INNER_SHOULD_BE_STATIC_ANON`). + private static final class OnEdt implements SuccessCallback { + + private final SuccessCallback delegate; + + OnEdt(SuccessCallback delegate) { + this.delegate = delegate; + } + + @Override + public void onSucess(V value) { + if (Display.getInstance().isEdt()) { + delegate.onSucess(value); + return; + } + Display.getInstance().callSerially(new Invoke(delegate, value)); + } + } + + private static final class Invoke implements Runnable { + + private final SuccessCallback delegate; + private final V value; + + Invoke(SuccessCallback delegate, V value) { + this.delegate = delegate; + this.value = value; + } + + @Override + public void run() { + delegate.onSucess(value); + } + } + /// Named rather than anonymous so the hop carries no synthetic reference /// to anything enclosing (SpotBugs `SIC_INNER_SHOULD_BE_STATIC_ANON`). private static final class Deliver implements Runnable { diff --git a/maven/core-unittests/src/test/java/com/codename1/health/HealthAwait.java b/maven/core-unittests/src/test/java/com/codename1/health/HealthAwait.java index 9eeb5fc32a2..e38775d7eb2 100644 --- a/maven/core-unittests/src/test/java/com/codename1/health/HealthAwait.java +++ b/maven/core-unittests/src/test/java/com/codename1/health/HealthAwait.java @@ -24,6 +24,10 @@ import com.codename1.ui.CN; import com.codename1.util.AsyncResource; +import com.codename1.util.SuccessCallback; + +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -69,6 +73,69 @@ public void run() { return res; } + /** + * Settles `res` and returns the failure it carries, or null if it + * succeeded. + * + *

Five test classes each had their own copy of this, all of them + * registering an {@code except} callback and reading the result straight + * back on the assumption that a callback attached to an already-failed + * resource fires inline on the registering thread. That assumption no + * longer holds: health results are delivered on the EDT whether the + * listener arrives before the outcome or after it, so an off-EDT caller + * gets the failure queued. Waiting for it is the same thing {@link + * #settled} does one step earlier, and having one copy means the next + * change to the delivery rule has one place to land.

+ */ + static Throwable errorOf(AsyncResource res) { + settled(res); + // Atomics rather than a one-element array: the callback runs on the + // EDT and the value is read from the test thread, so an unguarded + // field would be a data race that only misbehaves under CI timing. + final AtomicReference err = new AtomicReference(); + final AtomicBoolean delivered = new AtomicBoolean(); + res.except(new SuccessCallback() { + public void onSucess(Throwable t) { + err.set(t); + delivered.set(true); + } + }); + // Both sides, because plenty of callers ask this of a resource that + // succeeded and expect null back. Only one of the two ever fires, so + // waiting on `except` alone would wait out the whole limit on every + // successful call. + res.ready(new SuccessCallback() { + public void onSucess(T value) { + delivered.set(true); + } + }); + if (!delivered.get()) { + if (CN.isEdt()) { + CN.invokeAndBlock(new Runnable() { + public void run() { + pollDelivered(delivered); + } + }); + } else { + pollDelivered(delivered); + } + } + assertTrue(delivered.get(), + "the failure must be delivered rather than hang"); + return err.get(); + } + + private static void pollDelivered(AtomicBoolean delivered) { + long deadline = System.currentTimeMillis() + LIMIT_MILLIS; + while (!delivered.get() && System.currentTimeMillis() < deadline) { + try { + Thread.sleep(5L); + } catch (InterruptedException ex) { + return; + } + } + } + private static void poll(AsyncResource res) { long deadline = System.currentTimeMillis() + LIMIT_MILLIS; while (!res.isDone() && System.currentTimeMillis() < deadline) { diff --git a/maven/core-unittests/src/test/java/com/codename1/health/HealthEdtDeliveryTest.java b/maven/core-unittests/src/test/java/com/codename1/health/HealthEdtDeliveryTest.java index c51fc2e35d6..4762ad779bd 100644 --- a/maven/core-unittests/src/test/java/com/codename1/health/HealthEdtDeliveryTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/health/HealthEdtDeliveryTest.java @@ -310,6 +310,56 @@ private static String enclosingMethod(String[] lines, int at) { return ""; } + /** + * A listener attached after the outcome has already landed still arrives on + * the EDT. + * + *

{@code AsyncResource} runs a callback registered against a finished + * resource immediately, on whichever thread registered it, so completing on + * the EDT is only half the guarantee. The facade operations resolve inside + * the call, which made {@link #aFacadeActionDeliversOnTheEdt()} a race: it + * passed when the caller reached {@code onResult} before the EDT drained + * the completion, and failed when it did not.

+ * + *

Waiting for {@code isDone()} before listening makes the late case the + * only case, so this fails every time against that bug rather than + * occasionally.

+ */ + @Test + void aListenerAttachedAfterCompletionStillArrivesOnTheEdt() { + final Landing landing = new Landing(); + CN.invokeAndBlock(new Runnable() { + public void run() { + assertFalse(CN.isEdt(), "the operation must start off the EDT"); + AsyncResource settings = + Health.getInstance().openHealthSettings(); + long deadline = System.currentTimeMillis() + 10_000L; + while (!settings.isDone() + && System.currentTimeMillis() < deadline) { + try { + Thread.sleep(5L); + } catch (InterruptedException ex) { + return; + } + } + assertTrue(settings.isDone(), "the outcome must have landed first"); + settings.onResult(landing); + deadline = System.currentTimeMillis() + 10_000L; + while (!landing.arrived.get() + && System.currentTimeMillis() < deadline) { + try { + Thread.sleep(5L); + } catch (InterruptedException ex) { + return; + } + } + } + }); + assertTrue(landing.arrived.get(), "the callback must arrive"); + assertTrue(landing.onEdt.get(), + "a late listener must still be called on the EDT"); + } + @Test void aFacadeActionDeliversOnTheEdt() { final Landing landing = new Landing(); diff --git a/maven/core-unittests/src/test/java/com/codename1/health/HealthFallbackTest.java b/maven/core-unittests/src/test/java/com/codename1/health/HealthFallbackTest.java index bcc8a179353..25191591bc4 100644 --- a/maven/core-unittests/src/test/java/com/codename1/health/HealthFallbackTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/health/HealthFallbackTest.java @@ -202,18 +202,8 @@ private static void assertFailedWith(HealthError expected, assertEquals(expected, ((HealthException) err).getError()); } - /** - * An {@code except} callback registered on an already-failed resource - * fires synchronously, so the error can be read out without waiting -- - * the same trick {@code BtTestUtil} uses. - */ + /** Settles the resource and reads its failure; see {@link HealthAwait#errorOf}. */ private static Throwable errorOf(AsyncResource r) { - final Throwable[] err = new Throwable[1]; - r.except(new com.codename1.util.SuccessCallback() { - public void onSucess(Throwable t) { - err[0] = t; - } - }); - return err[0]; + return HealthAwait.errorOf(r); } } diff --git a/maven/core-unittests/src/test/java/com/codename1/health/HealthWireTest.java b/maven/core-unittests/src/test/java/com/codename1/health/HealthWireTest.java index 6d472eb223b..67552f3ba19 100644 --- a/maven/core-unittests/src/test/java/com/codename1/health/HealthWireTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/health/HealthWireTest.java @@ -599,15 +599,9 @@ void seriesTypesAreDeletableOnAndroidEvenThoughTheyAreNotWritable() { HealthDataType.SLEEP)); } - private static Throwable errorOf( - com.codename1.util.AsyncResource r) { - final Throwable[] err = new Throwable[1]; - r.except(new com.codename1.util.SuccessCallback() { - public void onSucess(Throwable t) { - err[0] = t; - } - }); - return err[0]; + /** Settles the resource and reads its failure; see {@link HealthAwait#errorOf}. */ + private static Throwable errorOf(com.codename1.util.AsyncResource r) { + return HealthAwait.errorOf(r); } /** diff --git a/maven/core-unittests/src/test/java/com/codename1/health/LocalHealthPersistenceTest.java b/maven/core-unittests/src/test/java/com/codename1/health/LocalHealthPersistenceTest.java index 06aa715ecb8..9ad70b56f35 100644 --- a/maven/core-unittests/src/test/java/com/codename1/health/LocalHealthPersistenceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/health/LocalHealthPersistenceTest.java @@ -532,18 +532,9 @@ HealthDataType.STEPS, new HealthQuantity(2, HealthUnit.COUNT), "and the record this build cannot read must survive"); } + /** Settles the resource and reads its failure; see {@link HealthAwait#errorOf}. */ private static Throwable errorOf(AsyncResource r) { - // Settled first. Results are delivered on the EDT on every backend - // now, so an off-EDT caller sees the error queued rather than already - // attached, and reading it without waiting found nothing. - HealthAwait.settled(r); - final Throwable[] err = new Throwable[1]; - r.except(new com.codename1.util.SuccessCallback() { - public void onSucess(Throwable t) { - err[0] = t; - } - }); - return err[0]; + return HealthAwait.errorOf(r); } /** A store whose backing storage refuses to take anything. */ diff --git a/maven/core-unittests/src/test/java/com/codename1/health/LocalHealthStoreTest.java b/maven/core-unittests/src/test/java/com/codename1/health/LocalHealthStoreTest.java index 363a5b94b06..2edebc2ac61 100644 --- a/maven/core-unittests/src/test/java/com/codename1/health/LocalHealthStoreTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/health/LocalHealthStoreTest.java @@ -731,22 +731,9 @@ void sourceFilterExcludesOtherApps() { filtered.get(0).getSource().getBundleId()); } - /** - * An {@code except} callback on an already-settled resource fires - * synchronously, so the error can be read without waiting. - */ + /** Settles the resource and reads its failure; see {@link HealthAwait#errorOf}. */ private static Throwable errorOf(com.codename1.util.AsyncResource r) { - // Settled first. Results are delivered on the EDT on every backend - // now, so an off-EDT caller sees the error queued rather than already - // attached, and reading it without waiting found nothing. - HealthAwait.settled(r); - final Throwable[] err = new Throwable[1]; - r.except(new com.codename1.util.SuccessCallback() { - public void onSucess(Throwable t) { - err[0] = t; - } - }); - return err[0]; + return HealthAwait.errorOf(r); } /** diff --git a/maven/core-unittests/src/test/java/com/codename1/health/WorkoutAndNutritionTest.java b/maven/core-unittests/src/test/java/com/codename1/health/WorkoutAndNutritionTest.java index 9420dd863cf..185f34b8335 100644 --- a/maven/core-unittests/src/test/java/com/codename1/health/WorkoutAndNutritionTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/health/WorkoutAndNutritionTest.java @@ -134,18 +134,9 @@ void aTerminalSessionIsReleasedOnTransitionNotOnTheNextGetter() next.discard(); } + /** Settles the resource and reads its failure; see {@link HealthAwait#errorOf}. */ private static Throwable errorOf(AsyncResource r) { - // Settled first: workout operations deliver on the EDT like every - // other result, so an off-EDT caller sees the error queued rather - // than already attached. - HealthAwait.settled(r); - final Throwable[] err = new Throwable[1]; - r.except(new SuccessCallback() { - public void onSucess(Throwable t) { - err[0] = t; - } - }); - return err[0]; + return HealthAwait.errorOf(r); } private static WorkoutSession startedSession() { From e78fa049d413dd1e228aa0fcb16c0c6debcc882f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:18:50 +0700 Subject: [PATCH 16/91] Address the fourth review round, and the health delivery fix's last caller DER signatures are now accepted only in their canonical form cn1EcdsaFromDer read the two INTEGERs and returned success without checking the SEQUENCE's own length or that the input was fully consumed. Being liberal there is not harmless: CNG verifies the P1363 pair it produces, so a signature with trailing bytes, a falsified outer length or a non-minimal INTEGER would verify on Windows and be rejected by JavaSE and every conforming verifier -- the same signature good on one port and bad on another. The whole input must now be exactly one ECDSA-Sig-Value, with minimal lengths and canonical INTEGERs. The OpenSSL harness grew cases for each: trailing garbage, a falsified outer length and a needless leading zero are refused at P-256 and P-521, while well-formed signatures -- including OpenSSL's own encodings -- still convert. An algorithm may no longer be paired with the wrong key cryptoSign discarded keyAlgorithm on the desktop ports, with a comment claiming it added nothing. The native picks the family off the DER key and takes only the digest from the algorithm name, so SHA256withRSA handed an EC key quietly produced an ECDSA signature -- and the matching verify accepted it, so nothing looked wrong until another port read it. JavaSE rejects the pairing when the Signature is initialised; both ports now do the same, on sign and on verify. The sweep gates a report before claiming its port backfill_port_status.sh wrote the coverage marker while downloading and ran the publication gate afterwards, so a newest run that uploaded an unusable report claimed the port and stopped the older candidates from being consulted -- the sweep would keep serving stale data, or fail its closing freshness assertion, with a good report sitting in the run behind it. The gate now runs before the marker. Timestamps from the future are refused A skewed producer clock poisons the data branch rather than just looking odd: the page reads the report as permanently fresh, and the sweep's lexical "is this newer" comparison then refuses every later correct timestamp, which nothing downstream can undo. An hour of tolerance absorbs ordinary skew. Two tests cover both sides. The javase health tests HealthReadAuthTrapTest has its own copy of the errorOf helper, in a module the core-unittests run does not touch, so the EDT delivery fix broke it and I did not see it until CI did -- the third time this change has found a caller I had not looked for. Fixed the same way, waiting on both sides so a resource that succeeded still answers null promptly. The whole javase suite passes, 207 tests, alongside core-unittests' 4677. Verified: the Windows cross-compile still links (crossCompilesWindowsExeWithXwin against an xwin SDK), the OAEP and ECDSA harness passes including the new strictness cases, and the conformance contract tests pass at 18. Co-Authored-By: Claude Opus 5 (1M context) --- .../impl/linux/LinuxImplementation.java | 23 +++++++++- .../nativeSources/cn1_windows_crypto.c | 31 ++++++++++++-- .../impl/windows/WindowsImplementation.java | 23 +++++++++- .../javase/health/HealthReadAuthTrapTest.java | 42 +++++++++++++++++-- .../conformance/backfill_port_status.sh | 17 ++++++-- .../conformance/port_status.py | 17 +++++++- .../conformance/test_port_status.py | 28 +++++++++++++ 7 files changed, 166 insertions(+), 15 deletions(-) diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java index 07fd63b57e0..f83c6c3935c 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java @@ -2805,17 +2805,36 @@ public byte[] rsaDecrypt(String transformation, byte[] privateKeyPkcs8, byte[] c @Override public byte[] cryptoSign(String algorithm, String keyAlgorithm, byte[] privateKeyPkcs8, byte[] data) { - // The digest and the key type both follow from the algorithm name and - // the DER key itself, so keyAlgorithm adds nothing here. + checkKeyFamily(algorithm, keyAlgorithm); return cryptoResult(LinuxNative.signData(algorithm, privateKeyPkcs8, data), "sign"); } @Override public boolean cryptoVerify(String algorithm, String keyAlgorithm, byte[] publicKeyX509, byte[] data, byte[] signature) { + checkKeyFamily(algorithm, keyAlgorithm); return LinuxNative.verifyData(algorithm, publicKeyX509, data, signature); } + /// The portable contract pairs an algorithm with a key of its own family, + /// and JavaSE rejects a mismatch when the Signature is initialised. The + /// native here reads the family off the DER key and takes only the digest + /// from the algorithm name, so `SHA256withRSA` handed an EC key would + /// quietly produce an ECDSA signature -- and the matching verify would + /// accept it, so nothing looks wrong until another port reads it. Refuse + /// the pairing rather than silently substituting the algorithm. + private static void checkKeyFamily(String algorithm, String keyAlgorithm) { + if (algorithm == null || keyAlgorithm == null) { + return; + } + boolean wantsEc = algorithm.toUpperCase().indexOf("ECDSA") >= 0; + boolean keyIsEc = keyAlgorithm.toUpperCase().startsWith("EC"); + if (wantsEc != keyIsEc) { + throw new RuntimeException(algorithm + " cannot be used with a " + + keyAlgorithm + " key"); + } + } + @Override public byte[][] generateRsaKeyPair(int bits) { byte[] blob = cryptoResult(LinuxNative.generateRsaKeyPair(bits), "RSA key generation"); diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c index 8a0995db236..df8a4f62d24 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c +++ b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c @@ -664,23 +664,39 @@ static int cn1EcCoordinateBytes(BCRYPT_KEY_HANDLE key) { } /* Inverse of cn1EcdsaToDer, padding each half back to `half` bytes. */ +/* Rejects anything that is not the one canonical encoding of this signature. + * + * Being liberal here is not harmless: CNG verifies the P1363 pair this + * produces, so a signature carrying trailing bytes, a falsified outer length + * or a non-minimal INTEGER would verify on Windows and be rejected by JavaSE + * and by any conforming verifier -- the same signature accepted on one port + * and refused on another. The whole input must be exactly one + * ECDSA-Sig-Value. */ static int cn1EcdsaFromDer(const unsigned char* der, int derLength, unsigned char* raw, int half) { int index = 1; int part; + int bodyLength; if (derLength < 8 || der[0] != 0x30) { return 0; } /* Accept the long form the P-521 body needs, and only that one extra - * length byte -- a sequence of two integers never runs past 255 bytes. */ + * length byte -- a sequence of two integers never runs past 255 bytes. + * DER also requires the shortest form, so a 0x81 that encodes a length + * under 128 is not canonical. */ if (der[index] == 0x81) { index++; - if (index >= derLength) { + if (index >= derLength || der[index] < 0x80) { return 0; } } else if ((der[index] & 0x80) != 0) { return 0; } + bodyLength = der[index]; index++; + /* The sequence must describe exactly the bytes that follow it. */ + if (index + bodyLength != derLength) { + return 0; + } memset(raw, 0, (size_t) (half * 2)); for (part = 0; part < 2; part++) { int length, start, copy; @@ -692,6 +708,14 @@ static int cn1EcdsaFromDer(const unsigned char* der, int derLength, unsigned cha if (length <= 0 || index + length > derLength) { return 0; } + /* Canonical INTEGER: no leading 0x00 unless it is there to keep the + * value positive, and never negative. */ + if (length > 1 && der[index] == 0x00 && (der[index + 1] & 0x80) == 0) { + return 0; + } + if ((der[index] & 0x80) != 0) { + return 0; + } start = 0; while (start < length - 1 && der[index + start] == 0) { start++; @@ -703,7 +727,8 @@ static int cn1EcdsaFromDer(const unsigned char* der, int derLength, unsigned cha memcpy(raw + part * half + (half - copy), der + index + start, (size_t) copy); index += length; } - return 1; + /* Nothing may follow the second INTEGER. */ + return index == derLength; } JAVA_OBJECT com_codename1_impl_windows_WindowsNative_rsaCrypt___java_lang_String_boolean_byte_1ARRAY_byte_1ARRAY_R_byte_1ARRAY( diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java index ea7a06919e3..574e91aff89 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java @@ -2813,17 +2813,36 @@ public byte[] rsaDecrypt(String transformation, byte[] privateKeyPkcs8, byte[] c @Override public byte[] cryptoSign(String algorithm, String keyAlgorithm, byte[] privateKeyPkcs8, byte[] data) { - // The digest and the key type both follow from the algorithm name and - // the DER key itself, so keyAlgorithm adds nothing here. + checkKeyFamily(algorithm, keyAlgorithm); return cryptoResult(WindowsNative.signData(algorithm, privateKeyPkcs8, data), "sign"); } @Override public boolean cryptoVerify(String algorithm, String keyAlgorithm, byte[] publicKeyX509, byte[] data, byte[] signature) { + checkKeyFamily(algorithm, keyAlgorithm); return WindowsNative.verifyData(algorithm, publicKeyX509, data, signature); } + /// The portable contract pairs an algorithm with a key of its own family, + /// and JavaSE rejects a mismatch when the Signature is initialised. The + /// native here reads the family off the DER key and takes only the digest + /// from the algorithm name, so `SHA256withRSA` handed an EC key would + /// quietly produce an ECDSA signature -- and the matching verify would + /// accept it, so nothing looks wrong until another port reads it. Refuse + /// the pairing rather than silently substituting the algorithm. + private static void checkKeyFamily(String algorithm, String keyAlgorithm) { + if (algorithm == null || keyAlgorithm == null) { + return; + } + boolean wantsEc = algorithm.toUpperCase().indexOf("ECDSA") >= 0; + boolean keyIsEc = keyAlgorithm.toUpperCase().startsWith("EC"); + if (wantsEc != keyIsEc) { + throw new RuntimeException(algorithm + " cannot be used with a " + + keyAlgorithm + " key"); + } + } + @Override public byte[][] generateRsaKeyPair(int bits) { byte[] blob = cryptoResult(WindowsNative.generateRsaKeyPair(bits), "RSA key generation"); diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/health/HealthReadAuthTrapTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/health/HealthReadAuthTrapTest.java index 303cd57e09f..61cdc8000bd 100644 --- a/maven/javase/src/test/java/com/codename1/impl/javase/health/HealthReadAuthTrapTest.java +++ b/maven/javase/src/test/java/com/codename1/impl/javase/health/HealthReadAuthTrapTest.java @@ -34,6 +34,9 @@ import com.codename1.health.QuantitySample; import com.codename1.health.SampleQuery; import com.codename1.util.AsyncResource; + +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; import com.codename1.util.SuccessCallback; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -138,15 +141,46 @@ private static AsyncResource settled(AsyncResource r) { return r; } - private static Throwable errorOf(AsyncResource r) { + /// Settles `r` and returns the failure it carries, or null if it + /// succeeded. + /// + /// This used to read the error straight back, because a callback attached + /// to an already-failed resource fired inline on the registering thread. + /// Health results are delivered on the EDT whether the listener arrives + /// before the outcome or after it, so an off-EDT caller gets it queued and + /// has to wait -- the same wait `settled` makes one step earlier. + /// + /// Both sides are registered because most callers ask this of a resource + /// that succeeded and expect null; only one of the two ever fires, so + /// waiting on `except` alone would burn the whole limit on every + /// successful call. + private static Throwable errorOf(AsyncResource r) { settled(r); - final Throwable[] err = new Throwable[1]; + // Atomics because the callback runs on the EDT while the value is read + // from the test thread. + final AtomicReference err = new AtomicReference(); + final AtomicBoolean delivered = new AtomicBoolean(); r.except(new SuccessCallback() { public void onSucess(Throwable t) { - err[0] = t; + err.set(t); + delivered.set(true); } }); - return err[0]; + r.ready(new SuccessCallback() { + public void onSucess(T value) { + delivered.set(true); + } + }); + long deadline = System.currentTimeMillis() + 10_000L; + while (!delivered.get() && System.currentTimeMillis() < deadline) { + try { + Thread.sleep(5L); + } catch (InterruptedException ex) { + break; + } + } + assertTrue(delivered.get(), "the outcome must be delivered rather than hang"); + return err.get(); } /** The permissive baseline: data is there and comes back. */ diff --git a/scripts/hellocodenameone/conformance/backfill_port_status.sh b/scripts/hellocodenameone/conformance/backfill_port_status.sh index 660db3f9d70..d714e498e5c 100755 --- a/scripts/hellocodenameone/conformance/backfill_port_status.sh +++ b/scripts/hellocodenameone/conformance/backfill_port_status.sh @@ -98,10 +98,21 @@ while IFS= read -r workflow; do run_id="${candidate}" while IFS= read -r downloaded; do found="$(jq -r '.port // empty' "${downloaded}" 2>/dev/null || true)" - if [ -n "${found}" ] && [ ! -f "${download_dir}/covered-${found}" ]; then - cp "${downloaded}" "${download_dir}/port-status-${found}.json" - : > "${download_dir}/covered-${found}" + if [ -z "${found}" ] || [ -f "${download_dir}/covered-${found}" ]; then + continue fi + # Gate before marking the port covered, not after. A newest run that + # uploaded an unusable report would otherwise claim the port and stop + # the older candidates from being consulted, so the sweep would keep + # serving stale data -- or fail its closing freshness assertion -- + # while a perfectly good report sat in the run behind it. + if ! python3 "${SCRIPT_DIR}/port_status.py" accept \ + --port "${found}" --report "${downloaded}" >/dev/null 2>&1; then + echo "Ignoring the ${found} report from run ${candidate}: not usable by the website." >&2 + continue + fi + cp "${downloaded}" "${download_dir}/port-status-${found}.json" + : > "${download_dir}/covered-${found}" done < <(find "${download_dir}/run-${candidate}" -type f -name 'port-status-*.json' | sort) fi done diff --git a/scripts/hellocodenameone/conformance/port_status.py b/scripts/hellocodenameone/conformance/port_status.py index e8a347613c9..6742d9ab4c6 100755 --- a/scripts/hellocodenameone/conformance/port_status.py +++ b/scripts/hellocodenameone/conformance/port_status.py @@ -10,7 +10,7 @@ import re import sys from collections import Counter -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from pathlib import Path @@ -30,6 +30,12 @@ ACCEPT_CONTRACT_DRIFT = 11 ACCEPT_UNUSABLE = 12 +# Producers and this checker can disagree by a little without anything +# being wrong -- runner clocks drift, and a report is stamped slightly +# before it is published. An hour absorbs that; a skewed clock or a +# mistyped --generated-at lands far outside it. +FUTURE_STAMP_TOLERANCE = timedelta(hours=1) + START_RE = re.compile(r"suite starting test=([A-Za-z0-9_]+)") FINISH_RE = re.compile(r"suite finished test=([A-Za-z0-9_]+)") SKIP_RE = re.compile(r"test=([A-Za-z0-9_]+) status=SKIPPED(?: reason=([^\s]+))?") @@ -622,6 +628,15 @@ def publishable_report_problems( else: if stamp.tzinfo is None: malformed.append(f"generated_at {generated_at!r} has no time zone") + elif stamp - datetime.now(timezone.utc) > FUTURE_STAMP_TOLERANCE: + # A clock skewed far ahead poisons the data branch rather than + # just looking odd: the page reads the report as permanently + # fresh, and the sweep's lexical "is this newer" comparison + # then refuses every later, correct timestamp. Nothing + # downstream can recover from that, so refuse it at the gate. + malformed.append( + f"generated_at {generated_at!r} is in the future" + ) mapped = test_to_feature(manifest) tests = report.get("tests") diff --git a/scripts/hellocodenameone/conformance/test_port_status.py b/scripts/hellocodenameone/conformance/test_port_status.py index 4db22b7469f..3d0b1418802 100755 --- a/scripts/hellocodenameone/conformance/test_port_status.py +++ b/scripts/hellocodenameone/conformance/test_port_status.py @@ -3,6 +3,7 @@ import json import tempfile import unittest +from datetime import datetime, timedelta, timezone from pathlib import Path from unittest.mock import patch @@ -300,6 +301,33 @@ def test_publishable_accepts_a_documented_test_skip(self): self.manifest, "android", report )) + def test_publishable_rejects_a_timestamp_from_the_future(self): + # A skewed producer clock poisons the data branch rather than merely + # looking odd: the page reads the report as permanently fresh, and the + # sweep's "is this newer" comparison then refuses every later correct + # timestamp. Nothing downstream can undo it, so it has to be refused + # here. + ahead = datetime.now(timezone.utc) + timedelta(days=400) + report = self.publishable_report( + "android", generated_at=ahead.strftime("%Y-%m-%dT%H:%M:%SZ") + ) + drift, malformed = port_status.publishable_report_problems( + self.manifest, "android", report + ) + self.assertEqual([], drift) + self.assertTrue(any("future" in problem for problem in malformed), malformed) + + def test_publishable_allows_a_little_clock_skew(self): + # Runner clocks drift and a report is stamped a moment before it is + # published, so being marginally ahead is normal rather than a defect. + skewed = datetime.now(timezone.utc) + timedelta(minutes=5) + report = self.publishable_report( + "android", generated_at=skewed.strftime("%Y-%m-%dT%H:%M:%SZ") + ) + self.assertEqual(([], []), port_status.publishable_report_problems( + self.manifest, "android", report + )) + def test_publishable_separates_contract_drift_from_a_broken_report(self): report = self.publishable_report("android") del report["tests"]["CameraApiTest"] From 880bc2824ea657a3dc08876496be45c2c7caedfd Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:22:12 +0700 Subject: [PATCH 17/91] Give the Linux browser its JS bridge instead of navigating to codenameone.com BrowserComponentScreenshotTest times out intermittently on the Linux runner -- "timeout waiting for DONE stage=show-completed", twice in a row including the harness's retry, with no failure from the test itself. It passes on most commits, which is what makes it a bug rather than a flake: the test hangs in one of its unbounded waits instead of failing. The wait it hangs in is the execute() callback. BrowserComponent.execute generates JavaScript that returns its value by calling cn1application.shouldNavigate(url), and falls back to `window.location.href = "https://www.codenameone.com/..."` when the page has no such object. The Linux port never defined one, so every execute() callback was a real navigation to the internet: the return value came back only if the runner could reach that host, and the navigation took the page under test away with it. Nothing bounded that wait, so a callback that never arrived stopped the suite rather than failing the test. The native side was already half-way there -- it registers a "cn1" script message handler and pushes its messages as MSG| events, and the file header describes that as the JS->Java bridge -- but nothing injected the script that posts to it and the Java side dropped MSG| on the floor. Both halves are now present: a document-start user script defines cn1application.shouldNavigate to post to the handler (the same bootstrap the iOS port injects), and poll() routes MSG| into fireBrowserNavigationCallbacks, the same sink a navigation callback uses. The portable layer decodes the return-value URL either way. The three WebKitGTK entry points this needs are resolved optionally rather than added to the required set: the bootstrap is an improvement on the navigation fallback, not a requirement for browsing, so a build without them keeps a working BrowserComponent instead of reporting it unsupported. The function pointers are declared with __typeof__ of the real declarations, so the header decides their signatures and a mismatched call is a compile error. That is as far as local verification goes here -- the port does not build on macOS, so the runtime behaviour of this one is on CI. Co-Authored-By: Claude Opus 5 (1M context) --- .../nativeSources/cn1_linux_browser.c | 32 +++++++++++++++++++ .../impl/linux/LinuxBrowserComponent.java | 9 ++++++ 2 files changed, 41 insertions(+) diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_browser.c b/Ports/LinuxPort/nativeSources/cn1_linux_browser.c index 3cadd982b42..63d941aa492 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_browser.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_browser.c @@ -56,6 +56,9 @@ static __typeof__(jsc_value_to_string)* p static __typeof__(webkit_web_view_load_html)* p_webkit_web_view_load_html; static __typeof__(webkit_web_view_load_uri)* p_webkit_web_view_load_uri; static __typeof__(webkit_web_view_run_javascript)* p_webkit_web_view_run_javascript; +static __typeof__(webkit_user_script_new)* p_webkit_user_script_new; +static __typeof__(webkit_user_content_manager_add_script)* p_webkit_user_content_manager_add_script; +static __typeof__(webkit_user_script_unref)* p_webkit_user_script_unref; static int cn1_wk_state = 0; /* 0 = untried, 1 = available, -1 = unavailable */ @@ -91,6 +94,14 @@ static int cn1LoadWebkit(void) { CN1_WK_SYM(p_webkit_web_view_load_uri, "webkit_web_view_load_uri"); CN1_WK_SYM(p_webkit_web_view_run_javascript, "webkit_web_view_run_javascript"); #undef CN1_WK_SYM + /* Optional: the JS->Java bootstrap is an improvement on the navigation + * fallback, not a requirement for browsing, so a build missing any of these + * keeps a working BrowserComponent rather than reporting unsupported. */ +#define CN1_WK_OPT(ptr, name) do { *(void**)(&ptr) = dlsym(h, name); } while (0) + CN1_WK_OPT(p_webkit_user_script_new, "webkit_user_script_new"); + CN1_WK_OPT(p_webkit_user_content_manager_add_script, "webkit_user_content_manager_add_script"); + CN1_WK_OPT(p_webkit_user_script_unref, "webkit_user_script_unref"); +#undef CN1_WK_OPT cn1_wk_state = ok ? 1 : -1; if (!ok) { cn1LinuxStubOnce("WebKitGTK present but an expected symbol was missing; BrowserComponent unsupported"); } return ok; @@ -148,6 +159,27 @@ static void cn1BrowserCreateOnMain(void* p) { pthread_mutex_init(&b->lock, 0); p_webkit_user_content_manager_register_script_message_handler(mgr, "cn1"); g_signal_connect(mgr, "script-message-received::cn1", G_CALLBACK(cn1BrowserScriptMessage), b); + /* Give the page the object BrowserComponent.execute's generated JavaScript + * looks for. Without it that code falls through to its last resort, + * `window.location.href = "https://www.codenameone.com/..."`, so every + * execute() callback and every JS->Java message became a real navigation to + * the internet: the return value only came back if the runner could reach + * that host, and the page navigated away from the content under test. This + * is the same bootstrap the iOS port injects, routed to the "cn1" message + * handler registered above. */ + if (p_webkit_user_script_new != 0 && p_webkit_user_content_manager_add_script != 0) { + WebKitUserScript* bootstrap = p_webkit_user_script_new( + "window.cn1application = window.cn1application || {};" + "window.cn1application.shouldNavigate = function(url) {" + " window.webkit.messageHandlers.cn1.postMessage(String(url));" + "};", + WEBKIT_USER_CONTENT_INJECT_TOP_FRAME, + WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, 0, 0); + p_webkit_user_content_manager_add_script(mgr, bootstrap); + if (p_webkit_user_script_unref != 0) { + p_webkit_user_script_unref(bootstrap); + } + } b->view = p_webkit_web_view_new_with_user_content_manager(mgr); g_signal_connect(b->view, "load-changed", G_CALLBACK(cn1BrowserLoadChanged), b); cn1LinuxOverlayAdd(b->view, 0, 0, req->w > 0 ? req->w : 1, req->h > 0 ? req->h : 1); diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxBrowserComponent.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxBrowserComponent.java index 626e3be2f4f..1cc8d3b2cf9 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxBrowserComponent.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxBrowserComponent.java @@ -117,6 +117,15 @@ private void poll() { browser.fireWebEvent(BrowserComponent.onLoad, new ActionEvent("")); } else if (ev.startsWith("NAV|")) { browser.fireBrowserNavigationCallbacks(ev.substring(4)); + } else if (ev.startsWith("MSG|")) { + // The JS->Java bridge. The page's cn1application.shouldNavigate + // posts here rather than assigning window.location, so an + // execute() return value comes back through the message handler + // instead of a navigation to codenameone.com -- which needed + // working egress to deliver a callback and took the page under + // test away with it. Same sink as a navigation callback: the + // portable layer decodes the return-value URL either way. + browser.fireBrowserNavigationCallbacks(ev.substring(4)); } } } From 445dfd3cda08adc44dc01fb5de9b79cd70b8946f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:23:30 +0700 Subject: [PATCH 18/91] Give LinuxBrowserComponent a copyright header and an accurate description The copyright gate compares against master and only sees files a branch touches, so editing this one brought it into scope and it turned out never to have carried a header. I pushed the previous commit without noticing, because I ran the gate and the commit in one line separated by `;` rather than `&&` -- the gate failed and the commit went out anyway. Its class description was also the Windows port's, naming WebView2, Direct2D and a .cpp file, none of which exist on this port. Replaced with what the peer actually is: a WebKitGTK WebView captured to PNG for the offscreen screenshot. Co-Authored-By: Claude Opus 5 (1M context) --- .../impl/linux/LinuxBrowserComponent.java | 40 +++++++++++++++---- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxBrowserComponent.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxBrowserComponent.java index 1cc8d3b2cf9..b1f2ea6b855 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxBrowserComponent.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxBrowserComponent.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2026, 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.impl.linux; import com.codename1.ui.BrowserComponent; @@ -9,15 +31,17 @@ import com.codename1.ui.geom.Dimension; import com.codename1.ui.util.UITimer; -/// Native Linux BrowserComponent peer backed by a WebView2 instance (the -/// native lifecycle lives in cn1_linux_browser.cpp). The component is rendered -/// from a cached image: the native side CapturePreview's the WebView2 to PNG -/// bytes after each navigation, which `generatePeerImage()` turns into the peer -/// image that `PeerComponent.paint()` draws (so it appears in the offscreen -/// Direct2D screenshot, where the live WebView2 visual would not). The peer polls -/// the native event queue to fire `onLoad` and to route the JS return-value -/// bridge (a cancelled navigation to a `/!cn1return/` URL) into the +/// Native Linux BrowserComponent peer backed by a WebKitGTK WebView (the native +/// lifecycle lives in cn1_linux_browser.c). The component is rendered from a +/// cached image: the native side captures the view to PNG bytes after each +/// navigation, which `generatePeerImage()` turns into the peer image that +/// `PeerComponent.paint()` draws, so it appears in the offscreen screenshot +/// where the live WebKit widget would not. The peer polls the native event +/// queue to fire `onLoad` and to route the JS return-value bridge into the /// BrowserComponent's navigation callbacks. +/// +/// (This description previously named WebView2, Direct2D and a .cpp file, none +/// of which exist here -- it had been copied from the Windows port.) class LinuxBrowserComponent extends PeerComponent { private final long peer; private final BrowserComponent browser; From 29dc949ebb9484617610369dd09a80419bd8aa17 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 1 Aug 2026 15:20:49 +0700 Subject: [PATCH 19/91] Stop the browser test from accepting a blank frame as a rendered page With the JS bridge working, BrowserComponentScreenshotTest got as far as emitting a screenshot on Linux -- and the picture is empty. The browser area is plain white; none of the fixture's content is in it. Two separate things let that pass as success. containsRenderedBrowserContent looked only for bright pixels, on the reasoning that the fixture is white and cyan text and an uncomposited peer is black. A peer that never composited at all is neither: it leaves the form's white background, which is bright everywhere and satisfies the text test on the first row it scans. The check now requires the fixture's dark #0e1116 backdrop as well, so evidence of the page itself is needed rather than evidence of brightness. The Linux port cannot composite the peer at all. browserCapturePng is a stub that returns null pending the async WebKit snapshot bridge, so generatePeerImage never produces an image and the peer is never in the capture. That is the same position the Mac and JavaSE baselines are already in, and the test already has a branch for it; Linux now takes that branch instead of waiting twelve seconds for pixels that cannot arrive. The DOM assertion through execute() -- which only started working with the bridge fix in the previous commit -- is the real coverage there. This leaves the Linux run needing a committed golden for the surrounding form. Seeding it from this code's own CI output rather than from the previous capture, since the capture path is what changed. Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/BrowserComponentScreenshotTest.java | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BrowserComponentScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BrowserComponentScreenshotTest.java index 23918b1b5ae..e7dba2a264b 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BrowserComponentScreenshotTest.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BrowserComponentScreenshotTest.java @@ -81,6 +81,10 @@ private static boolean isHtml5() { return "HTML5".equals(Display.getInstance().getPlatformName()); } + private static boolean isLinux() { + return "linux".equals(Display.getInstance().getPlatformName()); + } + private void checkReady() { if (!loaded || readyRunnable == null) { return; @@ -106,11 +110,15 @@ public void onSucess(BrowserComponent.JSRef result) { return; } - if (isHtml5() || CN.isDesktop()) { + if (isHtml5() || CN.isDesktop() || isLinux()) { // Desktop screenshots intentionally cannot include the native web // peer (the committed Mac/JavaSE baselines contain its black - // placeholder). The execute() assertion above validates the real - // DOM; use the normal harness capture for the surrounding form. + // placeholder), and the Linux port cannot either: its + // browserCapturePng is a documented stub pending the async WebKit + // snapshot bridge, so generatePeerImage always answers null and the + // peer never reaches the capture. The execute() assertion above + // validates the real DOM; use the normal harness capture for the + // surrounding form. UITimer.timer(2000, false, form, readyRunnable); } else { // DOM readiness and even WebKit's first meaningful paint do not @@ -179,7 +187,9 @@ private boolean containsRenderedBrowserContent(Image screen) { screen.toRGB(visualBand, 0, 0, left, top, bandWidth, bandHeight); int[] rgb = visualBand.getRGB(); int requiredBrightPixels = Math.max(32, bandWidth / 20); + int requiredDarkPixels = Math.max(32, bandWidth / 20); int brightPixels = 0; + int darkPixels = 0; for (int y = 0; y < bandHeight; y++) { int rowOffset = y * bandWidth; for (int x = 0; x < bandWidth; x++) { @@ -191,9 +201,19 @@ private boolean containsRenderedBrowserContent(Image screen) { // background. The black uncomposited peer contains neither. if ((r > 160 && g > 160 && b > 160) || (g > 120 && b > 160 && b > r + 30)) { - if (++brightPixels >= requiredBrightPixels) { - return true; - } + brightPixels++; + } else if (r < 80 && g < 80 && b < 80) { + // The fixture's #0e1116 backdrop. Requiring it as well as + // the text is what stops a blank frame from passing: a peer + // that never composited leaves the form's plain white + // background, which is bright everywhere and would satisfy + // the text test on its own -- which is exactly how the + // Linux port, whose peer capture is still a stub, recorded + // an empty rectangle as a rendered page. + darkPixels++; + } + if (brightPixels >= requiredBrightPixels && darkPixels >= requiredDarkPixels) { + return true; } } } From 9c37773f90a4c3f98f1806dd630ad9676449cd94 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:21:07 +0700 Subject: [PATCH 20/91] Add the Linux golden for the browser test's surrounding form Seeded from CI rather than a local build, and from a run of the current code rather than the earlier capture, since the capture path is what changed. The x64 and arm64 legs produced byte-identical images, which is the evidence that this baseline is stable rather than a snapshot of one run's timing. The picture is the form with an empty browser area, which is the honest baseline for this port: browserCapturePng is a stub pending the async WebKit snapshot bridge, so the peer cannot appear in a capture. The DOM assertion through execute() is what actually covers BrowserComponent on Linux. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/linux/screenshots/BrowserComponent.png | Bin 0 -> 5895 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 scripts/linux/screenshots/BrowserComponent.png diff --git a/scripts/linux/screenshots/BrowserComponent.png b/scripts/linux/screenshots/BrowserComponent.png new file mode 100644 index 0000000000000000000000000000000000000000..ae2ed97233837f5acb328e87ef71539fe92316f0 GIT binary patch literal 5895 zcmeHL`BM}2whvOZyA+jXYYN1|) zD68yN8a5$7NeB>%c8{R~LZ(-FZ%{ z$^ih5c3gA$+V5V8l%JfNH+fUJj$L|%0PJ^J=$ZQy@rldzZp7L3?Ak9Q>`q1Av1?gB zO0LJEP-lu;*oQ~0o{Xqk<7mK`=3F`Q>^JB#5@h}>P-X3B%i^2E(a{1e_{-)_?U)e6oU5*8@dT7!B^_<> zP_2N=>JnLSLA*4Qc-8>jP1Kp6mbgV}z~=fw>tRq};N^V%4l;u?D-s&4w0v+>5)!4H z#gtp*`JiCz5Uh}9D$`3Ao@R!7sT4-0`Q#L39J2JRYDDXLA+zm9Z{=2VmVXA$aVrO; z;VaD?WAxfXCaK%$!>S#M42$C*Wl`7fy9*rth?Tom+QbNU=&RP?()f{Id#|6cOnFP+ zLOYVAUDg8dEx++c`t60K^cLH4 zhqVk{|4D`(33d_$BpsT@EVNcy)Hj@gs(Bp_Abn3SMnBrrjS-Oi0%2Q!${81x1Dbqt zVA3Ho`&&bes~bH=GHut-g&1DqC`qJO^0i;Uo(+1fylHhPxyqVP*UzSZ5hp8BEpT+< zcvb0_#m)_HJ8uqmj~J_7rNLatDCP!oS>P;1>s9Lt4$aF^ynsPr45-qlVmiHAl-qz4 z=YY((j2042vD&U4Q%qo-NkHW$ZQi9 zxiD=q*QeHcX*g^m zOmv>E`pL6vYkMnDu#=+cJmH)yWwR_O+)7DwGGd;FalF*pCx-dKaD9u*uvkGu5@;=t3Z)ir*H%*t<68WSvl!g z*N?&C`u_v#kiWCya2`kN$zCpIA&_BR#4Ii$7(hLAe?LEnfYkq%skxA#OJYAUFpejn zdR`@?HFy&|owI%lD2rRLvIKHh>qc7UbO*zB!#nMefN0w9)6u+|Dl@-8RPw6sVE$ca ze}46~!^-N&)YxIkNhu+Bb)e10o5aRD-5Gew^d#2<$vd~MSP@80_L{CdbF#+M4Dy5T z+QgwxCm5COxLGO1iMm)xtS3QY*`?kT{{v=moJU+<=!@G8Ko1i*e2S*q(U-SL9uo1~ zyxr74c7HgMIhl1y2B)-oUJ(Ut@o4O@gmnlnp{LEA+rZFy_sOT#^P0yoQPXOZDVwkz zAJ-al+}sKZE&A@fywmW&)R1QK4YCYvn`wvGw6D9m%h3{staEEKWT~OS#4hs9lj{wh zyygvBn~f1S>vHSHj=gE4TTSr~1ARc%s6TL4Aqr1b<8!huzDRua`WiFcGvat_Pgi|v z)aV<9&*h`DYbq9x&djUMDk!VZU*#Zd>#Os-iXnaO-niB|W>o+qR@fV5 zRgC{pv-<&p?|J5|ebaf{wdpVqgmOJc(Lk#}OzvDV;(ABSs?8cv*?vi2InmFpZ*?-d zmYY(2w=S4gS>cytcjr*yFe<3Iti(awv~^->2-{R~b6~+x>|SFka3ty!iOoyI?I1?J z%G3@2s2F?$b-YsW3<%p`0%1J0xXPlCyRoT}=^D5)s2mG&BaB(`Q2Wil@9}4L~UwE5!+d$$b>G~PwO%8FZAwsDM)Ci83the_wJA!s* zzY#lT^Jy0R%)OLE*BX>r6Km<@pPt-C+$HMbX#PwFM+_`-qNaA?`B0>XQ|v#Py+|~@4Y3RJ3y;m(FsR7nH@~F^GBOkOO9{7m zl(MTwP8F2kdCfJD= zP-7bZI+@Z+@`VspBWZ4$7lq@pdl6XF`0tNO^?^+Z+rM9XU~L@|vr`bv;3XMOL96+3 zZ(cto6T+F%)>8#OZqjMttRN^5mdK*$<43uz@D;Q~nn{(OcC(@-DZe0)h+KsQ;obOj zq>Arz&_G7F;iP-hM4~O_=xcpcSzkHBZw1SVh9=YyYBx`P7c^trw=YxwoK6Jgvuaommkh;&>U=7a9$?}aIVRSK#lRBzTW6^)54-#d1-_A&g@DjEvTGs=)3_Dwn6Y7q&v?^L|s$LWd7^~Tl zKplQ(p?tS_k%MzNG-YBSmZ0rPw+MZ6@W|K7+rXoD!=-rbxX3jn)px-UT$i|&cSOv2 zNf-;4h1*hB9~0&NR`C_Xx`8|+mQKzDdPA0{ixn|ZX48s|an@<*c$f$?Vp)lsfAD~! zwnvTI*ZqPb{K0*=)KttmYb>-h_e)% zqZ3y~EssIA*5XW~O%EJ$np*){=$pJtaB@1-IkmhAnIdda@~~Pmq-4EW4IQlPNZs(_SJSN9&&32HubZTd9T9{z^|8_INZolLYi1;@Ucr-2fZI-* ztTAAOaDEo0Z69DHUh#iV?(a3;|F_Xgaf+{KYuMtzt~&S*Wz%zHR|Q=BUn=1Jef>K< zvFGXibk|;;_UiPW5`7OUdr;Yf%KI*~_Smw=mOZxo|7>Y&9|liOVH9TpfOcTuyEU*5 alzk2n%@U8*?rydL*RFcG5Wl(c^M3&r87f); literal 0 HcmV?d00001 From d4da95c410cb88a00573ffdda4b1e8df5d380dfb Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:07:40 +0700 Subject: [PATCH 21/91] Add the arm64 golden for the browser test The Linux port keeps separate reference sets per architecture (screenshots and screenshots-arm); I seeded only the x64 one, so x64 went green and arm64 failed the same gate for the same reason. Seeded from this run's own arm64 artifact, which is byte-identical to the x64 capture. Co-Authored-By: Claude Opus 5 (1M context) --- .../linux/screenshots-arm/BrowserComponent.png | Bin 0 -> 5895 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 scripts/linux/screenshots-arm/BrowserComponent.png diff --git a/scripts/linux/screenshots-arm/BrowserComponent.png b/scripts/linux/screenshots-arm/BrowserComponent.png new file mode 100644 index 0000000000000000000000000000000000000000..ae2ed97233837f5acb328e87ef71539fe92316f0 GIT binary patch literal 5895 zcmeHL`BM}2whvOZyA+jXYYN1|) zD68yN8a5$7NeB>%c8{R~LZ(-FZ%{ z$^ih5c3gA$+V5V8l%JfNH+fUJj$L|%0PJ^J=$ZQy@rldzZp7L3?Ak9Q>`q1Av1?gB zO0LJEP-lu;*oQ~0o{Xqk<7mK`=3F`Q>^JB#5@h}>P-X3B%i^2E(a{1e_{-)_?U)e6oU5*8@dT7!B^_<> zP_2N=>JnLSLA*4Qc-8>jP1Kp6mbgV}z~=fw>tRq};N^V%4l;u?D-s&4w0v+>5)!4H z#gtp*`JiCz5Uh}9D$`3Ao@R!7sT4-0`Q#L39J2JRYDDXLA+zm9Z{=2VmVXA$aVrO; z;VaD?WAxfXCaK%$!>S#M42$C*Wl`7fy9*rth?Tom+QbNU=&RP?()f{Id#|6cOnFP+ zLOYVAUDg8dEx++c`t60K^cLH4 zhqVk{|4D`(33d_$BpsT@EVNcy)Hj@gs(Bp_Abn3SMnBrrjS-Oi0%2Q!${81x1Dbqt zVA3Ho`&&bes~bH=GHut-g&1DqC`qJO^0i;Uo(+1fylHhPxyqVP*UzSZ5hp8BEpT+< zcvb0_#m)_HJ8uqmj~J_7rNLatDCP!oS>P;1>s9Lt4$aF^ynsPr45-qlVmiHAl-qz4 z=YY((j2042vD&U4Q%qo-NkHW$ZQi9 zxiD=q*QeHcX*g^m zOmv>E`pL6vYkMnDu#=+cJmH)yWwR_O+)7DwGGd;FalF*pCx-dKaD9u*uvkGu5@;=t3Z)ir*H%*t<68WSvl!g z*N?&C`u_v#kiWCya2`kN$zCpIA&_BR#4Ii$7(hLAe?LEnfYkq%skxA#OJYAUFpejn zdR`@?HFy&|owI%lD2rRLvIKHh>qc7UbO*zB!#nMefN0w9)6u+|Dl@-8RPw6sVE$ca ze}46~!^-N&)YxIkNhu+Bb)e10o5aRD-5Gew^d#2<$vd~MSP@80_L{CdbF#+M4Dy5T z+QgwxCm5COxLGO1iMm)xtS3QY*`?kT{{v=moJU+<=!@G8Ko1i*e2S*q(U-SL9uo1~ zyxr74c7HgMIhl1y2B)-oUJ(Ut@o4O@gmnlnp{LEA+rZFy_sOT#^P0yoQPXOZDVwkz zAJ-al+}sKZE&A@fywmW&)R1QK4YCYvn`wvGw6D9m%h3{staEEKWT~OS#4hs9lj{wh zyygvBn~f1S>vHSHj=gE4TTSr~1ARc%s6TL4Aqr1b<8!huzDRua`WiFcGvat_Pgi|v z)aV<9&*h`DYbq9x&djUMDk!VZU*#Zd>#Os-iXnaO-niB|W>o+qR@fV5 zRgC{pv-<&p?|J5|ebaf{wdpVqgmOJc(Lk#}OzvDV;(ABSs?8cv*?vi2InmFpZ*?-d zmYY(2w=S4gS>cytcjr*yFe<3Iti(awv~^->2-{R~b6~+x>|SFka3ty!iOoyI?I1?J z%G3@2s2F?$b-YsW3<%p`0%1J0xXPlCyRoT}=^D5)s2mG&BaB(`Q2Wil@9}4L~UwE5!+d$$b>G~PwO%8FZAwsDM)Ci83the_wJA!s* zzY#lT^Jy0R%)OLE*BX>r6Km<@pPt-C+$HMbX#PwFM+_`-qNaA?`B0>XQ|v#Py+|~@4Y3RJ3y;m(FsR7nH@~F^GBOkOO9{7m zl(MTwP8F2kdCfJD= zP-7bZI+@Z+@`VspBWZ4$7lq@pdl6XF`0tNO^?^+Z+rM9XU~L@|vr`bv;3XMOL96+3 zZ(cto6T+F%)>8#OZqjMttRN^5mdK*$<43uz@D;Q~nn{(OcC(@-DZe0)h+KsQ;obOj zq>Arz&_G7F;iP-hM4~O_=xcpcSzkHBZw1SVh9=YyYBx`P7c^trw=YxwoK6Jgvuaommkh;&>U=7a9$?}aIVRSK#lRBzTW6^)54-#d1-_A&g@DjEvTGs=)3_Dwn6Y7q&v?^L|s$LWd7^~Tl zKplQ(p?tS_k%MzNG-YBSmZ0rPw+MZ6@W|K7+rXoD!=-rbxX3jn)px-UT$i|&cSOv2 zNf-;4h1*hB9~0&NR`C_Xx`8|+mQKzDdPA0{ixn|ZX48s|an@<*c$f$?Vp)lsfAD~! zwnvTI*ZqPb{K0*=)KttmYb>-h_e)% zqZ3y~EssIA*5XW~O%EJ$np*){=$pJtaB@1-IkmhAnIdda@~~Pmq-4EW4IQlPNZs(_SJSN9&&32HubZTd9T9{z^|8_INZolLYi1;@Ucr-2fZI-* ztTAAOaDEo0Z69DHUh#iV?(a3;|F_Xgaf+{KYuMtzt~&S*Wz%zHR|Q=BUn=1Jef>K< zvFGXibk|;;_UiPW5`7OUdr;Yf%KI*~_Smw=mOZxo|7>Y&9|liOVH9TpfOcTuyEU*5 alzk2n%@U8*?rydL*RFcG5Wl(c^M3&r87f); literal 0 HcmV?d00001 From fc3907660423a8558b402ef4512e6fc70679fb8e Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:15:36 +0700 Subject: [PATCH 22/91] Refuse crypto names outside the advertised set, and read the raw offset now Four review findings, three of them the same defect: quietly doing something other than what the caller asked for. Cipher transformations The AES dispatch tested for "/GCM/" and "/ECB/" and treated everything else as CBC, so AES/CTR/NoPadding was executed as CBC. RSA treated every non-OAEP string as PKCS#1, and any unrecognized OAEP digest as SHA-256. JavaSE hands these names to JCE, which supports a name as written or refuses it, so the same request encrypted differently depending on the port and nobody was told. Both ports now match the four AES and two RSA constants exactly and refuse anything else. Signature algorithms cn1SignatureDigest / cn1DigestAlgorithm fell through to SHA-256, so a null, misspelled, differently cased or unsupported name -- MD5withRSA -- came back as a valid signature over a digest the caller never named, which no other port would agree with. Both now match Signature's six advertised algorithms and fail otherwise. Making the Windows one answer NULL meant cn1DigestLength(NULL) would call wcscmp on it, and it is evaluated in the callers' declarations, before their checks run; the function and both entry points are guarded. Windows raw time zone offset It sampled 1 January and 1 July of the current year and preferred July when neither was in daylight saving. That is wrong whenever the base offset changes mid-year: in February 2024 Asia/Almaty was still UTC+6, but July's reading is the UTC+5 rule that had not taken effect, and a change landing after July stayed invisible for the rest of the year. The fix removes the sampling rather than adding to it. ICU keeps the base offset and the daylight adjustment in separate calendar fields, so UCAL_ZONE_OFFSET read at the current instant is the raw offset directly. Sweep candidate window A fixed newest-five slice could hide a usable report behind five runs that each omitted a different matrix leg -- the Linux producer especially, whose reports are not reliably published by workflow_run. Candidates are now every run inside the contract's own staleness horizon; the loop already stops as soon as each owned port is covered, so this costs nothing when the newest run is complete. Verified: the OpenSSL harness still passes all its OAEP and ECDSA checks including the DER strictness cases, the conformance contract tests pass at 18, both port sources compile, and crossCompilesWindowsExeWithXwin still links -- which matters here because this touched nativeMethods.m and the compat header again. Co-Authored-By: Claude Opus 5 (1M context) --- .../nativeSources/cn1_linux_crypto.c | 74 ++++++++++++++++--- .../nativeSources/cn1_windows_crypto.c | 68 +++++++++++++++-- .../conformance/backfill_port_status.sh | 23 +++++- vm/ByteCodeTranslator/src/cn1_win_compat.c | 8 +- vm/ByteCodeTranslator/src/cn1_win_compat.h | 9 ++- vm/ByteCodeTranslator/src/nativeMethods.m | 53 +++++-------- 6 files changed, 174 insertions(+), 61 deletions(-) diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c b/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c index 424810157e4..8287560eadf 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c @@ -101,6 +101,43 @@ JAVA_BOOLEAN com_codename1_impl_linux_LinuxNative_secureRandomBytes___byte_1ARRA return JAVA_TRUE; } +/* ------------------------------------------- advertised names, and only those + * + * JavaSE hands these strings to JCE, which either supports a name as written or + * refuses it. Matching loosely here -- picking CBC because a string contains + * neither "/GCM/" nor "/ECB/", or SHA-256 because it names no digest we know -- + * means the same request encrypts or signs differently depending on which port + * runs it, and the caller is never told. A name outside the advertised set is + * refused instead. + */ + +static int cn1IsAesTransformation(const char* transformation) { + return strcmp(transformation, "AES/GCM/NoPadding") == 0 + || strcmp(transformation, "AES/CBC/PKCS5Padding") == 0 + || strcmp(transformation, "AES/CBC/NoPadding") == 0 + || strcmp(transformation, "AES/ECB/PKCS5Padding") == 0; +} + +static int cn1IsRsaTransformation(const char* transformation) { + return strcmp(transformation, "RSA/ECB/OAEPWithSHA-256AndMGF1Padding") == 0 + || strcmp(transformation, "RSA/ECB/PKCS1Padding") == 0; +} + +/* The digest half of Signature's six advertised algorithms; 0 for anything + * else, which the callers turn into a failure. */ +static const EVP_MD* cn1SignatureDigestOrNull(const char* algorithm) { + if (strcmp(algorithm, "SHA256withRSA") == 0 || strcmp(algorithm, "SHA256withECDSA") == 0) { + return EVP_sha256(); + } + if (strcmp(algorithm, "SHA384withRSA") == 0 || strcmp(algorithm, "SHA384withECDSA") == 0) { + return EVP_sha384(); + } + if (strcmp(algorithm, "SHA512withRSA") == 0 || strcmp(algorithm, "SHA512withECDSA") == 0) { + return EVP_sha512(); + } + return 0; +} + /* ------------------------------------------------------------ AES */ static const EVP_CIPHER* cn1AesCipher(const char* transformation, int keyLength) { @@ -127,7 +164,12 @@ JAVA_OBJECT com_codename1_impl_linux_LinuxNative_aesCrypt___java_lang_String_boo const unsigned char* iv = cn1Bytes(ivArray, &ivLength); const unsigned char* aad = cn1Bytes(aadArray, &aadLength); const unsigned char* data = cn1Bytes(dataArray, &dataLength); - int gcm = strstr(mode, "/GCM/") != 0; + int gcm; + if (!cn1IsAesTransformation(mode)) { + cn1CryptoFail("unsupported cipher transformation"); + return JAVA_NULL; + } + gcm = strstr(mode, "/GCM/") != 0; int ecb = strstr(mode, "/ECB/") != 0; int padded = strstr(mode, "NoPadding") == 0; const EVP_CIPHER* cipher = cn1AesCipher(mode, keyLength); @@ -256,8 +298,12 @@ static EVP_PKEY* cn1PrivateKey(const unsigned char* der, int length) { } static int cn1ApplyRsaPadding(EVP_PKEY_CTX* ctx, const char* transformation) { + if (!cn1IsRsaTransformation(transformation)) { + cn1CryptoFail("unsupported cipher transformation"); + return 0; + } if (strstr(transformation, "OAEP") != 0) { - const EVP_MD* md = strstr(transformation, "SHA-1") != 0 ? EVP_sha1() : EVP_sha256(); + const EVP_MD* md = EVP_sha256(); // The mask function stays on SHA-1 even when the OAEP digest is // SHA-256. That is what the JCE providers behind the JavaSE and // Android ports do for this transformation name, and ciphertext has to @@ -327,16 +373,7 @@ JAVA_OBJECT com_codename1_impl_linux_LinuxNative_rsaCrypt___java_lang_String_boo /* ------------------------------------------------------------ signatures */ static const EVP_MD* cn1SignatureDigest(const char* algorithm) { - if (strstr(algorithm, "SHA512") != 0 || strstr(algorithm, "SHA-512") != 0) { - return EVP_sha512(); - } - if (strstr(algorithm, "SHA384") != 0 || strstr(algorithm, "SHA-384") != 0) { - return EVP_sha384(); - } - if (strstr(algorithm, "SHA1") != 0 || strstr(algorithm, "SHA-1") != 0) { - return EVP_sha1(); - } - return EVP_sha256(); + return cn1SignatureDigestOrNull(algorithm); } JAVA_OBJECT com_codename1_impl_linux_LinuxNative_signData___java_lang_String_byte_1ARRAY_byte_1ARRAY_R_byte_1ARRAY( @@ -354,6 +391,14 @@ JAVA_OBJECT com_codename1_impl_linux_LinuxNative_signData___java_lang_String_byt if (key == 0) { return JAVA_NULL; } + if (cn1SignatureDigest(name) == 0) { + /* Not one of Signature's advertised algorithms. Passing a null digest + * on would let OpenSSL choose one, which is how an unsupported name + * used to come back as a valid signature over a different digest. */ + cn1CryptoFail("unsupported signature algorithm"); + EVP_PKEY_free(key); + return JAVA_NULL; + } ctx = EVP_MD_CTX_new(); if (ctx == 0) { cn1CryptoFail("digest context"); @@ -398,6 +443,11 @@ JAVA_BOOLEAN com_codename1_impl_linux_LinuxNative_verifyData___java_lang_String_ if (key == 0) { return JAVA_FALSE; } + if (cn1SignatureDigest(name) == 0) { + cn1CryptoFail("unsupported signature algorithm"); + EVP_PKEY_free(key); + return JAVA_FALSE; + } ctx = EVP_MD_CTX_new(); if (ctx == 0) { cn1CryptoFail("digest context"); diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c index df8a4f62d24..5090e98c897 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c +++ b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c @@ -118,6 +118,28 @@ JAVA_BOOLEAN com_codename1_impl_windows_WindowsNative_secureRandomBytes___byte_1 return JAVA_TRUE; } +/* ------------------------------------------- advertised names, and only those + * + * JavaSE hands these strings to JCE, which either supports a name as written or + * refuses it. Matching loosely here -- picking CBC because a string contains + * neither "/GCM/" nor "/ECB/", or SHA-256 because it names no digest we know -- + * means the same request encrypts or signs differently depending on which port + * runs it, and the caller is never told. A name outside the advertised set is + * refused instead. + */ + +static int cn1IsAesTransformation(const char* transformation) { + return strcmp(transformation, "AES/GCM/NoPadding") == 0 + || strcmp(transformation, "AES/CBC/PKCS5Padding") == 0 + || strcmp(transformation, "AES/CBC/NoPadding") == 0 + || strcmp(transformation, "AES/ECB/PKCS5Padding") == 0; +} + +static int cn1IsRsaTransformation(const char* transformation) { + return strcmp(transformation, "RSA/ECB/OAEPWithSHA-256AndMGF1Padding") == 0 + || strcmp(transformation, "RSA/ECB/PKCS1Padding") == 0; +} + /* ------------------------------------------------------------ AES */ JAVA_OBJECT com_codename1_impl_windows_WindowsNative_aesCrypt___java_lang_String_boolean_byte_1ARRAY_byte_1ARRAY_byte_1ARRAY_byte_1ARRAY_R_byte_1ARRAY( @@ -129,7 +151,7 @@ JAVA_OBJECT com_codename1_impl_windows_WindowsNative_aesCrypt___java_lang_String unsigned char* iv = cn1Bytes(ivArray, &ivLength); unsigned char* aad = cn1Bytes(aadArray, &aadLength); unsigned char* data = cn1Bytes(dataArray, &dataLength); - int gcm = strstr(mode, "/GCM/") != 0; + int gcm; int ecb = strstr(mode, "/ECB/") != 0; int padded = strstr(mode, "NoPadding") == 0; int bodyLength = dataLength; @@ -143,6 +165,11 @@ JAVA_OBJECT com_codename1_impl_windows_WindowsNative_aesCrypt___java_lang_String BCRYPT_AUTHENTICATED_CIPHER_MODE_INFO auth; unsigned char tag[CN1_GCM_TAG_BYTES]; + if (!cn1IsAesTransformation(mode)) { + cn1CryptoFail("unsupported cipher transformation", 0); + return JAVA_NULL; + } + gcm = strstr(mode, "/GCM/") != 0; /* A missing GCM nonce would otherwise repeat across messages under one * key, which destroys the mode, and a short CBC IV is read as a whole * block. */ @@ -358,20 +385,29 @@ static int cn1PublicKeyIsEc(const unsigned char* der, int length) { return isEc; } +/* The digest half of Signature's six advertised algorithms; NULL for anything + * else, which the callers turn into a failure rather than signing with a digest + * nobody asked for. */ static LPCWSTR cn1DigestAlgorithm(const char* algorithm) { - if (strstr(algorithm, "SHA512") != 0 || strstr(algorithm, "SHA-512") != 0) { - return BCRYPT_SHA512_ALGORITHM; + if (strcmp(algorithm, "SHA256withRSA") == 0 || strcmp(algorithm, "SHA256withECDSA") == 0) { + return BCRYPT_SHA256_ALGORITHM; } - if (strstr(algorithm, "SHA384") != 0 || strstr(algorithm, "SHA-384") != 0) { + if (strcmp(algorithm, "SHA384withRSA") == 0 || strcmp(algorithm, "SHA384withECDSA") == 0) { return BCRYPT_SHA384_ALGORITHM; } - if (strstr(algorithm, "SHA1") != 0 || strstr(algorithm, "SHA-1") != 0) { - return BCRYPT_SHA1_ALGORITHM; + if (strcmp(algorithm, "SHA512withRSA") == 0 || strcmp(algorithm, "SHA512withECDSA") == 0) { + return BCRYPT_SHA512_ALGORITHM; } - return BCRYPT_SHA256_ALGORITHM; + return NULL; } static int cn1DigestLength(LPCWSTR algorithm) { + if (algorithm == NULL) { + /* cn1DigestAlgorithm answers NULL for a name outside the advertised + * set; the callers check for that, but this is evaluated in their + * declarations, before the check runs. */ + return 0; + } if (wcscmp(algorithm, BCRYPT_SHA512_ALGORITHM) == 0) { return 64; } @@ -741,7 +777,7 @@ JAVA_OBJECT com_codename1_impl_windows_WindowsNative_rsaCrypt___java_lang_String BCRYPT_KEY_HANDLE publicKey = encrypt ? cn1PublicKey(keyDer, keyLength) : NULL; NCRYPT_KEY_HANDLE privateKey = encrypt ? 0 : cn1PrivateKey(keyDer, keyLength, 0); int oaepMode = strstr(mode, "OAEP") != 0; - LPCWSTR labelDigest = strstr(mode, "SHA-1") != 0 ? BCRYPT_SHA1_ALGORITHM : BCRYPT_SHA256_ALGORITHM; + LPCWSTR labelDigest = BCRYPT_SHA256_ALGORITHM; unsigned char* out = 0; unsigned char* block = 0; ULONG outLength = 0, produced = 0; @@ -749,6 +785,10 @@ JAVA_OBJECT com_codename1_impl_windows_WindowsNative_rsaCrypt___java_lang_String NTSTATUS status; JAVA_OBJECT result = JAVA_NULL; + if (!cn1IsRsaTransformation(mode)) { + cn1CryptoFail("unsupported cipher transformation", 0); + return JAVA_NULL; + } if (encrypt ? (publicKey == NULL) : (privateKey == 0)) { return JAVA_NULL; } @@ -878,6 +918,13 @@ JAVA_OBJECT com_codename1_impl_windows_WindowsNative_signData___java_lang_String if (key == 0) { return JAVA_NULL; } + if (digestAlgorithm == NULL) { + /* Not one of Signature's advertised algorithms. Falling back to SHA-256 + * would return a valid signature over a digest the caller never asked + * for, which no other port would agree with. */ + cn1CryptoFail("unsupported signature algorithm", 0); + goto done; + } if (!cn1Digest(digestAlgorithm, data, dataLength, digest, digestLength)) { goto done; } @@ -942,6 +989,11 @@ JAVA_BOOLEAN com_codename1_impl_windows_WindowsNative_verifyData___java_lang_Str if (key == NULL) { return JAVA_FALSE; } + if (digestAlgorithm == NULL) { + cn1CryptoFail("unsupported signature algorithm", 0); + BCryptDestroyKey(key); + return JAVA_FALSE; + } if (cn1Digest(digestAlgorithm, data, dataLength, digest, digestLength)) { unsigned char raw[132]; const unsigned char* toVerify = signature; diff --git a/scripts/hellocodenameone/conformance/backfill_port_status.sh b/scripts/hellocodenameone/conformance/backfill_port_status.sh index d714e498e5c..374cb72a3b0 100755 --- a/scripts/hellocodenameone/conformance/backfill_port_status.sh +++ b/scripts/hellocodenameone/conformance/backfill_port_status.sh @@ -61,17 +61,32 @@ trap cleanup EXIT published=0 skipped=0 +# The contract's own freshness window bounds how far back a candidate run is +# worth considering: a report older than this is stale by definition, so there +# is nothing to be gained by looking past it. +sweep_stale_days="$(jq -r '.stale_after_days' "${MANIFEST}")" + # One producing workflow can own several ports (the iOS suite emits four), so # sweep per workflow and let the report itself name the port it belongs to. while IFS= read -r workflow; do # Newest first, and a failed run counts: a suite that fails still uploads the # normalized report, and a report that records real failures is the result # the table is supposed to show. - candidates="$(gh run list --workflow "${workflow}" --branch master --limit 40 \ + # Every run still inside the staleness horizon is a candidate, rather than a + # fixed newest-five slice. A workflow whose matrix legs fail independently -- + # the Linux producer especially, whose reports are not reliably published by + # workflow_run -- can accumulate several runs that each omit a different leg, + # and a five-run cap then hides a perfectly good report just behind them. The + # loop below stops as soon as every port the workflow owns is covered, so the + # wider net costs nothing when the newest run is complete. + horizon="$(date -u -d "${sweep_stale_days} days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \ + || date -u -v-"${sweep_stale_days}"d +%Y-%m-%dT%H:%M:%SZ)" + candidates="$(gh run list --workflow "${workflow}" --branch master --limit 100 \ --json databaseId,event,conclusion,updatedAt \ - --jq '[.[] | select((.event == "push" or .event == "schedule") and - (.conclusion == "success" or .conclusion == "failure"))] - | sort_by(.updatedAt) | reverse | .[0:5] | .[].databaseId')" + --jq --arg horizon "${horizon}" '[.[] | select((.event == "push" or .event == "schedule") and + (.conclusion == "success" or .conclusion == "failure") and + (.updatedAt >= $horizon))] + | sort_by(.updatedAt) | reverse | .[].databaseId')" if [ -z "${candidates}" ]; then echo "No completed master run for ${workflow}; nothing to publish." >&2 continue diff --git a/vm/ByteCodeTranslator/src/cn1_win_compat.c b/vm/ByteCodeTranslator/src/cn1_win_compat.c index 829b337295b..eefef097452 100644 --- a/vm/ByteCodeTranslator/src/cn1_win_compat.c +++ b/vm/ByteCodeTranslator/src/cn1_win_compat.c @@ -347,7 +347,8 @@ static int cn1IcuAvailable(void) { return resolved > 0; } -int cn1_win_zone_offset_millis(const char* zoneId, long long millis, int* offsetOut, int* dstOut) { +int cn1_win_zone_offset_millis(const char* zoneId, long long millis, int* offsetOut, + int* dstOut, int* rawOut) { WCHAR zone[128]; CN1UCalendar cal; int32_t status = 0; @@ -376,6 +377,11 @@ int cn1_win_zone_offset_millis(const char* zoneId, long long millis, int* offset if (dstOut != 0) { *dstOut = dstOffset != 0; } + if (rawOut != 0) { + /* UCAL_ZONE_OFFSET is the standard-time offset on its own; the daylight + * adjustment is the separate UCAL_DST_OFFSET field. */ + *rawOut = (int) zoneOffset; + } return 1; } diff --git a/vm/ByteCodeTranslator/src/cn1_win_compat.h b/vm/ByteCodeTranslator/src/cn1_win_compat.h index 1ff1db0c903..8f77f92da75 100644 --- a/vm/ByteCodeTranslator/src/cn1_win_compat.h +++ b/vm/ByteCodeTranslator/src/cn1_win_compat.h @@ -148,9 +148,14 @@ long long cn1_monotonic_micros(void); zone identifier at an instant, writing the offset to offsetOut and whether daylight time is in effect to dstOut. Returns non-zero on success, and zero when the platform cannot answer -- the caller then keeps whatever the C - runtime reported. Lives in cn1_win_compat.c because resolving it needs + runtime reported. rawOut, when non-null, receives the zone's standard-time + offset at that instant with any daylight adjustment excluded -- ICU tracks + the two separately, so the base offset in force now needs no guessing from + seasonal samples. Any output pointer may be null. + Lives in cn1_win_compat.c because resolving it needs , which this header keeps out of translated units. */ -int cn1_win_zone_offset_millis(const char* zoneId, long long millis, int* offsetOut, int* dstOut); +int cn1_win_zone_offset_millis(const char* zoneId, long long millis, int* offsetOut, + int* dstOut, int* rawOut); /* --- environment / time.h POSIX helpers absent from MSVC --- Thin static-inline wrappers over the MSVC equivalents; used by the date / diff --git a/vm/ByteCodeTranslator/src/nativeMethods.m b/vm/ByteCodeTranslator/src/nativeMethods.m index 26394d14cf4..c2d14639aa1 100644 --- a/vm/ByteCodeTranslator/src/nativeMethods.m +++ b/vm/ByteCodeTranslator/src/nativeMethods.m @@ -2623,8 +2623,9 @@ static void cn1_with_timezone(const char* zoneId, void (*func)(void*), void* ctx * The lookup itself lives in cn1_win_compat.c, the one translation unit that * may include ; keeping it out of here is what lets the clean * target compile this file against a minimal SDK layout. */ -static int cn1WinZoneOffsetMillis(const char* zoneId, long long millis, int* offsetOut, int* dstOut) { - return cn1_win_zone_offset_millis(zoneId, millis, offsetOut, dstOut); +static int cn1WinZoneOffsetMillis(const char* zoneId, long long millis, int* offsetOut, + int* dstOut, int* rawOut) { + return cn1_win_zone_offset_millis(zoneId, millis, offsetOut, dstOut, rawOut); } /* Milliseconds since the epoch for a set of UTC calendar fields. */ @@ -2752,7 +2753,7 @@ JAVA_INT java_util_TimeZone_getTimezoneOffset___java_lang_String_int_int_int_int { int offset = 0; if (cn1WinZoneOffsetMillis(buffer, cn1WinUtcMillis(year, month, day, timeOfDayMillis), - &offset, 0)) { + &offset, 0, 0)) { return offset; } } @@ -2771,36 +2772,20 @@ JAVA_INT java_util_TimeZone_getTimezoneRawOffset___java_lang_String_R_int(CODENA cn1_timezone_raw_ctx ctx; #ifdef _WIN32 { - /* The raw offset is the current standard-time one. Sample both - * solstices of the current year rather than a fixed past year: a zone - * whose base offset changes (Asia/Almaty moved from UTC+6 to UTC+5 - * during 2024, with neither sample flagged as daylight saving) would - * otherwise report its retired offset forever. When neither sample is - * in daylight saving they can still differ, so prefer the later one -- - * that is the rule in force now. */ - int januaryOffset = 0, januaryDst = 0, julyOffset = 0, julyDst = 0; - time_t nowSeconds = time(NULL); - struct tm nowUtc; - int currentYear = 2024; -#ifdef _WIN32 - if (gmtime_s(&nowUtc, &nowSeconds) == 0) { - currentYear = nowUtc.tm_year + 1900; - } -#endif - if (cn1WinZoneOffsetMillis(buffer, cn1WinUtcMillis(currentYear, 1, 1, 43200000), - &januaryOffset, &januaryDst) && - cn1WinZoneOffsetMillis(buffer, cn1WinUtcMillis(currentYear, 7, 1, 43200000), - &julyOffset, &julyDst)) { - if (!januaryDst && !julyDst) { - return julyOffset; - } - if (!julyDst) { - return julyOffset; - } - if (!januaryDst) { - return januaryOffset; - } - return januaryOffset < julyOffset ? januaryOffset : julyOffset; + /* The raw offset is the standard-time one in force right now. ICU keeps + * the base offset and the daylight adjustment in separate calendar + * fields, so asking at the current instant answers it directly. + * + * This used to sample both solstices of the current year and prefer + * July's when neither was in daylight saving, which is wrong whenever + * the base offset changes mid-year: with the clock in February 2024, + * Asia/Almaty was still UTC+6 but July's reading is the UTC+5 rule that + * had not taken effect yet, and a change landing after July stayed + * invisible for the rest of the year. */ + int rawOffset = 0; + long long nowMillis = (long long) time(NULL) * 1000LL; + if (cn1WinZoneOffsetMillis(buffer, nowMillis, 0, 0, &rawOffset)) { + return rawOffset; } } #endif @@ -2824,7 +2809,7 @@ JAVA_BOOLEAN java_util_TimeZone_isTimezoneDST___java_lang_String_long_R_boolean( #ifdef _WIN32 { int dst = 0; - if (cn1WinZoneOffsetMillis(buffer, (long long) millis, 0, &dst)) { + if (cn1WinZoneOffsetMillis(buffer, (long long) millis, 0, &dst, 0)) { return dst ? JAVA_TRUE : JAVA_FALSE; } } From eeb59d114070164ae90fe1f233c1dd2a4fab3bd1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:25:46 +0700 Subject: [PATCH 23/91] Make a documented skip prove itself, and compare timestamps as instants The skip erratum was the important one: a green cell could outrun its explanation. The table treated any skip of a named test as documented, matching on the test name alone. VideoIORoundTripTest wrapped writer creation and frame writing in one catch and reported everything as "encode-unavailable-on-", so an encoder that regressed produced the same skip an encoder-less runner does -- and the video round trip rendered green, explained, and wrong. The test now separates the two. Failing to obtain a writer is the documented "this runner exposes no encoder" case and still skips. Failing after one was obtained means the encoder is present and broke, which is a failure. The errata now declare which reason codes they cover, and a cell reads as documented only when every reason the run reported matches one of them. Verified by injecting a regression-style reason into a report: the cell drops from a documented green to partial, while the twelve genuine documented cells are unchanged. The sweep's own timestamp handling disagreed with its gate The gate deliberately tolerates an hour of clock skew, and then the closing freshness assertion read any negative age as an unreadable timestamp -- so the nightly job could fail over a report the same run had just published, until wall time caught up. The assertion now allows the same margin, and keeps failing on a genuinely unparseable value, which is now reported distinctly rather than sharing the -1 sentinel. The "is this newer" test was lexical, which is not chronological for any timestamp the gate accepts but that is not normalized to Z: 2026-08-01T01:00:00+02:00 sorts after 2026-08-01T00:00:00Z while being an hour older. Both are parsed and compared as instants now; the example above is one of the cases checked. A report may only claim a port its workflow produces The port is read out of the artifact, so a misconfigured matrix stamping another port's id on its report would have been published straight over that port's entry -- Linux evidence replacing Android's genuine result, with both the gate and the freshness check satisfied. The id must now be in the set the workflow is declared to own. Co-Authored-By: Claude Opus 5 (1M context) --- docs/website/data/port_status_supplement.json | 783 ++++++++++++++++-- .../partials/port-status-feature-status.html | 30 +- .../tests/VideoIORoundTripTest.java | 17 +- .../conformance/backfill_port_status.sh | 60 +- 4 files changed, 810 insertions(+), 80 deletions(-) diff --git a/docs/website/data/port_status_supplement.json b/docs/website/data/port_status_supplement.json index 2f2ad1cd4d7..bc4b6f719aa 100644 --- a/docs/website/data/port_status_supplement.json +++ b/docs/website/data/port_status_supplement.json @@ -4,13 +4,20 @@ "test": "CameraApiTest", "reason": "The unattended runner cannot respond to operating-system camera permission prompts or provide stable physical-camera input. The test therefore avoids opening a real session on targets where doing so could hang CI or produce nondeterministic frames.", "platform_support": "A skipped camera test does not mean the Codename One port is unsupported. Every listed target remains a supported port; camera availability is a separate runtime capability and depends on the device, permissions, and camera backend.", - "verification": "The native camera implementations are compiled in their port builds and exercised with granted permissions on real hardware or an interactive browser. Deterministic API assertions use the synthetic camera backend outside this portability table." + "verification": "The native camera implementations are compiled in their port builds and exercised with granted permissions on real hardware or an interactive browser. Deterministic API assertions use the synthetic camera backend outside this portability table.", + "reason_codes": [ + "needs-runtime-permission-on-" + ] }, { "test": "VideoIORoundTripTest", "reason": "This assertion requires a working video encoder as well as a decoder. Apple simulator and constrained-device runners do not expose a stable encoder to the headless job, so the encode/decode round trip is skipped there.", "platform_support": "The port remains supported. Video playback and frame decoding are measured separately by VideoIODecodedFramesScreenshotTest; this skip is limited to creating a new encoded video in that CI environment.", - "verification": "Encoder-backed targets run the full counting-frame and audio round trip. Apple media playback and decoding are covered separately, while device-only recording is verified in signed hardware builds." + "verification": "Encoder-backed targets run the full counting-frame and audio round trip. Apple media playback and decoding are covered separately, while device-only recording is verified in signed hardware builds.", + "reason_codes": [ + "encode-unavailable-on-", + "VideoIO-unsupported-on-" + ] } ], "features": [ @@ -22,12 +29,59 @@ "testing": "Build each native backend, then run permission-granted capture checks on hardware or an interactive browser and verify a real frame and saved image.", "why_not_automated": "A headless portability run cannot accept consent dialogs, guarantee a camera device, or compare nondeterministic sensor frames.", "coverage": [ - {"ports": ["android"], "state": "supported", "label": "CameraX", "detail": "Native CameraX backend; runtime permission and hardware required."}, - {"ports": ["ios-gl", "ios-metal", "mac-native"], "state": "supported", "label": "AVFoundation", "detail": "Native AVFoundation backend; signed build, entitlement, permission, and camera hardware required."}, - {"ports": ["javascript"], "state": "conditional", "label": "MediaDevices", "detail": "Browser mediaDevices backend when the page is secure and the user grants access."}, - {"ports": ["linux-x64", "linux-arm64"], "state": "conditional", "label": "Native camera", "detail": "Native desktop camera backend when a compatible host camera is available."}, - {"ports": ["windows-x64", "windows-arm64"], "state": "conditional", "label": "Media Foundation", "detail": "Native Media Foundation camera backend when the host exposes a camera."}, - {"ports": ["watchos", "tvos"], "state": "unavailable", "label": "No camera", "detail": "The target form factor does not expose a Codename One camera-capture backend."} + { + "ports": [ + "android" + ], + "state": "supported", + "label": "CameraX", + "detail": "Native CameraX backend; runtime permission and hardware required." + }, + { + "ports": [ + "ios-gl", + "ios-metal", + "mac-native" + ], + "state": "supported", + "label": "AVFoundation", + "detail": "Native AVFoundation backend; signed build, entitlement, permission, and camera hardware required." + }, + { + "ports": [ + "javascript" + ], + "state": "conditional", + "label": "MediaDevices", + "detail": "Browser mediaDevices backend when the page is secure and the user grants access." + }, + { + "ports": [ + "linux-x64", + "linux-arm64" + ], + "state": "conditional", + "label": "Native camera", + "detail": "Native desktop camera backend when a compatible host camera is available." + }, + { + "ports": [ + "windows-x64", + "windows-arm64" + ], + "state": "conditional", + "label": "Media Foundation", + "detail": "Native Media Foundation camera backend when the host exposes a camera." + }, + { + "ports": [ + "watchos", + "tvos" + ], + "state": "unavailable", + "label": "No camera", + "detail": "The target form factor does not expose a Codename One camera-capture backend." + } ] }, { @@ -38,10 +92,45 @@ "testing": "Use a signed permission-granted build, record a calibrated tone or voice sample, and verify duration, level, and playback on the target device.", "why_not_automated": "Hosted runners do not provide consistent microphones and operating systems deliberately require interactive consent.", "coverage": [ - {"ports": ["android", "ios-gl", "ios-metal", "mac-native"], "state": "supported", "label": "Native recorder", "detail": "Native media recorder with runtime permission and physical input."}, - {"ports": ["javascript"], "state": "conditional", "label": "Browser capture", "detail": "Available through browser media capture on a secure origin after consent."}, - {"ports": ["linux-x64", "linux-arm64", "windows-x64", "windows-arm64"], "state": "conditional", "label": "Host audio", "detail": "Available when the native desktop media stack exposes an input device."}, - {"ports": ["watchos", "tvos"], "state": "unavailable", "label": "No suite backend", "detail": "No general-purpose Codename One recording backend is declared for this target."} + { + "ports": [ + "android", + "ios-gl", + "ios-metal", + "mac-native" + ], + "state": "supported", + "label": "Native recorder", + "detail": "Native media recorder with runtime permission and physical input." + }, + { + "ports": [ + "javascript" + ], + "state": "conditional", + "label": "Browser capture", + "detail": "Available through browser media capture on a secure origin after consent." + }, + { + "ports": [ + "linux-x64", + "linux-arm64", + "windows-x64", + "windows-arm64" + ], + "state": "conditional", + "label": "Host audio", + "detail": "Available when the native desktop media stack exposes an input device." + }, + { + "ports": [ + "watchos", + "tvos" + ], + "state": "unavailable", + "label": "No suite backend", + "detail": "No general-purpose Codename One recording backend is declared for this target." + } ] }, { @@ -52,11 +141,52 @@ "testing": "Run the AR or sensor sample on supported hardware, verify session startup and sensor updates, and compare motion against a controlled physical action.", "why_not_automated": "Simulators do not provide a real camera pose, AR runtime, or reproducible physical movement; the automated row checks API contracts and unsupported fallbacks instead.", "coverage": [ - {"ports": ["android"], "state": "conditional", "label": "ARCore / sensors", "detail": "Available on compatible devices with the required vendor services and sensors."}, - {"ports": ["ios-gl", "ios-metal"], "state": "conditional", "label": "ARKit / sensors", "detail": "Available on compatible iPhone or iPad hardware."}, - {"ports": ["watchos"], "state": "conditional", "label": "Motion sensors", "detail": "Motion sensing is form-factor dependent; general AR sessions are not applicable."}, - {"ports": ["javascript"], "state": "conditional", "label": "Browser sensors", "detail": "Browser sensor APIs depend on browser policy, secure context, consent, and hardware."}, - {"ports": ["mac-native", "linux-x64", "linux-arm64", "windows-x64", "windows-arm64", "tvos"], "state": "unavailable", "label": "Fallback contract", "detail": "The portable API reports that no live AR or motion backend is present."} + { + "ports": [ + "android" + ], + "state": "conditional", + "label": "ARCore / sensors", + "detail": "Available on compatible devices with the required vendor services and sensors." + }, + { + "ports": [ + "ios-gl", + "ios-metal" + ], + "state": "conditional", + "label": "ARKit / sensors", + "detail": "Available on compatible iPhone or iPad hardware." + }, + { + "ports": [ + "watchos" + ], + "state": "conditional", + "label": "Motion sensors", + "detail": "Motion sensing is form-factor dependent; general AR sessions are not applicable." + }, + { + "ports": [ + "javascript" + ], + "state": "conditional", + "label": "Browser sensors", + "detail": "Browser sensor APIs depend on browser policy, secure context, consent, and hardware." + }, + { + "ports": [ + "mac-native", + "linux-x64", + "linux-arm64", + "windows-x64", + "windows-arm64", + "tvos" + ], + "state": "unavailable", + "label": "Fallback contract", + "detail": "The portable API reports that no live AR or motion backend is present." + } ] }, { @@ -67,10 +197,45 @@ "testing": "Install a signed build, grant the appropriate permission level, feed known routes or physically cross a geofence, and verify foreground and background callbacks.", "why_not_automated": "The result depends on user permission, GPS/radio state, operating-system scheduling, and physical movement outside the hosted runner.", "coverage": [ - {"ports": ["android", "ios-gl", "ios-metal"], "state": "supported", "label": "Native location", "detail": "Native foreground and background location subject to OS permissions and policy."}, - {"ports": ["mac-native", "watchos"], "state": "conditional", "label": "Apple location", "detail": "Available where the target and entitlement expose Core Location behavior."}, - {"ports": ["javascript"], "state": "conditional", "label": "Geolocation", "detail": "Foreground browser geolocation on a secure origin after consent; background behavior is browser-limited."}, - {"ports": ["linux-x64", "linux-arm64", "windows-x64", "windows-arm64", "tvos"], "state": "unavailable", "label": "No declared backend", "detail": "No complete location and geofencing backend is declared for this portability target."} + { + "ports": [ + "android", + "ios-gl", + "ios-metal" + ], + "state": "supported", + "label": "Native location", + "detail": "Native foreground and background location subject to OS permissions and policy." + }, + { + "ports": [ + "mac-native", + "watchos" + ], + "state": "conditional", + "label": "Apple location", + "detail": "Available where the target and entitlement expose Core Location behavior." + }, + { + "ports": [ + "javascript" + ], + "state": "conditional", + "label": "Geolocation", + "detail": "Foreground browser geolocation on a secure origin after consent; background behavior is browser-limited." + }, + { + "ports": [ + "linux-x64", + "linux-arm64", + "windows-x64", + "windows-arm64", + "tvos" + ], + "state": "unavailable", + "label": "No declared backend", + "detail": "No complete location and geofencing backend is declared for this portability target." + } ] }, { @@ -81,11 +246,52 @@ "testing": "Use a signed app and real provider credentials, register a device, send from the push service, and verify foreground, background, and launch delivery.", "why_not_automated": "Push requires certificates or provider keys, an externally reachable service, a uniquely registered installation, and asynchronous OS delivery.", "coverage": [ - {"ports": ["android"], "state": "supported", "label": "FCM", "detail": "Firebase Cloud Messaging with app credentials and notification permission."}, - {"ports": ["ios-gl", "ios-metal"], "state": "supported", "label": "APNs", "detail": "Apple Push Notification service with signing entitlements and permission."}, - {"ports": ["javascript"], "state": "conditional", "label": "Web Push", "detail": "Supported by compatible browsers on HTTPS with service-worker and provider setup."}, - {"ports": ["mac-native"], "state": "conditional", "label": "Apple entitlement", "detail": "Depends on the signed macOS target and its notification entitlements."}, - {"ports": ["linux-x64", "linux-arm64", "windows-x64", "windows-arm64", "watchos", "tvos"], "state": "unavailable", "label": "No standalone backend", "detail": "No current standalone Codename One remote-push backend is declared for this target."} + { + "ports": [ + "android" + ], + "state": "supported", + "label": "FCM", + "detail": "Firebase Cloud Messaging with app credentials and notification permission." + }, + { + "ports": [ + "ios-gl", + "ios-metal" + ], + "state": "supported", + "label": "APNs", + "detail": "Apple Push Notification service with signing entitlements and permission." + }, + { + "ports": [ + "javascript" + ], + "state": "conditional", + "label": "Web Push", + "detail": "Supported by compatible browsers on HTTPS with service-worker and provider setup." + }, + { + "ports": [ + "mac-native" + ], + "state": "conditional", + "label": "Apple entitlement", + "detail": "Depends on the signed macOS target and its notification entitlements." + }, + { + "ports": [ + "linux-x64", + "linux-arm64", + "windows-x64", + "windows-arm64", + "watchos", + "tvos" + ], + "state": "unavailable", + "label": "No standalone backend", + "detail": "No current standalone Codename One remote-push backend is declared for this target." + } ] }, { @@ -96,10 +302,45 @@ "testing": "Create sandbox products, sign with a store account, complete purchase and restore flows, and validate receipts against the store or Commerce backend.", "why_not_automated": "The flow requires store-side product configuration, signed identities, sandbox accounts, payment UI, and mutable server receipt state.", "coverage": [ - {"ports": ["android"], "state": "supported", "label": "Play Billing", "detail": "Google Play purchase and subscription flow in a store-installed build."}, - {"ports": ["ios-gl", "ios-metal"], "state": "supported", "label": "StoreKit", "detail": "Apple StoreKit purchase, restore, and subscription flow."}, - {"ports": ["mac-native"], "state": "conditional", "label": "StoreKit", "detail": "Requires a signed Mac App Store configuration and matching products."}, - {"ports": ["javascript", "linux-x64", "linux-arm64", "windows-x64", "windows-arm64", "watchos", "tvos"], "state": "unavailable", "label": "No store adapter", "detail": "No standalone Codename One store-purchase adapter is declared for this target."} + { + "ports": [ + "android" + ], + "state": "supported", + "label": "Play Billing", + "detail": "Google Play purchase and subscription flow in a store-installed build." + }, + { + "ports": [ + "ios-gl", + "ios-metal" + ], + "state": "supported", + "label": "StoreKit", + "detail": "Apple StoreKit purchase, restore, and subscription flow." + }, + { + "ports": [ + "mac-native" + ], + "state": "conditional", + "label": "StoreKit", + "detail": "Requires a signed Mac App Store configuration and matching products." + }, + { + "ports": [ + "javascript", + "linux-x64", + "linux-arm64", + "windows-x64", + "windows-arm64", + "watchos", + "tvos" + ], + "state": "unavailable", + "label": "No store adapter", + "detail": "No standalone Codename One store-purchase adapter is declared for this target." + } ] }, { @@ -110,9 +351,38 @@ "testing": "Populate a device test account, grant access, exercise read/select/write operations, and verify the result in the native contacts or calendar application.", "why_not_automated": "Access is permission-gated and the expected result lives in private user databases and native UI outside the test process.", "coverage": [ - {"ports": ["android", "ios-gl", "ios-metal"], "state": "supported", "label": "Native stores", "detail": "Native contacts and calendar services after the user grants access."}, - {"ports": ["mac-native"], "state": "conditional", "label": "Apple services", "detail": "Availability depends on the macOS target, entitlement, and user account."}, - {"ports": ["javascript", "linux-x64", "linux-arm64", "windows-x64", "windows-arm64", "watchos", "tvos"], "state": "unavailable", "label": "No portable store", "detail": "The target does not expose a complete Codename One personal-data backend."} + { + "ports": [ + "android", + "ios-gl", + "ios-metal" + ], + "state": "supported", + "label": "Native stores", + "detail": "Native contacts and calendar services after the user grants access." + }, + { + "ports": [ + "mac-native" + ], + "state": "conditional", + "label": "Apple services", + "detail": "Availability depends on the macOS target, entitlement, and user account." + }, + { + "ports": [ + "javascript", + "linux-x64", + "linux-arm64", + "windows-x64", + "windows-arm64", + "watchos", + "tvos" + ], + "state": "unavailable", + "label": "No portable store", + "detail": "The target does not expose a complete Codename One personal-data backend." + } ] }, { @@ -123,9 +393,38 @@ "testing": "Enroll biometrics on hardware, run success, cancellation, lockout, and enrollment-change cases, and verify protected-secret invalidation.", "why_not_automated": "Hosted runners have no enrolled biometric hardware and native prompts are intentionally controlled by the user and secure hardware.", "coverage": [ - {"ports": ["android"], "state": "supported", "label": "BiometricPrompt", "detail": "Android BiometricPrompt or legacy fingerprint support, backed by Android Keystore."}, - {"ports": ["ios-gl", "ios-metal"], "state": "supported", "label": "LocalAuthentication", "detail": "Face ID or Touch ID through LocalAuthentication and Keychain."}, - {"ports": ["mac-native", "javascript", "linux-x64", "linux-arm64", "windows-x64", "windows-arm64", "watchos", "tvos"], "state": "unavailable", "label": "NOT_AVAILABLE", "detail": "The portable API returns its documented non-supporting fallback."} + { + "ports": [ + "android" + ], + "state": "supported", + "label": "BiometricPrompt", + "detail": "Android BiometricPrompt or legacy fingerprint support, backed by Android Keystore." + }, + { + "ports": [ + "ios-gl", + "ios-metal" + ], + "state": "supported", + "label": "LocalAuthentication", + "detail": "Face ID or Touch ID through LocalAuthentication and Keychain." + }, + { + "ports": [ + "mac-native", + "javascript", + "linux-x64", + "linux-arm64", + "windows-x64", + "windows-arm64", + "watchos", + "tvos" + ], + "state": "unavailable", + "label": "NOT_AVAILABLE", + "detail": "The portable API returns its documented non-supporting fallback." + } ] }, { @@ -136,9 +435,38 @@ "testing": "Present known physical tags and cards, verify payloads and errors, and use a certified reader for host-card-emulation exchanges.", "why_not_automated": "NFC requires short-range hardware, physical tags or readers, user presentation, and platform entitlements that hosted runners lack.", "coverage": [ - {"ports": ["android"], "state": "supported", "label": "Android NFC", "detail": "NDEF, tag technologies, and HCE on compatible hardware."}, - {"ports": ["ios-gl", "ios-metal"], "state": "conditional", "label": "Core NFC", "detail": "NDEF and selected tag technologies; HCE is restricted by iOS version and region."}, - {"ports": ["mac-native", "javascript", "linux-x64", "linux-arm64", "windows-x64", "windows-arm64", "watchos", "tvos"], "state": "unavailable", "label": "NOT_AVAILABLE", "detail": "The API returns its documented non-supporting fallback."} + { + "ports": [ + "android" + ], + "state": "supported", + "label": "Android NFC", + "detail": "NDEF, tag technologies, and HCE on compatible hardware." + }, + { + "ports": [ + "ios-gl", + "ios-metal" + ], + "state": "conditional", + "label": "Core NFC", + "detail": "NDEF and selected tag technologies; HCE is restricted by iOS version and region." + }, + { + "ports": [ + "mac-native", + "javascript", + "linux-x64", + "linux-arm64", + "windows-x64", + "windows-arm64", + "watchos", + "tvos" + ], + "state": "unavailable", + "label": "NOT_AVAILABLE", + "detail": "The API returns its documented non-supporting fallback." + } ] }, { @@ -149,9 +477,38 @@ "testing": "Use a known peripheral or protocol simulator, scan and connect, then verify characteristic reads, writes, notifications, reconnects, and permission denial.", "why_not_automated": "Radio state, nearby peripherals, pairing, permissions, and timing are external to the deterministic screenshot runner.", "coverage": [ - {"ports": ["android", "ios-gl", "ios-metal"], "state": "conditional", "label": "cn1-bluetooth", "detail": "Maintained native library backend on supported phone and tablet hardware."}, - {"ports": ["mac-native", "linux-x64", "linux-arm64", "windows-x64", "windows-arm64"], "state": "conditional", "label": "Host-dependent", "detail": "Desktop hardware support depends on the selected maintained library backend and host adapter."}, - {"ports": ["javascript", "watchos", "tvos"], "state": "unavailable", "label": "No declared backend", "detail": "No maintained general-purpose Bluetooth backend is declared for this target."} + { + "ports": [ + "android", + "ios-gl", + "ios-metal" + ], + "state": "conditional", + "label": "cn1-bluetooth", + "detail": "Maintained native library backend on supported phone and tablet hardware." + }, + { + "ports": [ + "mac-native", + "linux-x64", + "linux-arm64", + "windows-x64", + "windows-arm64" + ], + "state": "conditional", + "label": "Host-dependent", + "detail": "Desktop hardware support depends on the selected maintained library backend and host adapter." + }, + { + "ports": [ + "javascript", + "watchos", + "tvos" + ], + "state": "unavailable", + "label": "No declared backend", + "detail": "No maintained general-purpose Bluetooth backend is declared for this target." + } ] }, { @@ -162,9 +519,38 @@ "testing": "Install a signed build, configure the required modes, background or terminate it, and observe callbacks across OS throttling and restart scenarios.", "why_not_automated": "Scheduling is deliberately nondeterministic, power-policy controlled, entitlement dependent, and often takes longer than a CI job window.", "coverage": [ - {"ports": ["android", "ios-gl", "ios-metal"], "state": "supported", "label": "Native scheduler", "detail": "Platform background mechanisms subject to OS quotas, permissions, and lifecycle policy."}, - {"ports": ["mac-native"], "state": "conditional", "label": "Target-dependent", "detail": "Availability depends on the macOS application mode and signed capabilities."}, - {"ports": ["javascript", "linux-x64", "linux-arm64", "windows-x64", "windows-arm64", "watchos", "tvos"], "state": "unavailable", "label": "No equivalent contract", "detail": "No equivalent Codename One background-fetch contract is declared for this target."} + { + "ports": [ + "android", + "ios-gl", + "ios-metal" + ], + "state": "supported", + "label": "Native scheduler", + "detail": "Platform background mechanisms subject to OS quotas, permissions, and lifecycle policy." + }, + { + "ports": [ + "mac-native" + ], + "state": "conditional", + "label": "Target-dependent", + "detail": "Availability depends on the macOS application mode and signed capabilities." + }, + { + "ports": [ + "javascript", + "linux-x64", + "linux-arm64", + "windows-x64", + "windows-arm64", + "watchos", + "tvos" + ], + "state": "unavailable", + "label": "No equivalent contract", + "detail": "No equivalent Codename One background-fetch contract is declared for this target." + } ] }, { @@ -175,9 +561,38 @@ "testing": "Use release-like signed builds, compromised and clean devices, fresh server nonces, and backend verification of vendor-signed verdicts.", "why_not_automated": "Trust verdicts depend on hardware-backed keys, store-distributed builds, vendor services, device state, and an application backend.", "coverage": [ - {"ports": ["android"], "state": "conditional", "label": "Play Integrity", "detail": "Root signals and optional Google Play Integrity when enabled and verified by a backend."}, - {"ports": ["ios-gl", "ios-metal"], "state": "conditional", "label": "App Attest", "detail": "Jailbreak signals and optional Apple App Attest when enabled and verified by a backend."}, - {"ports": ["mac-native", "javascript", "linux-x64", "linux-arm64", "windows-x64", "windows-arm64", "watchos", "tvos"], "state": "unavailable", "label": "Unsupported fallback", "detail": "No hardware-backed Codename One attestation backend is declared for this target."} + { + "ports": [ + "android" + ], + "state": "conditional", + "label": "Play Integrity", + "detail": "Root signals and optional Google Play Integrity when enabled and verified by a backend." + }, + { + "ports": [ + "ios-gl", + "ios-metal" + ], + "state": "conditional", + "label": "App Attest", + "detail": "Jailbreak signals and optional Apple App Attest when enabled and verified by a backend." + }, + { + "ports": [ + "mac-native", + "javascript", + "linux-x64", + "linux-arm64", + "windows-x64", + "windows-arm64", + "watchos", + "tvos" + ], + "state": "unavailable", + "label": "Unsupported fallback", + "detail": "No hardware-backed Codename One attestation backend is declared for this target." + } ] }, { @@ -188,8 +603,31 @@ "testing": "Seed platform storage, launch the native picker, select and cancel items, and verify temporary URI or security-scoped access after returning to the app.", "why_not_automated": "The decisive UI and permissions belong to another process, and hosted runners do not share an identical populated media or document library.", "coverage": [ - {"ports": ["android", "ios-gl", "ios-metal", "mac-native", "javascript", "linux-x64", "linux-arm64", "windows-x64", "windows-arm64"], "state": "conditional", "label": "Native picker", "detail": "Uses the target's file, document, or browser picker; available types and access rules vary by OS."}, - {"ports": ["watchos", "tvos"], "state": "unavailable", "label": "No general picker", "detail": "The form factor has no general-purpose Codename One document/gallery picker."} + { + "ports": [ + "android", + "ios-gl", + "ios-metal", + "mac-native", + "javascript", + "linux-x64", + "linux-arm64", + "windows-x64", + "windows-arm64" + ], + "state": "conditional", + "label": "Native picker", + "detail": "Uses the target's file, document, or browser picker; available types and access rules vary by OS." + }, + { + "ports": [ + "watchos", + "tvos" + ], + "state": "unavailable", + "label": "No general picker", + "detail": "The form factor has no general-purpose Codename One document/gallery picker." + } ] }, { @@ -200,9 +638,38 @@ "testing": "Open each native chooser or handler on a configured device, complete and cancel actions, and verify returned result metadata where the OS supplies it.", "why_not_automated": "Installed applications, accounts, SIM capability, chooser UI, and user selection are outside the app process and differ per runner.", "coverage": [ - {"ports": ["android", "ios-gl", "ios-metal"], "state": "supported", "label": "Native intents", "detail": "Native share and communication handlers when the device has a matching service."}, - {"ports": ["mac-native", "javascript", "linux-x64", "linux-arm64", "windows-x64", "windows-arm64"], "state": "conditional", "label": "OS/browser handler", "detail": "Uses an installed desktop handler, browser capability, or portable fallback where available."}, - {"ports": ["watchos", "tvos"], "state": "unavailable", "label": "Form-factor limited", "detail": "No general-purpose Codename One communication chooser is declared for this target."} + { + "ports": [ + "android", + "ios-gl", + "ios-metal" + ], + "state": "supported", + "label": "Native intents", + "detail": "Native share and communication handlers when the device has a matching service." + }, + { + "ports": [ + "mac-native", + "javascript", + "linux-x64", + "linux-arm64", + "windows-x64", + "windows-arm64" + ], + "state": "conditional", + "label": "OS/browser handler", + "detail": "Uses an installed desktop handler, browser capability, or portable fallback where available." + }, + { + "ports": [ + "watchos", + "tvos" + ], + "state": "unavailable", + "label": "Form-factor limited", + "detail": "No general-purpose Codename One communication chooser is declared for this target." + } ] }, { @@ -213,9 +680,38 @@ "testing": "Use a store-eligible build and account, request the prompt under vendor quota rules, and verify fallback behavior separately.", "why_not_automated": "Apple and Google intentionally decide whether the native prompt appears, so a request cannot deterministically assert visible store UI.", "coverage": [ - {"ports": ["android"], "state": "supported", "label": "Play Review", "detail": "Google Play In-App Review when the build includes the service."}, - {"ports": ["ios-gl", "ios-metal"], "state": "supported", "label": "StoreKit", "detail": "StoreKit review request subject to Apple's display quota."}, - {"ports": ["mac-native", "javascript", "linux-x64", "linux-arm64", "windows-x64", "windows-arm64", "watchos", "tvos"], "state": "fallback", "label": "Portable fallback", "detail": "Codename One shows its built-in rating sheet when no native prompt is available."} + { + "ports": [ + "android" + ], + "state": "supported", + "label": "Play Review", + "detail": "Google Play In-App Review when the build includes the service." + }, + { + "ports": [ + "ios-gl", + "ios-metal" + ], + "state": "supported", + "label": "StoreKit", + "detail": "StoreKit review request subject to Apple's display quota." + }, + { + "ports": [ + "mac-native", + "javascript", + "linux-x64", + "linux-arm64", + "windows-x64", + "windows-arm64", + "watchos", + "tvos" + ], + "state": "fallback", + "label": "Portable fallback", + "detail": "Codename One shows its built-in rating sheet when no native prompt is available." + } ] }, { @@ -226,9 +722,38 @@ "testing": "Use valid provider keys on a networked device, load known coordinates, exercise gestures and markers, and confirm provider attribution and fallback behavior.", "why_not_automated": "Provider keys are secrets, map imagery changes independently, usage may be billed, and native peers or network tiles are not deterministic pixels.", "coverage": [ - {"ports": ["android", "ios-gl", "ios-metal"], "state": "conditional", "label": "Native provider", "detail": "Native provider when configured, with the portable map renderer as fallback."}, - {"ports": ["javascript"], "state": "conditional", "label": "Web provider", "detail": "Browser map provider with API key and network access, plus portable fallback."}, - {"ports": ["mac-native", "linux-x64", "linux-arm64", "windows-x64", "windows-arm64", "watchos", "tvos"], "state": "fallback", "label": "Portable map", "detail": "Portable vector or tile rendering is used when no native provider is active."} + { + "ports": [ + "android", + "ios-gl", + "ios-metal" + ], + "state": "conditional", + "label": "Native provider", + "detail": "Native provider when configured, with the portable map renderer as fallback." + }, + { + "ports": [ + "javascript" + ], + "state": "conditional", + "label": "Web provider", + "detail": "Browser map provider with API key and network access, plus portable fallback." + }, + { + "ports": [ + "mac-native", + "linux-x64", + "linux-arm64", + "windows-x64", + "windows-arm64", + "watchos", + "tvos" + ], + "state": "fallback", + "label": "Portable map", + "detail": "Portable vector or tile rendering is used when no native provider is active." + } ] }, { @@ -239,9 +764,38 @@ "testing": "Use provider test-unit identifiers in a signed build, complete consent flows, and verify fill, impression, click, and lifecycle callbacks.", "why_not_automated": "Ad inventory, consent state, provider accounts, network policy, and SDK UI are external and nondeterministic; the first table uses deterministic mock ads.", "coverage": [ - {"ports": ["android", "ios-gl", "ios-metal"], "state": "conditional", "label": "Provider SDK", "detail": "Supported through configured native ad libraries and provider test units."}, - {"ports": ["javascript"], "state": "conditional", "label": "Web integration", "detail": "Depends on the selected web advertising integration and hosting policy."}, - {"ports": ["mac-native", "linux-x64", "linux-arm64", "windows-x64", "windows-arm64", "watchos", "tvos"], "state": "fallback", "label": "Mock/custom", "detail": "The portable ad component can be tested with mock or application-provided content; no bundled live network is claimed."} + { + "ports": [ + "android", + "ios-gl", + "ios-metal" + ], + "state": "conditional", + "label": "Provider SDK", + "detail": "Supported through configured native ad libraries and provider test units." + }, + { + "ports": [ + "javascript" + ], + "state": "conditional", + "label": "Web integration", + "detail": "Depends on the selected web advertising integration and hosting policy." + }, + { + "ports": [ + "mac-native", + "linux-x64", + "linux-arm64", + "windows-x64", + "windows-arm64", + "watchos", + "tvos" + ], + "state": "fallback", + "label": "Mock/custom", + "detail": "The portable ad component can be tested with mock or application-provided content; no bundled live network is claimed." + } ] }, { @@ -252,12 +806,59 @@ "testing": "Install the correctly signed app or extension, publish timelines and actions, then inspect the launcher, lock screen, Dynamic Island, notification area, or desktop host.", "why_not_automated": "The first table checks serialization, rasterization, timelines, and dispatch, but the final surface is rendered by a separate OS process with signing and entitlement requirements.", "coverage": [ - {"ports": ["android"], "state": "supported", "label": "Widgets/notifications", "detail": "Lowers supported surfaces to Android widgets and ongoing notifications."}, - {"ports": ["ios-gl", "ios-metal"], "state": "conditional", "label": "WidgetKit/ActivityKit", "detail": "Requires supported OS versions, extension packaging, and entitlements."}, - {"ports": ["mac-native", "linux-x64", "linux-arm64"], "state": "conditional", "label": "Desktop surface", "detail": "Uses the desktop floating-window or preview presentation available to the port."}, - {"ports": ["windows-x64", "windows-arm64"], "state": "conditional", "label": "Windows surfaces", "detail": "Floating widgets are supported; Widgets Board integration requires MSIX and Windows App SDK packaging."}, - {"ports": ["javascript"], "state": "fallback", "label": "In-app preview", "detail": "Portable document and rasterizer behavior is available without an OS widget host."}, - {"ports": ["watchos", "tvos"], "state": "unavailable", "label": "No declared lowering", "detail": "No standalone Codename One external-surface lowering is declared for this target."} + { + "ports": [ + "android" + ], + "state": "supported", + "label": "Widgets/notifications", + "detail": "Lowers supported surfaces to Android widgets and ongoing notifications." + }, + { + "ports": [ + "ios-gl", + "ios-metal" + ], + "state": "conditional", + "label": "WidgetKit/ActivityKit", + "detail": "Requires supported OS versions, extension packaging, and entitlements." + }, + { + "ports": [ + "mac-native", + "linux-x64", + "linux-arm64" + ], + "state": "conditional", + "label": "Desktop surface", + "detail": "Uses the desktop floating-window or preview presentation available to the port." + }, + { + "ports": [ + "windows-x64", + "windows-arm64" + ], + "state": "conditional", + "label": "Windows surfaces", + "detail": "Floating widgets are supported; Widgets Board integration requires MSIX and Windows App SDK packaging." + }, + { + "ports": [ + "javascript" + ], + "state": "fallback", + "label": "In-app preview", + "detail": "Portable document and rasterizer behavior is available without an OS widget host." + }, + { + "ports": [ + "watchos", + "tvos" + ], + "state": "unavailable", + "label": "No declared lowering", + "detail": "No standalone Codename One external-surface lowering is declared for this target." + } ] }, { @@ -268,8 +869,31 @@ "testing": "Enable the platform screen reader, traverse representative forms, verify announcements and actions, and repeat with dynamic content and input devices.", "why_not_automated": "The first table validates semantics and API state, while the final speech, focus order, gestures, and switch-control behavior belong to external OS services.", "coverage": [ - {"ports": ["android", "ios-gl", "ios-metal", "mac-native", "javascript", "linux-x64", "linux-arm64", "windows-x64", "windows-arm64"], "state": "conditional", "label": "OS assistive tech", "detail": "Semantic output is consumed by the screen reader or accessibility stack available on that OS."}, - {"ports": ["watchos", "tvos"], "state": "conditional", "label": "Form-factor service", "detail": "Behavior depends on the target's accessibility service and navigation model."} + { + "ports": [ + "android", + "ios-gl", + "ios-metal", + "mac-native", + "javascript", + "linux-x64", + "linux-arm64", + "windows-x64", + "windows-arm64" + ], + "state": "conditional", + "label": "OS assistive tech", + "detail": "Semantic output is consumed by the screen reader or accessibility stack available on that OS." + }, + { + "ports": [ + "watchos", + "tvos" + ], + "state": "conditional", + "label": "Form-factor service", + "detail": "Behavior depends on the target's accessibility service and navigation model." + } ] }, { @@ -280,7 +904,24 @@ "testing": "Compile each architecture, inspect the generated package, sign with platform credentials, install it, and exercise the integrated native entry point or SDK in its target environment.", "why_not_automated": "These checks happen before app startup or require private signing identities, store portals, proprietary SDK credentials, and platform-specific source rather than one shared runtime assertion.", "coverage": [ - {"ports": ["android", "ios-gl", "ios-metal", "mac-native", "javascript", "linux-x64", "linux-arm64", "windows-x64", "windows-arm64", "watchos", "tvos"], "state": "supported", "label": "Target build", "detail": "The port has its own build, packaging, and native-integration path; exact signing and extension capabilities are target specific."} + { + "ports": [ + "android", + "ios-gl", + "ios-metal", + "mac-native", + "javascript", + "linux-x64", + "linux-arm64", + "windows-x64", + "windows-arm64", + "watchos", + "tvos" + ], + "state": "supported", + "label": "Target build", + "detail": "The port has its own build, packaging, and native-integration path; exact signing and extension capabilities are target specific." + } ] } ] diff --git a/docs/website/layouts/partials/port-status-feature-status.html b/docs/website/layouts/partials/port-status-feature-status.html index 32153e94ed1..33d06810701 100644 --- a/docs/website/layouts/partials/port-status-feature-status.html +++ b/docs/website/layouts/partials/port-status-feature-status.html @@ -31,14 +31,38 @@ {{- $notRun = add $notRun 1 -}} {{- end -}} {{- end -}} - {{- /* A skip only reads as green when the errata below account for it by - name. An undocumented skip stays a partial result. */ -}} + {{- /* A skip only reads as green when the errata account for that test AND + for the reason the run actually gave. Matching on the test name alone + let any future skip of a named test render as documented: the video + round trip reports encoder trouble as a skip on that test, so an + encoder that regressed would have shown a green, explained cell that + said nothing about what went wrong. An unrecognized reason -- or a + skip carrying none -- stays a partial result. */ -}} {{- $documented := gt (len $skippedTests) 0 -}} {{- range $skippedTests -}} {{- $test := . -}} + {{- $result := index $report.tests $test -}} + {{- $reasons := slice -}} + {{- with $result -}}{{- with .reasons -}}{{- $reasons = . -}}{{- end -}}{{- end -}} {{- $found := false -}} {{- range $supplement.skip_reasons -}} - {{- if eq .test $test -}}{{- $found = true -}}{{- end -}} + {{- if eq .test $test -}} + {{- $codes := .reason_codes -}} + {{- if $codes -}} + {{- $allMatched := gt (len $reasons) 0 -}} + {{- range $reasons -}} + {{- $reason := . -}} + {{- $ok := false -}} + {{- range $codes -}} + {{- if hasPrefix $reason . -}}{{- $ok = true -}}{{- end -}} + {{- end -}} + {{- if not $ok -}}{{- $allMatched = false -}}{{- end -}} + {{- end -}} + {{- if $allMatched -}}{{- $found = true -}}{{- end -}} + {{- else -}} + {{- $found = true -}} + {{- end -}} + {{- end -}} {{- end -}} {{- if not $found -}}{{- $documented = false -}}{{- end -}} {{- end -}} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/VideoIORoundTripTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/VideoIORoundTripTest.java index f4bede2c9d9..a3b50f4b44f 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/VideoIORoundTripTest.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/VideoIORoundTripTest.java @@ -157,6 +157,13 @@ private void runRoundTrip() { + "/cn1-videoio-roundtrip-" + System.currentTimeMillis() + (webm ? ".webm" : ".mp4"); // ---- ENCODE (encode-side unavailability is a SKIP, not a failure) ---- + // Two phases, reported differently on purpose. Failing to obtain a + // writer is the documented "this runner exposes no encoder" case and + // is skipped. Failing after one was obtained means the encoder is + // there and broke, which is a regression -- reporting that as the same + // skip made an encoder failure render as a documented green cell, + // indistinguishable from a target that never had an encoder. + VideoWriter writer; try { VideoWriterBuilder builder = new VideoWriterBuilder() .path(path).container(container) @@ -165,7 +172,13 @@ private void runRoundTrip() { if (withAudio) { builder.hasAudio(true).audioCodec(audioCodec).sampleRate(SAMPLE_RATE).audioChannels(1); } - VideoWriter writer = io.createWriter(builder); + writer = io.createWriter(builder); + } catch (Throwable t) { + cleanup(path); + skip("encode-unavailable-on-" + Display.getInstance().getPlatformName()); + return; + } + try { int samplesPerFrame = SAMPLE_RATE / FRAMES; for (int i = 0; i < FRAMES; i++) { writer.writeFrame(makeCountingFrame(i), Math.round(i * 1000f / FPS)); @@ -176,7 +189,7 @@ private void runRoundTrip() { writer.close(); } catch (Throwable t) { cleanup(path); - skip("encode-unavailable-on-" + Display.getInstance().getPlatformName() + ":" + t.getMessage()); + fail("the encoder was available but failed while writing: " + t); return; } diff --git a/scripts/hellocodenameone/conformance/backfill_port_status.sh b/scripts/hellocodenameone/conformance/backfill_port_status.sh index 374cb72a3b0..1e5404c32e8 100755 --- a/scripts/hellocodenameone/conformance/backfill_port_status.sh +++ b/scripts/hellocodenameone/conformance/backfill_port_status.sh @@ -61,6 +61,35 @@ trap cleanup EXIT published=0 skipped=0 +# Mirrors port_status.py's FUTURE_STAMP_TOLERANCE. The gate publishes a report +# stamped slightly ahead of now, so the closing assertion has to accept the same +# margin or it fails over reports this very run published. +future_skew_seconds=3600 + +# True when $1 is a strictly later instant than $2. Both are timezone-aware +# ISO-8601, but not necessarily normalized to Z, so they are compared as +# instants rather than as text. +newer_instant() { + python3 - "$1" "$2" <<'INSTANT' +import sys +from datetime import datetime + +def parse(value): + try: + stamp = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + return stamp if stamp.tzinfo is not None else None + +candidate = parse(sys.argv[1]) +current = parse(sys.argv[2]) +# An unreadable candidate is never "newer"; an unreadable stored value is +# replaced, since leaving it in place would strand the port on something the +# page cannot render either. +sys.exit(0 if candidate is not None and (current is None or candidate > current) else 1) +INSTANT +} + # The contract's own freshness window bounds how far back a candidate run is # worth considering: a report older than this is stale by definition, so there # is nothing to be gained by looking past it. @@ -116,6 +145,18 @@ while IFS= read -r workflow; do if [ -z "${found}" ] || [ -f "${download_dir}/covered-${found}" ]; then continue fi + # Only ports this workflow is declared to produce. The port is read from + # the artifact, so a misconfigured matrix that stamped someone else's id + # on its report would otherwise be published straight over that port's + # entry -- Linux evidence replacing Android's genuine result, with both + # the gate and the freshness check satisfied. + case " ${owned} " in + *" ${found} "*) ;; + *) + echo "Ignoring a report naming ${found}: ${workflow} does not produce that port." >&2 + continue + ;; + esac # Gate before marking the port covered, not after. A newest run that # uploaded an unusable report would otherwise claim the port and stop # the older candidates from being consulted, so the sweep would keep @@ -156,7 +197,11 @@ while IFS= read -r workflow; do --jq '.content' 2>/dev/null | base64 --decode > "${tmp_dir}/current.json" 2>/dev/null; then current="$(jq -r '.generated_at // empty' "${tmp_dir}/current.json" 2>/dev/null || true)" fi - if [ -n "${current}" ] && [[ ! "${generated}" > "${current}" ]]; then + # Compare instants rather than strings. The gate accepts any timezone-aware + # timestamp, and "2026-08-01T01:00:00+02:00" sorts after + # "2026-08-01T00:00:00Z" while being an hour older, so a lexical test can + # overwrite a newer report or refuse a genuinely newer one. + if [ -n "${current}" ] && ! newer_instant "${generated}" "${current}"; then skipped=$((skipped + 1)) continue fi @@ -198,15 +243,22 @@ raw = sys.argv[1] try: stamp = datetime.fromisoformat(raw.replace("Z", "+00:00")) except ValueError: - print(-1) + print("unreadable") else: - print(-1 if stamp.tzinfo is None + print("unreadable" if stamp.tzinfo is None else int((datetime.now(timezone.utc) - stamp).total_seconds())) AGE )" stale_seconds=$((stale_days * 86400)) - if [ "${age_seconds}" -lt 0 ]; then + # The publication gate deliberately tolerates an hour of clock skew, so a + # report it accepted can legitimately carry a timestamp a little ahead of + # now. Calling that unreadable here would fail the nightly job over a report + # the same run just published, until wall time caught up. Unparseable stays + # -1 from the helper above and is still a problem. + if [ "${age_seconds}" = "unreadable" ]; then problems+=("${port}: unreadable generated_at ${generated:-}") + elif [ "${age_seconds}" -lt "-${future_skew_seconds}" ]; then + problems+=("${port}: generated_at ${generated} is $(( -age_seconds / 60 )) minutes in the future") elif [ "${age_seconds}" -gt "${stale_seconds}" ]; then problems+=("${port}: last report is $((age_seconds / 3600)) hours old (limit ${stale_days} days)") fi From 2ff146cad97efc81508d1265d49dc954e6a3ad3b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:20:15 +0700 Subject: [PATCH 24/91] Document encoder skips per port instead of failing the Apple runs My previous commit turned a write-phase encoder failure into a hard failure, which took ios-gl, ios-metal and mac-native red. The evidence says the split was in the wrong place: on the Apple simulators the writer IS obtained and close() then fails with "Failed to finalize video file". That is the same "no usable encoder in the headless job" condition the erratum already describes, surfacing at finalize rather than at creation -- not a regression. So the write phase is a skip again, under its own reason code (encode-write-failed-on-) rather than sharing the creation one. What keeps that honest is the port scoping, which is what the review actually asked for and what I had left out: an erratum's reason codes now name the ports they cover, and a skip reads as documented only when the reason matches AND the report comes from one of those ports. Encoder trouble is documented on the five Apple targets and nowhere else, so the identical code arriving from Linux or Windows -- where the encoder is meant to work -- stays undocumented and renders partial rather than green. Verified by injecting the same reason code into two reports: tvos renders is-pass with the documented-skip note, linux-x64 renders is-partial. Also added the camera erratum's newer reason codes -- no-host-webcam-capture-on-win and no-camera-device-on-headless-runner -- scoped to Windows and Linux, so those intentional headless skips get the documented pass instead of sitting partial. The permission-prompt code stays unscoped on purpose: an unattended runner cannot answer a prompt on any target, so there is no port where it would mean a regression. Scoping it cost a currently-documented Windows cell in a first attempt, which is the kind of transient this distinction avoids. Co-Authored-By: Claude Opus 5 (1M context) --- docs/website/data/port_status_supplement.json | 50 +++++++++++++++++-- .../partials/port-status-feature-status.html | 11 +++- .../tests/VideoIORoundTripTest.java | 9 +++- 3 files changed, 65 insertions(+), 5 deletions(-) diff --git a/docs/website/data/port_status_supplement.json b/docs/website/data/port_status_supplement.json index bc4b6f719aa..ef0bc5bfe35 100644 --- a/docs/website/data/port_status_supplement.json +++ b/docs/website/data/port_status_supplement.json @@ -6,7 +6,23 @@ "platform_support": "A skipped camera test does not mean the Codename One port is unsupported. Every listed target remains a supported port; camera availability is a separate runtime capability and depends on the device, permissions, and camera backend.", "verification": "The native camera implementations are compiled in their port builds and exercised with granted permissions on real hardware or an interactive browser. Deterministic API assertions use the synthetic camera backend outside this portability table.", "reason_codes": [ - "needs-runtime-permission-on-" + { + "prefix": "needs-runtime-permission-on-" + }, + { + "prefix": "no-host-webcam-capture-on-win", + "ports": [ + "windows-x64", + "windows-arm64" + ] + }, + { + "prefix": "no-camera-device-on-headless-runner", + "ports": [ + "linux-x64", + "linux-arm64" + ] + } ] }, { @@ -15,8 +31,36 @@ "platform_support": "The port remains supported. Video playback and frame decoding are measured separately by VideoIODecodedFramesScreenshotTest; this skip is limited to creating a new encoded video in that CI environment.", "verification": "Encoder-backed targets run the full counting-frame and audio round trip. Apple media playback and decoding are covered separately, while device-only recording is verified in signed hardware builds.", "reason_codes": [ - "encode-unavailable-on-", - "VideoIO-unsupported-on-" + { + "prefix": "encode-unavailable-on-", + "ports": [ + "ios-gl", + "ios-metal", + "mac-native", + "tvos", + "watchos" + ] + }, + { + "prefix": "encode-write-failed-on-", + "ports": [ + "ios-gl", + "ios-metal", + "mac-native", + "tvos", + "watchos" + ] + }, + { + "prefix": "VideoIO-unsupported-on-", + "ports": [ + "ios-gl", + "ios-metal", + "mac-native", + "tvos", + "watchos" + ] + } ] } ], diff --git a/docs/website/layouts/partials/port-status-feature-status.html b/docs/website/layouts/partials/port-status-feature-status.html index 33d06810701..251843fe25b 100644 --- a/docs/website/layouts/partials/port-status-feature-status.html +++ b/docs/website/layouts/partials/port-status-feature-status.html @@ -54,7 +54,16 @@ {{- $reason := . -}} {{- $ok := false -}} {{- range $codes -}} - {{- if hasPrefix $reason . -}}{{- $ok = true -}}{{- end -}} + {{- /* A code documents this skip only when the reason matches + AND, where the erratum names ports, this is one of them. + Encoder trouble is expected on the Apple simulators and + nowhere else, so the same code from a port whose encoder is + meant to work stays undocumented and cannot render green. */ -}} + {{- $portAllowed := true -}} + {{- with .ports -}} + {{- $portAllowed = in . $port.id -}} + {{- end -}} + {{- if and $portAllowed (hasPrefix $reason .prefix) -}}{{- $ok = true -}}{{- end -}} {{- end -}} {{- if not $ok -}}{{- $allMatched = false -}}{{- end -}} {{- end -}} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/VideoIORoundTripTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/VideoIORoundTripTest.java index a3b50f4b44f..3eca87011c2 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/VideoIORoundTripTest.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/VideoIORoundTripTest.java @@ -189,7 +189,14 @@ private void runRoundTrip() { writer.close(); } catch (Throwable t) { cleanup(path); - fail("the encoder was available but failed while writing: " + t); + // Still a skip, with its own reason code. The Apple simulators + // obtain a writer and then fail to finalize the file, which is the + // same "no usable encoder in the headless job" condition as failing + // to obtain one -- it just surfaces later. Reporting it under a + // distinct code lets the errata document it for the targets where + // it is expected, while the same code from a port whose encoder is + // supposed to work stays undocumented and therefore not green. + skip("encode-write-failed-on-" + Display.getInstance().getPlatformName()); return; } From 1b30b0699d54f4aafbf649b094e89df5f57db286 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:26:08 +0700 Subject: [PATCH 25/91] Honour the requested UI-settle budget, and say when it runs out DrawImage fails intermittently on the JavaScript port with its two offscreen-image cells half-painted: those two variants render into a fresh Image and composite it, so they are the slowest to settle, and the capture goes out mid-paint. Someone had already found that and asked for a longer settle -- port.js requests maxFrames 120 for exactly this test. The host then clamped the value to 96, so the request was quietly cut by a fifth and the settle returned whatever had been drawn so far. The ceiling now allows the budget that is actually asked for, with a bound well above any current request so a bad value still cannot spin forever. Exhausting the budget is also no longer silent. Running out of frames without ever meeting the quiet-and-stable condition is the difference between "the UI was ready" and "we stopped waiting", and only one of those explains a half-drawn screenshot afterwards; it is now reported in the settle diagnostics and returned to the caller. This is a budget fix, not a cure: if the composite of an offscreen image can still outrun its own queued ops the underlying ordering needs work, and the new settleExhausted flag is what will say so next time rather than leaving it to be guessed from the pixels. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/javascript/browser_bridge.js | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/vm/ByteCodeTranslator/src/javascript/browser_bridge.js b/vm/ByteCodeTranslator/src/javascript/browser_bridge.js index 8ab3d4e313e..002e2063454 100644 --- a/vm/ByteCodeTranslator/src/javascript/browser_bridge.js +++ b/vm/ByteCodeTranslator/src/javascript/browser_bridge.js @@ -4622,7 +4622,14 @@ hostBridge.register('__cn1_wait_for_ui_settle__', function(request) { var payload = request || {}; var reason = payload.reason == null ? 'unknown' : String(payload.reason); - var maxFrames = Math.max(1, Math.min(96, (payload.maxFrames | 0) || 14)); + // Honour the caller's budget rather than quietly halving it. The + // graphics tests that render into an offscreen image and composite it + // ask for 120 frames precisely because they are the slowest to settle, + // and the old ceiling of 96 silently returned whatever had been drawn + // so far -- which is how DrawImage shipped a capture with its two + // offscreen-image cells still half-painted. The bound stays, well above + // any current request, so a bad value cannot spin forever. + var maxFrames = Math.max(1, Math.min(240, (payload.maxFrames | 0) || 14)); var stableFrames = Math.max(1, Math.min(6, (payload.stableFrames | 0) || 2)); var quietFramesRequired = Math.max(1, Math.min(12, (payload.quietFrames | 0) || stableFrames)); var previousSignature = String(global.__cn1LastScreenshotSignature || ''); @@ -4634,6 +4641,7 @@ var seenRenderSeq = startRenderSeq; var renderAdvanced = false; var quietFrames = 0; + var settleExhausted = false; function chooseBetter(a, b) { if (!a) { return b; @@ -4687,6 +4695,12 @@ } } if (index + 1 >= maxFrames) { + // Out of budget without ever meeting the quiet + stable condition. + // The capture still proceeds with the best frame seen, but say so: + // an exhausted settle is the difference between "the UI was ready" + // and "we stopped waiting", and only one of those explains a + // half-drawn screenshot afterwards. + settleExhausted = true; return best; } return runFrame(index + 1); @@ -4712,6 +4726,7 @@ diag('SCREENSHOT_START', 'settleRenderEndSeq', seenRenderSeq | 0); diag('SCREENSHOT_START', 'settleRenderAdvanced', renderAdvanced ? 1 : 0); diag('SCREENSHOT_START', 'settleQuietObserved', quietFrames | 0); + diag('SCREENSHOT_START', 'settleExhausted', settleExhausted ? 1 : 0); return { changedFromPrevious: changed ? 1 : 0, canvasSignature: meta.canvasSignature || 'none', @@ -4721,7 +4736,8 @@ canvasPick: meta.canvasPick | 0, renderStartSeq: startRenderSeq | 0, renderEndSeq: seenRenderSeq | 0, - renderAdvanced: renderAdvanced ? 1 : 0 + renderAdvanced: renderAdvanced ? 1 : 0, + settleExhausted: settleExhausted ? 1 : 0 }; }); }); From a3bbdd0546b9742a281cc5fe69e656e04a515607 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:53:13 +0700 Subject: [PATCH 26/91] Keep identifier-ignorable characters out of identifier starts isJavaIdentifierStart fell through to isIdentifierIgnorable, which is right for isJavaIdentifierPart and wrong here: an ignorable character may appear inside a name but never as its first character. Giving getType a real answer for the ASCII controls turned that latent wrong fallback into a visible one, since U+0000 through U+0008 are ignorable and now report CONTROL rather than UNASSIGNED, so the newly enabled identifier API began accepting a NUL as the start of a Java identifier. Verified against the reference JDK: fourteen code points -- the ASCII and C1 controls, a letter, a digit, underscore, dollar, tab and space -- checked for start, part and ignorable through the ParparVM clean target. All match. Against the previous code the same probe fails on all seven control characters. Co-Authored-By: Claude Opus 5 (1M context) --- vm/JavaAPI/src/java/lang/Character.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/vm/JavaAPI/src/java/lang/Character.java b/vm/JavaAPI/src/java/lang/Character.java index 7ffc234a140..21571ce31f5 100644 --- a/vm/JavaAPI/src/java/lang/Character.java +++ b/vm/JavaAPI/src/java/lang/Character.java @@ -363,10 +363,15 @@ public static boolean isJavaIdentifierStart(int codePoint) { case CURRENCY_SYMBOL: return true; default: - return isIdentifierIgnorable(codePoint); + // No ignorable fallback here, unlike isJavaIdentifierPart. An + // identifier-ignorable character is allowed inside a name but + // never as its first character, and with getType now answering + // CONTROL for the ASCII controls, the old fallback let + // U+0000 through U+0008 start an identifier. + return false; } } - + public static boolean isJavaIdentifierPart(char ch) { return isJavaIdentifierPart((int) ch); } From 6e05a5c7fb157660a6ddcea051d350af64e5d9c6 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 2 Aug 2026 02:30:38 +0700 Subject: [PATCH 27/91] Align the iOS OAEP mask with every other port, and check the key family from the DER OAEP interop RSA_OAEP_SHA256 ciphertext could not cross between iOS and anywhere else. The transformation this API advertises is the JCE name "RSA/ECB/OAEPWithSHA-256AndMGF1Padding", which in JCE means a SHA-256 label with a SHA-1 mask; JavaSE and Android both hand that string straight to Cipher.getInstance and get exactly that, and the Linux and Windows ports produce it. iOS selected kSecKeyAlgorithmRSAEncryptionOAEPSHA256, which uses SHA-256 for both halves. So the outlier is iOS, and it was already unable to exchange ciphertext with Android before this branch existed -- adding two more ports on the JCE side only made it four against one. iOS is the side that moved. SecKey cannot express the JCE pairing, so the padding is built in the port and the key operation runs raw, which is the same thing the Windows port does for the same reason. Verified against OpenSSL as an independent oracle: blocks the iOS code encodes unpad correctly with the JCE pairing and blocks that pairing produces decode correctly in the iOS code, at 2048, 3072 and 4096 bits, plus rejection of a corrupted leading byte, seed and DB. That cross-port direction is the one that was broken, so it is the one worth testing. A raw RSA result can come back with its leading zero bytes dropped, and an OAEP block is defined at exactly the modulus width, so the block is left-padded before unpadding. Key family The previous fix compared the caller's keyAlgorithm label, which PrivateKey.fromPkcs8 and PublicKey.fromX509 accept without ever checking it against the bytes. EC DER labelled "RSA" therefore satisfied the comparison while the native derived EC from the DER and answered a SHA256withRSA request with an ECDSA signature. Both natives now take the family from the encoded key -- EVP_PKEY_base_id on Linux, the isEc the Windows importer already reports -- and refuse a mismatch. The Java-side check stays as a cheaper early error but is no longer what is trusted. Verified: the Windows OAEP/ECDSA harness still passes, both port sources compile, and crossCompilesWindowsExeWithXwin still links. Co-Authored-By: Claude Opus 5 (1M context) --- .../nativeSources/cn1_linux_crypto.c | 23 +++ .../nativeSources/cn1_windows_crypto.c | 14 ++ Ports/iOSPort/nativeSources/CN1Crypto.m | 191 ++++++++++++++++++ 3 files changed, 228 insertions(+) diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c b/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c index 8287560eadf..20e1f5a60d6 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c @@ -372,6 +372,19 @@ JAVA_OBJECT com_codename1_impl_linux_LinuxNative_rsaCrypt___java_lang_String_boo /* ------------------------------------------------------------ signatures */ +/* The signature algorithm's family must match the key that was actually handed + * over, read from the DER rather than from the caller's label. PrivateKey. + * fromPkcs8 and PublicKey.fromX509 take an arbitrary algorithm string and never + * check it against the bytes, so a Java-side comparison of that label can be + * satisfied while the encoded key is a different family entirely -- and the + * primitive below would then sign an "RSA" request with ECDSA. */ +static int cn1KeyFamilyMatches(const char* algorithm, EVP_PKEY* key) { + int wantsEc = strstr(algorithm, "ECDSA") != 0; + int keyIsEc = EVP_PKEY_base_id(key) == EVP_PKEY_EC; + return wantsEc == keyIsEc; +} + + static const EVP_MD* cn1SignatureDigest(const char* algorithm) { return cn1SignatureDigestOrNull(algorithm); } @@ -399,6 +412,11 @@ JAVA_OBJECT com_codename1_impl_linux_LinuxNative_signData___java_lang_String_byt EVP_PKEY_free(key); return JAVA_NULL; } + if (!cn1KeyFamilyMatches(name, key)) { + cn1CryptoFail("the signature algorithm does not match the key"); + EVP_PKEY_free(key); + return JAVA_NULL; + } ctx = EVP_MD_CTX_new(); if (ctx == 0) { cn1CryptoFail("digest context"); @@ -448,6 +466,11 @@ JAVA_BOOLEAN com_codename1_impl_linux_LinuxNative_verifyData___java_lang_String_ EVP_PKEY_free(key); return JAVA_FALSE; } + if (!cn1KeyFamilyMatches(name, key)) { + cn1CryptoFail("the signature algorithm does not match the key"); + EVP_PKEY_free(key); + return JAVA_FALSE; + } ctx = EVP_MD_CTX_new(); if (ctx == 0) { cn1CryptoFail("digest context"); diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c index 5090e98c897..1cec98dbae6 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c +++ b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c @@ -925,6 +925,15 @@ JAVA_OBJECT com_codename1_impl_windows_WindowsNative_signData___java_lang_String cn1CryptoFail("unsupported signature algorithm", 0); goto done; } + /* The family has to come from the DER, not from the caller's label: + * PrivateKey.fromPkcs8 takes an arbitrary algorithm string and never checks + * it against the bytes, so the Java-side comparison can be satisfied while + * the encoded key is a different family -- and NCrypt would then answer an + * "RSA" request with an ECDSA signature. */ + if ((strstr(name, "ECDSA") != 0) != (isEc != 0)) { + cn1CryptoFail("the signature algorithm does not match the key", 0); + goto done; + } if (!cn1Digest(digestAlgorithm, data, dataLength, digest, digestLength)) { goto done; } @@ -994,6 +1003,11 @@ JAVA_BOOLEAN com_codename1_impl_windows_WindowsNative_verifyData___java_lang_Str BCryptDestroyKey(key); return JAVA_FALSE; } + if ((strstr(name, "ECDSA") != 0) != (isEc != 0)) { + cn1CryptoFail("the signature algorithm does not match the key", 0); + BCryptDestroyKey(key); + return JAVA_FALSE; + } if (cn1Digest(digestAlgorithm, data, dataLength, digest, digestLength)) { unsigned char raw[132]; const unsigned char* toVerify = signature; diff --git a/Ports/iOSPort/nativeSources/CN1Crypto.m b/Ports/iOSPort/nativeSources/CN1Crypto.m index 7de2179a7a6..ece67f44bf1 100644 --- a/Ports/iOSPort/nativeSources/CN1Crypto.m +++ b/Ports/iOSPort/nativeSources/CN1Crypto.m @@ -284,6 +284,141 @@ static int cn1_seckey_op(SecKeyRef key, SecKeyAlgorithm alg, int forEncrypt, return (int) len; } +/* + * OAEP, built here rather than taken from SecKey. + * + * kSecKeyAlgorithmRSAEncryptionOAEPSHA256 uses SHA-256 for the label hash AND + * for MGF1. The transformation this port advertises is the JCE name + * "RSA/ECB/OAEPWithSHA-256AndMGF1Padding", which in JCE means a SHA-256 label + * with a SHA-1 mask -- that is what java.crypto gives the JavaSE and Android + * ports for the same string, and what the Linux and Windows ports produce. iOS + * was the only port pairing SHA-256 with SHA-256, so ciphertext never crossed + * between it and any other port. SecKey cannot express the JCE pairing, so the + * padding is built here and the key operation runs raw. + */ + +static void cn1_mgf1_sha1(const uint8_t* seed, int seedLen, uint8_t* mask, int maskLen) { + uint8_t counter[4]; + uint8_t digest[CC_SHA1_DIGEST_LENGTH]; + int produced = 0; + uint32_t count = 0; + while (produced < maskLen) { + int chunk = maskLen - produced; + CC_SHA1_CTX ctx; + counter[0] = (uint8_t) ((count >> 24) & 0xff); + counter[1] = (uint8_t) ((count >> 16) & 0xff); + counter[2] = (uint8_t) ((count >> 8) & 0xff); + counter[3] = (uint8_t) (count & 0xff); + CC_SHA1_Init(&ctx); + CC_SHA1_Update(&ctx, seed, (CC_LONG) seedLen); + CC_SHA1_Update(&ctx, counter, 4); + CC_SHA1_Final(digest, &ctx); + if (chunk > CC_SHA1_DIGEST_LENGTH) { + chunk = CC_SHA1_DIGEST_LENGTH; + } + memcpy(mask + produced, digest, (size_t) chunk); + produced += chunk; + count++; + } +} + +/* All ones when a == b, zero otherwise, without branching on the values. */ +static uint32_t cn1_ct_eq_mask(uint32_t a, uint32_t b) { + uint32_t diff = a ^ b; + uint32_t nonZero = (diff | (0u - diff)) >> 31; + return nonZero - 1u; +} + +static int cn1_oaep_encode(const uint8_t* message, int messageLen, + uint8_t* block, int blockLen) { + const int hashLen = CC_SHA256_DIGEST_LENGTH; + int dbLen = blockLen - hashLen - 1; + uint8_t seed[CC_SHA256_DIGEST_LENGTH]; + uint8_t* mask; + int i; + if (dbLen <= 0 || messageLen > dbLen - hashLen - 1) { + return 0; + } + mask = (uint8_t*) malloc((size_t) dbLen); + if (!mask) { + return 0; + } + memset(block, 0, (size_t) blockLen); + CC_SHA256("", 0, block + 1 + hashLen); + block[blockLen - messageLen - 1] = 0x01; + if (messageLen > 0) { + memcpy(block + blockLen - messageLen, message, (size_t) messageLen); + } + if (CCRandomGenerateBytes(seed, (size_t) hashLen) != kCCSuccess) { + free(mask); + return 0; + } + cn1_mgf1_sha1(seed, hashLen, mask, dbLen); + for (i = 0; i < dbLen; i++) { + block[1 + hashLen + i] ^= mask[i]; + } + cn1_mgf1_sha1(block + 1 + hashLen, dbLen, mask, hashLen); + for (i = 0; i < hashLen; i++) { + block[1 + i] = (uint8_t) (seed[i] ^ mask[i]); + } + free(mask); + return 1; +} + +/* Every check folds into one accumulator and one generic failure is reported: + * telling a leading-byte error from a label-hash error is enough to mount the + * adaptive attacks OAEP exists to prevent. */ +static int cn1_oaep_decode(uint8_t* block, int blockLen, uint8_t* message, int* messageLen) { + const int hashLen = CC_SHA256_DIGEST_LENGTH; + int dbLen = blockLen - hashLen - 1; + uint8_t labelHash[CC_SHA256_DIGEST_LENGTH]; + uint8_t seed[CC_SHA256_DIGEST_LENGTH]; + uint8_t* mask; + int i; + uint32_t bad = 0; + uint32_t seenDelimiter = 0; + uint32_t messageStart = 0; + if (dbLen <= 0) { + return 0; + } + mask = (uint8_t*) malloc((size_t) dbLen); + if (!mask) { + return 0; + } + bad |= (uint32_t) block[0]; + cn1_mgf1_sha1(block + 1 + hashLen, dbLen, mask, hashLen); + for (i = 0; i < hashLen; i++) { + seed[i] = (uint8_t) (block[1 + i] ^ mask[i]); + } + cn1_mgf1_sha1(seed, hashLen, mask, dbLen); + for (i = 0; i < dbLen; i++) { + block[1 + hashLen + i] ^= mask[i]; + } + free(mask); + CC_SHA256("", 0, labelHash); + for (i = 0; i < hashLen; i++) { + bad |= (uint32_t) (labelHash[i] ^ block[1 + hashLen + i]); + } + for (i = 1 + hashLen + hashLen; i < blockLen; i++) { + uint32_t value = block[i]; + uint32_t isDelimiter = cn1_ct_eq_mask(value, 0x01); + uint32_t isZero = cn1_ct_eq_mask(value, 0x00); + uint32_t firstDelimiter = isDelimiter & ~seenDelimiter; + messageStart |= ((uint32_t) (i + 1)) & firstDelimiter; + bad |= ~seenDelimiter & ~isDelimiter & ~isZero; + seenDelimiter |= isDelimiter; + } + bad |= ~seenDelimiter; + if (bad != 0) { + return 0; + } + *messageLen = blockLen - (int) messageStart; + if (*messageLen > 0) { + memcpy(message, block + messageStart, (size_t) *messageLen); + } + return 1; +} + static SecKeyAlgorithm rsa_padding_alg(int paddingKind) { return paddingKind == 2 ? kSecKeyAlgorithmRSAEncryptionOAEPSHA256 @@ -296,6 +431,26 @@ int cn1_crypto_rsa_encrypt(int paddingKind, uint8_t* out, int outCap) { SecKeyRef key = cn1_load_rsa_public(x509, x509Len); if (!key) return CN1_CRYPTO_E_BAD_KEY; + if (paddingKind == 2) { + /* Pad here and run the key raw, so the mask stays SHA-1 (see the OAEP + * note above). */ + int blockLen = (int) SecKeyGetBlockSize(key); + uint8_t* block = (uint8_t*) malloc((size_t) (blockLen > 0 ? blockLen : 1)); + int rc; + if (!block) { + CFRelease(key); + return CN1_CRYPTO_E_GENERIC; + } + if (!cn1_oaep_encode(in, inLen, block, blockLen)) { + free(block); + CFRelease(key); + return CN1_CRYPTO_E_BAD_INPUT; + } + rc = cn1_seckey_op(key, kSecKeyAlgorithmRSAEncryptionRaw, 1, block, blockLen, out, outCap); + free(block); + CFRelease(key); + return rc; + } int rc = cn1_seckey_op(key, rsa_padding_alg(paddingKind), 1, in, inLen, out, outCap); CFRelease(key); return rc; @@ -307,6 +462,42 @@ int cn1_crypto_rsa_decrypt(int paddingKind, uint8_t* out, int outCap) { SecKeyRef key = cn1_load_rsa_private(pkcs8, pkcs8Len); if (!key) return CN1_CRYPTO_E_BAD_KEY; + if (paddingKind == 2) { + int blockLen = (int) SecKeyGetBlockSize(key); + uint8_t* raw = (uint8_t*) malloc((size_t) (blockLen > 0 ? blockLen : 1)); + uint8_t* block; + int rawLen, messageLen = 0, rc; + if (!raw) { + CFRelease(key); + return CN1_CRYPTO_E_GENERIC; + } + rawLen = cn1_seckey_op(key, kSecKeyAlgorithmRSAEncryptionRaw, 0, in, inLen, raw, blockLen); + CFRelease(key); + if (rawLen < 0) { + free(raw); + return rawLen; + } + /* A raw result may arrive with its leading zero bytes dropped; the OAEP + * block is defined at exactly the modulus width, so restore them. */ + block = (uint8_t*) calloc((size_t) (blockLen > 0 ? blockLen : 1), 1); + if (!block) { + free(raw); + return CN1_CRYPTO_E_GENERIC; + } + if (rawLen > blockLen) { + free(raw); + free(block); + return CN1_CRYPTO_E_BAD_INPUT; + } + memcpy(block + (blockLen - rawLen), raw, (size_t) rawLen); + free(raw); + if (!cn1_oaep_decode(block, blockLen, out, &messageLen) || messageLen > outCap) { + free(block); + return CN1_CRYPTO_E_BAD_INPUT; + } + free(block); + return messageLen; + } int rc = cn1_seckey_op(key, rsa_padding_alg(paddingKind), 0, in, inLen, out, outCap); CFRelease(key); return rc; From e8bf68eec9f66ec0ea634101d8170fa2423f6a3d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 2 Aug 2026 05:33:14 +0700 Subject: [PATCH 28/91] Require the exact key family, and let a dispatched run repair a stale port Key family The check I added tested only EVP_PKEY_EC on Linux and a boolean isEc on Windows, so every non-EC key counted as RSA. DSA bytes carrying an "RSA" label therefore passed and were signed as DSA under a SHA256withRSA request. Both ports now name the family they require: RSA for the RSA algorithms, EC for the ECDSA ones, and anything else is refused. On Windows that meant reporting an actual family from the importer and the SubjectPublicKeyInfo rather than "EC or not". Sweep candidates The producers declare workflow_dispatch and schedule, not push, so filtering candidates to push and schedule meant a maintainer rerunning a producer on master to repair a port the scheduled run missed could never reach the table -- the one case where a manual recovery is the whole point. Dispatched runs on master now count. Co-Authored-By: Claude Opus 5 (1M context) --- .../nativeSources/cn1_linux_crypto.c | 12 +++- .../nativeSources/cn1_windows_crypto.c | 55 +++++++++++++------ .../conformance/backfill_port_status.sh | 7 ++- 3 files changed, 53 insertions(+), 21 deletions(-) diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c b/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c index 20e1f5a60d6..4b989a260b0 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c @@ -379,9 +379,15 @@ JAVA_OBJECT com_codename1_impl_linux_LinuxNative_rsaCrypt___java_lang_String_boo * satisfied while the encoded key is a different family entirely -- and the * primitive below would then sign an "RSA" request with ECDSA. */ static int cn1KeyFamilyMatches(const char* algorithm, EVP_PKEY* key) { - int wantsEc = strstr(algorithm, "ECDSA") != 0; - int keyIsEc = EVP_PKEY_base_id(key) == EVP_PKEY_EC; - return wantsEc == keyIsEc; + /* Name the family that is required rather than testing "not EC": every + * other key type would otherwise pass as RSA, so DSA bytes carrying an + * "RSA" label would reach EVP_DigestSign and come back as a DSA signature + * under a SHA256withRSA request. */ + int id = EVP_PKEY_base_id(key); + if (strstr(algorithm, "ECDSA") != 0) { + return id == EVP_PKEY_EC; + } + return id == EVP_PKEY_RSA; } diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c index 1cec98dbae6..f0659b5b47d 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c +++ b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c @@ -54,6 +54,11 @@ #define CN1_GCM_TAG_BYTES 16 +/* Key families, named so that "not EC" can never stand in for RSA. */ +#define CN1_KEY_OTHER 0 +#define CN1_KEY_RSA 1 +#define CN1_KEY_EC 2 + /* Per-thread: crypto failures on different threads would otherwise overwrite * each other and lastCryptoError() could answer with an unrelated call's * message. */ @@ -330,15 +335,15 @@ static BCRYPT_KEY_HANDLE cn1PublicKey(const unsigned char* der, int length) { * EC with one path; *isEc reports which arrived so the caller can pick the * matching padding. */ -static NCRYPT_KEY_HANDLE cn1PrivateKey(const unsigned char* der, int length, int* isEc) { +static NCRYPT_KEY_HANDLE cn1PrivateKey(const unsigned char* der, int length, int* family) { NCRYPT_PROV_HANDLE provider = 0; NCRYPT_KEY_HANDLE key = 0; SECURITY_STATUS status; WCHAR algorithm[64]; DWORD algorithmBytes = 0; - if (isEc != 0) { - *isEc = 0; + if (family != 0) { + *family = CN1_KEY_OTHER; } status = NCryptOpenStorageProvider(&provider, MS_KEY_STORAGE_PROVIDER, 0); if (status != ERROR_SUCCESS) { @@ -362,27 +367,41 @@ static NCRYPT_KEY_HANDLE cn1PrivateKey(const unsigned char* der, int length, int } return 0; } - if (isEc != 0 && + if (family != 0 && NCryptGetProperty(key, NCRYPT_ALGORITHM_GROUP_PROPERTY, (PBYTE) algorithm, sizeof(algorithm), &algorithmBytes, 0) == ERROR_SUCCESS) { - *isEc = wcscmp(algorithm, NCRYPT_ECDSA_ALGORITHM_GROUP) == 0 - || wcscmp(algorithm, NCRYPT_ECDH_ALGORITHM_GROUP) == 0; + /* Name both families rather than reporting "EC or not": anything else + * -- DSA in particular -- would otherwise be indistinguishable from RSA + * and would sign a SHA256withRSA request with whatever it actually is. */ + if (wcscmp(algorithm, NCRYPT_ECDSA_ALGORITHM_GROUP) == 0 + || wcscmp(algorithm, NCRYPT_ECDH_ALGORITHM_GROUP) == 0) { + *family = CN1_KEY_EC; + } else if (wcscmp(algorithm, NCRYPT_RSA_ALGORITHM_GROUP) == 0) { + *family = CN1_KEY_RSA; + } } return key; } -/* True when an X.509 SubjectPublicKeyInfo carries an elliptic-curve key. */ -static int cn1PublicKeyIsEc(const unsigned char* der, int length) { +/* The family an X.509 SubjectPublicKeyInfo carries. Named explicitly for the + * same reason as the private-key side: treating every non-EC key as RSA lets a + * DSA key verify an RSA request. */ +static int cn1PublicKeyFamily(const unsigned char* der, int length) { CERT_PUBLIC_KEY_INFO* info = 0; DWORD infoLength = 0; - int isEc = 0; + int family = CN1_KEY_OTHER; if (CryptDecodeObjectEx(X509_ASN_ENCODING, X509_PUBLIC_KEY_INFO, der, (DWORD) length, CRYPT_DECODE_ALLOC_FLAG, NULL, &info, &infoLength)) { - isEc = info->Algorithm.pszObjId != 0 - && strcmp(info->Algorithm.pszObjId, szOID_ECC_PUBLIC_KEY) == 0; + if (info->Algorithm.pszObjId != 0) { + if (strcmp(info->Algorithm.pszObjId, szOID_ECC_PUBLIC_KEY) == 0) { + family = CN1_KEY_EC; + } else if (strcmp(info->Algorithm.pszObjId, szOID_RSA_RSA) == 0) { + family = CN1_KEY_RSA; + } + } LocalFree(info); } - return isEc; + return family; } /* The digest half of Signature's six advertised algorithms; NULL for anything @@ -899,10 +918,11 @@ JAVA_OBJECT com_codename1_impl_windows_WindowsNative_rsaCrypt___java_lang_String JAVA_OBJECT com_codename1_impl_windows_WindowsNative_signData___java_lang_String_byte_1ARRAY_byte_1ARRAY_R_byte_1ARRAY( CODENAME_ONE_THREAD_STATE, JAVA_OBJECT algorithm, JAVA_OBJECT keyArray, JAVA_OBJECT dataArray) { const char* name = algorithm == JAVA_NULL ? "" : stringToUTF8(threadStateData, algorithm); - int keyLength = 0, dataLength = 0, isEc = 0; + int keyLength = 0, dataLength = 0, keyFamily = CN1_KEY_OTHER; unsigned char* keyDer = cn1Bytes(keyArray, &keyLength); unsigned char* data = cn1Bytes(dataArray, &dataLength); - NCRYPT_KEY_HANDLE key = cn1PrivateKey(keyDer, keyLength, &isEc); + NCRYPT_KEY_HANDLE key = cn1PrivateKey(keyDer, keyLength, &keyFamily); + int isEc = keyFamily == CN1_KEY_EC; LPCWSTR digestAlgorithm = cn1DigestAlgorithm(name); unsigned char digest[64]; int digestLength = cn1DigestLength(digestAlgorithm); @@ -930,7 +950,7 @@ JAVA_OBJECT com_codename1_impl_windows_WindowsNative_signData___java_lang_String * it against the bytes, so the Java-side comparison can be satisfied while * the encoded key is a different family -- and NCrypt would then answer an * "RSA" request with an ECDSA signature. */ - if ((strstr(name, "ECDSA") != 0) != (isEc != 0)) { + if (keyFamily != (strstr(name, "ECDSA") != 0 ? CN1_KEY_EC : CN1_KEY_RSA)) { cn1CryptoFail("the signature algorithm does not match the key", 0); goto done; } @@ -987,7 +1007,8 @@ JAVA_BOOLEAN com_codename1_impl_windows_WindowsNative_verifyData___java_lang_Str unsigned char* signature = cn1Bytes(signatureArray, &signatureLength); /* CryptImportPublicKeyInfoEx2 handles both key kinds; only the padding * differs, so read the algorithm out of the SubjectPublicKeyInfo. */ - int isEc = cn1PublicKeyIsEc(keyDer, keyLength); + int keyFamily = cn1PublicKeyFamily(keyDer, keyLength); + int isEc = keyFamily == CN1_KEY_EC; BCRYPT_KEY_HANDLE key = cn1PublicKey(keyDer, keyLength); LPCWSTR digestAlgorithm = cn1DigestAlgorithm(name); unsigned char digest[64]; @@ -1003,7 +1024,7 @@ JAVA_BOOLEAN com_codename1_impl_windows_WindowsNative_verifyData___java_lang_Str BCryptDestroyKey(key); return JAVA_FALSE; } - if ((strstr(name, "ECDSA") != 0) != (isEc != 0)) { + if (keyFamily != (strstr(name, "ECDSA") != 0 ? CN1_KEY_EC : CN1_KEY_RSA)) { cn1CryptoFail("the signature algorithm does not match the key", 0); BCryptDestroyKey(key); return JAVA_FALSE; diff --git a/scripts/hellocodenameone/conformance/backfill_port_status.sh b/scripts/hellocodenameone/conformance/backfill_port_status.sh index 1e5404c32e8..6f2cc28c467 100755 --- a/scripts/hellocodenameone/conformance/backfill_port_status.sh +++ b/scripts/hellocodenameone/conformance/backfill_port_status.sh @@ -101,6 +101,11 @@ while IFS= read -r workflow; do # Newest first, and a failed run counts: a suite that fails still uploads the # normalized report, and a report that records real failures is the result # the table is supposed to show. + # + # workflow_dispatch counts too. The producers declare dispatch and schedule + # rather than push, so a maintainer rerunning one on master to repair a port + # the scheduled run missed is exactly the recovery this sweep exists to pick + # up -- and filtering it out meant the manual fix could never reach the table. # Every run still inside the staleness horizon is a candidate, rather than a # fixed newest-five slice. A workflow whose matrix legs fail independently -- # the Linux producer especially, whose reports are not reliably published by @@ -112,7 +117,7 @@ while IFS= read -r workflow; do || date -u -v-"${sweep_stale_days}"d +%Y-%m-%dT%H:%M:%SZ)" candidates="$(gh run list --workflow "${workflow}" --branch master --limit 100 \ --json databaseId,event,conclusion,updatedAt \ - --jq --arg horizon "${horizon}" '[.[] | select((.event == "push" or .event == "schedule") and + --jq --arg horizon "${horizon}" '[.[] | select((.event == "push" or .event == "schedule" or .event == "workflow_dispatch") and (.conclusion == "success" or .conclusion == "failure") and (.updatedAt >= $horizon))] | sort_by(.updatedAt) | reverse | .[].databaseId')" From 0f7eed222d081975abf2a867106b492211633304 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 2 Aug 2026 07:01:27 +0700 Subject: [PATCH 29/91] Arm the Linux gates that were letting unrun tests pass as success Comparing this branch's Linux report against master's shows the problem the strict gate exists to catch, and it is mine: master pass 161 fail 7 not-run 2 skip 0 this branch pass 161 fail 0 not-run 8 skip 1 Five of master's seven failures are genuinely fixed and one is a documented skip. But six tests that RAN on master do not run here at all -- five that passed, and FileSystemStorageOpenInputStreamMissingTest, which failed on master and which I previously reported as fixed. It was not fixed. It stopped running. That is worse than a red build. "fail: 0" read as success while it was partly the absence of tests rather than the absence of failures, and my own reporting repeated that reading. Two gates were supposed to prevent exactly this and neither was live. CN1_REQUIRE_SUITE was set to '1'. It is read with Boolean.parseBoolean, which answers false for '1', so the gate demanding the suite's own completion marker has never been armed on Linux -- the suite could be force-killed with trailing tests unrun and the job stayed green. The Windows pipeline passes a real boolean, which is why its gate works. Now 'true', in both the glibc job and the musl container. CN1SS_FAIL_ON_TEST_PROBLEMS was never exported for the Linux report, so --fail-on-test-problems -- which fails on tests that fail, do not run, or an incomplete suite -- was not passed, unlike the iOS, JavaScript and Mac workflows. Now exported. This will turn Linux red until the suite runs to completion, and that is the correct state: the workflow already documents that it "intermittently DIES mid-run with no output", and master leaves two tests unrun for the same reason. Making that visible is the point. A green built on tests that never ran is the failure this PR was opened to remove, not a result to keep. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/linux-build-run.yml | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/.github/workflows/linux-build-run.yml b/.github/workflows/linux-build-run.yml index 488c69b532b..ce6e418b229 100644 --- a/.github/workflows/linux-build-run.yml +++ b/.github/workflows/linux-build-run.yml @@ -224,7 +224,13 @@ jobs: # the VideoIO grid, the VR scene and the 360 panorama were still queued # behind the slow non-rendering API tail, so the suite was force-killed # and every trailing test was published as "never run". - CN1_REQUIRE_SUITE: '1' + # + # This is read with Boolean.parseBoolean, which answers false for '1', + # so the gate it describes has never actually been armed -- the suite + # could be force-killed with trailing tests unrun and nothing failed. + # The Windows pipeline passes a real boolean, which is why its gate + # works. 'true' arms it here. + CN1_REQUIRE_SUITE: 'true' # Build the native ELF (and the demo) against an old glibc for portability. CN1_CC: /usr/local/bin/cn1-zig-cc # After the suite runs, the capture test relinks the same objects into a @@ -326,7 +332,7 @@ jobs: -v "$GITHUB_WORKSPACE":/cn1 -w /cn1 \ -e CN1_SHOT_OUTPUT_DIR=/cn1/artifacts/linux-port/raw-musl \ -e CN1_APP_LOG_TEE=/cn1/artifacts/linux-port/raw-musl/app-output.log \ - -e CN1_REQUIRE_SUITE=1 \ + -e CN1_REQUIRE_SUITE=true \ -e LIBGL_ALWAYS_SOFTWARE=1 \ docker.io/library/alpine:3.20 sh -ec ' sed -i "s|^#\(.*/community\)|\1|" /etc/apk/repositories @@ -422,6 +428,13 @@ jobs: # Gate the Linux port (both arches): fail on any mismatch/error or a new # screenshot that has no committed golden (missing_expected). export CN1SS_FAIL_ON_MISMATCH=1 + # A test that fails an assertion, or never runs at all, has to fail + # this workflow the way it already does on iOS, JavaScript and Mac. + # Without it a suite that stopped early published its trailing tests + # as "never run" and the job stayed green -- a result that reads as + # success while hiding both the tests that failed and the fact that + # they stopped running. + export CN1SS_FAIL_ON_TEST_PROBLEMS=1 export CN1SS_ALLOWED_MISSING=0 if [ "${{ github.event_name }}" != "pull_request" ]; then export CN1SS_SKIP_COMMENT=1; fi for arch in x64 arm64; do From 3d4051fb1cf8d74ccd119895e2ec9ad5208422f9 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 2 Aug 2026 07:39:43 +0700 Subject: [PATCH 30/91] Stop a closed MCP transport from stranding the process-wide registration MCPLoopbackTransportOpenTest failed in CI with "Another MCP socket transport is already open on port 47899" -- a port that class never uses. The registration is process-wide and had been left claimed by an earlier test class. The race is in the product, not the test. MCPServer.start hands the transport to a reader thread and returns; stop() closes it from the caller's thread. When stop() wins, close() cannot release the registration because open() has not claimed it yet -- and open(), arriving afterwards on the reader thread, claims it for a transport that will never listen. The slot is then held for the rest of the process and every later open() is refused, which is why an unrelated MCP test failed depending on class order. open() now re-reads the closed flag after claiming the slot and releases it again if the transport was closed underneath it. The regression test drives the sequence directly -- close, then open -- rather than trying to win a race. Against the previous code it fails, and takes the other two tests in the class down with it, which is the cascade CI saw. Also in this commit, both from review: An OAEP block shorter than 2*hLen+2 was accepted. With a 512-bit key the data block is 31 bytes while the SHA-256 label hash is 32, so the hash was written and compared past the end of the block. AddressSanitizer reports a heap-buffer-overflow on the previous code and is clean with the guard, while 640- and 768-bit blocks still round-trip. Fixed on iOS and Windows. TimeZone.getOffset asked ICU about the wrong instant on Windows. Its fields are local STANDARD time -- GregorianCalendar adds the raw offset before calling -- and converting them as UTC keeps the old offset for roughly the zone's raw-offset span around a transition, so America/New_York entering DST reported EST for the first five hours of EDT. It now queries at fields - rawOffset. The cross-compile still links. Co-Authored-By: Claude Opus 5 (1M context) --- .../mcp/MCPLoopbackSocketTransport.java | 14 +++++++++++ .../nativeSources/cn1_windows_crypto.c | 10 ++++++-- Ports/iOSPort/nativeSources/CN1Crypto.m | 7 ++++-- .../mcp/MCPLoopbackTransportOpenTest.java | 23 +++++++++++++++++++ vm/ByteCodeTranslator/src/nativeMethods.m | 19 +++++++++++---- 5 files changed, 65 insertions(+), 8 deletions(-) diff --git a/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java b/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java index 5e715ae6fea..0520124c448 100644 --- a/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java +++ b/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java @@ -118,6 +118,20 @@ public void open() throws IOException { } active = this; } + // open() runs on the server's reader thread, so a stop() from another thread can + // already have closed this transport before we got here. close() clears the + // registration only if it is still ours, and at that point it was not ours yet -- + // so claiming it now would strand the process-wide slot on a transport that never + // listens, and every later open() would be refused with "already open on port N" + // for the lifetime of the process. Release it and fail instead. + boolean closedBeforeListening; + synchronized (lock) { + closedBeforeListening = closed; + } + if (closedBeforeListening) { + clearActiveIfOurs(); + throw new IOException("This MCP socket transport was closed before it began listening"); + } try { listening = Socket.listenLoopback(port, Connection.class); } catch (RuntimeException ex) { diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c index f0659b5b47d..761d6d58fca 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c +++ b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c @@ -546,7 +546,10 @@ static int cn1OaepEncode(LPCWSTR labelDigest, LPCWSTR maskDigest, const unsigned unsigned char seed[64]; unsigned char mask[1024]; int i; - if (dbLength <= 0 || dbLength > (int) sizeof(mask)) { + /* An OAEP block cannot be shorter than 2*hLen+2. A 512-bit key leaves a DB + * shorter than the label hash itself, and the hash would then be written + * and compared past the end of the block. */ + if (blockLength < 2 * hashLength + 2 || dbLength > (int) sizeof(mask)) { cn1CryptoFail("RSA-OAEP block does not fit the key", 0); return 0; } @@ -614,7 +617,10 @@ static int cn1OaepDecode(LPCWSTR labelDigest, LPCWSTR maskDigest, unsigned char* unsigned int bad = 0; unsigned int seenDelimiter = 0; unsigned int messageStart = 0; - if (dbLength <= 0 || dbLength > (int) sizeof(mask)) { + /* An OAEP block cannot be shorter than 2*hLen+2. A 512-bit key leaves a DB + * shorter than the label hash itself, and the hash would then be written + * and compared past the end of the block. */ + if (blockLength < 2 * hashLength + 2 || dbLength > (int) sizeof(mask)) { cn1CryptoFail("RSA-OAEP block does not fit the key", 0); return 0; } diff --git a/Ports/iOSPort/nativeSources/CN1Crypto.m b/Ports/iOSPort/nativeSources/CN1Crypto.m index ece67f44bf1..610dd94f6ce 100644 --- a/Ports/iOSPort/nativeSources/CN1Crypto.m +++ b/Ports/iOSPort/nativeSources/CN1Crypto.m @@ -336,7 +336,10 @@ static int cn1_oaep_encode(const uint8_t* message, int messageLen, uint8_t seed[CC_SHA256_DIGEST_LENGTH]; uint8_t* mask; int i; - if (dbLen <= 0 || messageLen > dbLen - hashLen - 1) { + /* An OAEP block cannot be shorter than 2*hLen+2; a 512-bit key leaves a DB + * shorter than the label hash, and the label hash would then be written and + * compared past the end of the block. */ + if (blockLen < 2 * hashLen + 2 || messageLen > dbLen - hashLen - 1) { return 0; } mask = (uint8_t*) malloc((size_t) dbLen); @@ -378,7 +381,7 @@ static int cn1_oaep_decode(uint8_t* block, int blockLen, uint8_t* message, int* uint32_t bad = 0; uint32_t seenDelimiter = 0; uint32_t messageStart = 0; - if (dbLen <= 0) { + if (blockLen < 2 * hashLen + 2) { return 0; } mask = (uint8_t*) malloc((size_t) dbLen); diff --git a/maven/core-unittests/src/test/java/com/codename1/mcp/MCPLoopbackTransportOpenTest.java b/maven/core-unittests/src/test/java/com/codename1/mcp/MCPLoopbackTransportOpenTest.java index c9985e747b4..bba072ef257 100644 --- a/maven/core-unittests/src/test/java/com/codename1/mcp/MCPLoopbackTransportOpenTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/mcp/MCPLoopbackTransportOpenTest.java @@ -55,6 +55,29 @@ void closeTransports() { } } + /// A transport closed before its reader thread reaches open() must not take the + /// process-wide registration with it. + /// + /// MCPServer.start hands the transport to a reader thread and returns; stop() closes + /// it from the caller's thread. When stop() wins that race, close() cannot release the + /// registration because open() has not claimed it yet -- and open() then claimed it + /// for a transport that never listens, stranding the slot for the rest of the process. + /// Every later open() was refused with "already open on port N", which is how an + /// unrelated MCP test started failing in CI depending on class order. + @Test + void aTransportClosedBeforeItListensReleasesTheRegistration() throws Exception { + implementation.setServerSocketAvailable(true); + + MCPLoopbackSocketTransport early = new MCPLoopbackSocketTransport(47883); + early.close(); + // The reader thread's open(), arriving after the close. + assertThrows(IOException.class, () -> early.open()); + + // The slot has to be free, or nothing can open a transport again. + second = new MCPLoopbackSocketTransport(47884); + second.open(); + } + @Test void aFailedListenNeitherStrandsTheRegistrationNorEscapesAsRuntime() throws Exception { // The transport checks loopback support and then uses it. Report support to the diff --git a/vm/ByteCodeTranslator/src/nativeMethods.m b/vm/ByteCodeTranslator/src/nativeMethods.m index c2d14639aa1..00e7377674b 100644 --- a/vm/ByteCodeTranslator/src/nativeMethods.m +++ b/vm/ByteCodeTranslator/src/nativeMethods.m @@ -2751,10 +2751,21 @@ JAVA_INT java_util_TimeZone_getTimezoneOffset___java_lang_String_int_int_int_int cn1_timezone_offset_ctx ctx; #ifdef _WIN32 { - int offset = 0; - if (cn1WinZoneOffsetMillis(buffer, cn1WinUtcMillis(year, month, day, timeOfDayMillis), - &offset, 0, 0)) { - return offset; + /* These fields are local STANDARD time, which is what GregorianCalendar + * hands down (it adds the raw offset to the epoch before calling). The + * instant they name is therefore fields - rawOffset, not the fields read + * as UTC: asking about the wrong instant makes the calendar keep the old + * offset for roughly the zone's raw-offset span either side of a + * transition, so America/New_York entering DST reports EST for the first + * five hours of EDT. */ + int rawOffset = 0; + long long nominal = cn1WinUtcMillis(year, month, day, timeOfDayMillis); + if (cn1WinZoneOffsetMillis(buffer, nominal, 0, 0, &rawOffset)) { + int offset = 0; + if (cn1WinZoneOffsetMillis(buffer, nominal - (long long) rawOffset, + &offset, 0, 0)) { + return offset; + } } } #endif From 34087aa6fb7aedf8964e4963fbc605448a6ebe6f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:41:49 +0700 Subject: [PATCH 31/91] Name the test the Linux suite stops in With the gates armed the Linux suite now fails honestly, and the failure says the suite never emitted its completion marker -- but not which test it stopped in, which is the one thing needed to fix it. The in-app watchdog was supposed to answer that and could not. It did not emit a single marker across a 47-minute wedge, and the reason is structural: a test that blocks the event dispatch thread in a tight loop also blocks the collector, so the watchdog's own log call can be stuck waiting to allocate. A diagnostic that lives inside the wedged process cannot be relied on to report the wedge. The harness already reads the suite's output from outside the process, so it now records the last "suite starting test=" it saw and names it in both the console line and the assertion message. That works whatever state the app is in. On the current run it identifies Base64NativePerformanceTest: the log ends cleanly on that announcement with no exception and no further output, so the suite is hung there rather than crashed -- which is a different problem from the "silently DIES mid-run" the workflow comment describes, and worth knowing apart. Co-Authored-By: Claude Opus 5 (1M context) --- .../CleanTargetLinuxIntegrationTest.java | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java index 0d304694a61..d6b8a8d1540 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java @@ -376,6 +376,13 @@ void capturesHelloSuiteOverWebSocketLinux() throws Exception { // Set by the suite watchdog when a test blocks the event dispatch thread; // the run cannot progress past that point, so stop instead of waiting. final java.util.concurrent.atomic.AtomicReference wedged = new java.util.concurrent.atomic.AtomicReference<>(); + // The last test the suite announced. The in-app watchdog names a wedging + // test itself, but it cannot always get the message out: a test that blocks + // the event dispatch thread in a tight loop also blocks the collector, so + // the watchdog's own log call can be stuck waiting to allocate. Reading the + // announcement from outside the process always works, and "stopped in X" is + // the difference between a diagnosable failure and a silent one. + final java.util.concurrent.atomic.AtomicReference lastStarted = new java.util.concurrent.atomic.AtomicReference<>(); final Process appF = app; Thread areader = new Thread(() -> { // Tee the app's merged stdout/stderr to CN1_APP_LOG_TEE when @@ -399,6 +406,11 @@ void capturesHelloSuiteOverWebSocketLinux() throws Exception { if (tee != null) { tee.println(line); } if (line.contains("CN1SS:SUITE:FINISHED")) { finished.set(true); } if (line.contains("CN1SS:SUITE:WEDGED")) { wedged.set(line); } + int startedAt = line.indexOf("CN1SS:INFO:suite starting test="); + if (startedAt >= 0) { + lastStarted.set(line.substring( + startedAt + "CN1SS:INFO:suite starting test=".length()).trim()); + } } } catch (IOException ignore) { } @@ -454,15 +466,19 @@ void capturesHelloSuiteOverWebSocketLinux() throws Exception { } pngs = CleanTargetIntegrationTest.countPngFiles(outDir); if (!finished.get()) { + String stoppedIn = lastStarted.get(); System.out.println("CN1SS:HARNESS: suite never emitted CN1SS:SUITE:FINISHED; pngs=" + pngs - + " -- every test after the last logged one is reported as never run."); + + "; stopped in " + (stoppedIn == null ? "" : stoppedIn) + + " -- that test and every one after it is reported as never run."); } assertTrue(wedged.get() == null, "the suite stopped because a test blocked the event dispatch thread: " + wedged.get()); assertTrue(finished.get() || (!requireSuite && pngs >= minPngs), "hello suite capture incomplete: pngs=" + pngs + " (need " + minPngs + ")" - + " suiteFinished=" + finished.get() + "\n" + serverLog); + + " suiteFinished=" + finished.get() + + " stoppedIn=" + (lastStarted.get() == null ? "" : lastStarted.get()) + + "\n" + serverLog); String outEnv = System.getenv("CN1_SHOT_OUTPUT_DIR"); if (outEnv != null) { From 5cdb9ccef83b7ff82f33405c20703dde4eda8453 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:45:26 +0700 Subject: [PATCH 32/91] Decide the MCP listener publication under the lock close() uses My previous fix closed the "registration left claimed" half of this race and left a worse one open. When close() runs while listenLoopback() is binding, it sees listening == null, clears the registration and returns -- and open() then publishes a listener nobody holds a reference to stop. That leaks the bound port, and because connection callbacks consult the process-wide `active`, the orphan could hand a connection to a later transport. `listening` is now published under the same lock close() takes, so the outcome is decidable whichever thread wins: either close() sees a published listener and stops it, or open() sees the closed flag and stops it itself, releasing the registration on the way out. The bind itself stays outside that lock, which is the one place I did not follow the review literally. Socket.listenLoopback is a platform call and the connection callback takes the same lock, so holding it across the bind invites a deadlock. Checking "did close() win?" immediately afterwards gives the same invariant -- no orphaned listener, no stranded registration -- without that risk. All 43 MCP tests pass. Co-Authored-By: Claude Opus 5 (1M context) --- .../mcp/MCPLoopbackSocketTransport.java | 45 +++++++++++++------ 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java b/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java index 0520124c448..78b5ac7b9fc 100644 --- a/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java +++ b/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java @@ -119,21 +119,24 @@ public void open() throws IOException { active = this; } // open() runs on the server's reader thread, so a stop() from another thread can - // already have closed this transport before we got here. close() clears the - // registration only if it is still ours, and at that point it was not ours yet -- - // so claiming it now would strand the process-wide slot on a transport that never - // listens, and every later open() would be refused with "already open on port N" - // for the lifetime of the process. Release it and fail instead. - boolean closedBeforeListening; - synchronized (lock) { - closedBeforeListening = closed; - } - if (closedBeforeListening) { - clearActiveIfOurs(); - throw new IOException("This MCP socket transport was closed before it began listening"); - } + // already have closed this transport, or close it while we are binding. Two + // things have to be true afterwards whichever order those land in: the + // process-wide registration must not stay claimed by a transport that never + // listens (every later open() would then be refused with "already open on port + // N" for the life of the process), and no listener may be left bound with + // nobody holding a reference to stop it -- an orphan keeps the port and its + // connection callbacks still consult `active`, so it could hand a connection to + // a later transport. + // + // Publishing `listening` under the same lock close() uses is what makes that + // decidable. The bind itself stays outside the lock -- Socket.listenLoopback is + // a platform call and holding a lock the connection callback also takes across + // it invites a deadlock -- so the check is "did close() win?" immediately after, + // and the loser cleans up. Either close() sees a published listener and stops + // it, or we see closed and stop it ourselves. + Socket.StopListening bound; try { - listening = Socket.listenLoopback(port, Connection.class); + bound = Socket.listenLoopback(port, Connection.class); } catch (RuntimeException ex) { // Two things go wrong if this escapes. The process-wide registration would stay // pointing at a transport that never started listening, so every later open() @@ -146,6 +149,20 @@ public void open() throws IOException { failure.initCause(ex); throw failure; } + boolean closedWhileBinding; + synchronized (lock) { + closedWhileBinding = closed; + if (!closedWhileBinding) { + listening = bound; + } + } + if (closedWhileBinding) { + clearActiveIfOurs(); + if (bound != null) { + bound.stop(); + } + throw new IOException("This MCP socket transport was closed before it began listening"); + } } /// Releases the process-wide registration, but only when it is still this transport's. From fb754a9a7ba1a00a5ca993397f93e33e66f6eba0 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:59:11 +0700 Subject: [PATCH 33/91] Remove the in-process wedge watchdog that deadlocked the Linux suite Base64NativePerformanceTest passes on master and hangs on this branch, and the watchdog I added is why. A test that blocks the event dispatch thread in a tight compute loop also stops the collector. The watchdog's log call allocates, to build its message, so it parks waiting for a collection that cannot happen until the EDT yields -- which is why it reported nothing at all across a 47 minute wedge, the one job it existed to do. Worse than useless: a second thread merely asking to allocate during that test's benchmark loops is enough to deadlock the pair, and the suite stopped dead on the most allocation-heavy test in it. Everything after it was then published as never run, which is the masking the previous commits were opened to remove -- caused by the diagnostic meant to expose it. A watchdog living inside the wedged process was the wrong design. It cannot allocate, cannot log, and can only add a thread to the deadlock it is watching for. The harness already reads the suite's output from outside the process and now names the last announced test from there, which works whatever state the app is in and cannot perturb it. So the watchdog is gone: the thread, the two fields it polled, and the per-test bookkeeping that fed it. Cn1ssDeviceRunner's synthetic lambda set is byte identical to master's again, which also removes for good the id-renumbering hazard that broke the JavaScript port earlier in this branch. Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/Cn1ssDeviceRunner.java | 95 +++---------------- 1 file changed, 15 insertions(+), 80 deletions(-) diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java index 9c2a546a782..bdd89876bbe 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java @@ -491,86 +491,25 @@ public void runSuite() { logThrowable("EDT", (Throwable)e.getSource()); }); }); - startWedgeWatchdog(); runNextTest(0); } - /// Name of the test the event dispatch thread is inside, and the wall clock - /// at which it stops being plausible that it is still working. Written on - /// the EDT, read by the watchdog thread. - private volatile String activeTestName; - private volatile long activeTestDeadline; - - /// Grace on top of a test's own timeout before the watchdog concludes the - /// EDT is not coming back. The per-test timeout is itself enforced by an - /// EDT callback, so a test that blocks the thread outright can never be - /// timed out by it -- the suite simply stops, and every remaining test is - /// published as "never run" with nothing saying why. - private static final long WEDGE_GRACE_MS = 30000L; - - /// The watchdog body. + /// Which test wedged the suite is reported by the capture harness, not from + /// in here. /// - /// This is a named class and MUST NOT be rewritten as a lambda. The - /// JavaScript port hand-binds three of this class's Runnable lambdas by - /// translated id -- Cn1ssDeviceRunner_lambda_1_run through _3_run, see - /// bindCiFallback in port.js -- and the translator numbers lambdas in their - /// declaration order within the class. A lambda declared here, ahead of the - /// ones in runNextTest, shifts every one of those ids by one, so the bridge - /// that polls for a test's completion gets handed the lambda that starts a - /// test instead. The ids still resolve, so nothing reports an error: the - /// suite simply stops advancing after its first test. A named class has its - /// own method namespace and leaves that numbering alone. - private final class WedgeWatchdog implements Runnable { - public void run() { - while (!suiteFinished) { - try { - Thread.sleep(1000L); - } catch (InterruptedException interrupted) { - return; - } - String name = activeTestName; - long deadline = activeTestDeadline; - if (name == null || deadline <= 0L) { - continue; - } - long overrun = System.currentTimeMillis() - (deadline + WEDGE_GRACE_MS); - if (overrun < 0L) { - continue; - } - // Report against the test rather than the suite: this is the - // one line that says which test stopped the run. - log("CN1SS:ERR:suite test=" + name + " failed: the event dispatch thread has not" - + " returned from this test " + overrun + "ms past its deadline; the suite" - + " cannot continue and every later test is unreached"); - log("CN1SS:SUITE:WEDGED test=" + name); - // Nothing here can end the process: exitApplication would have - // to run on the very thread that is stuck, and the raw exit - // calls are not part of the API the ports support. The marker - // above is the contract instead -- the capture harness watches - // for it and stops the run, having been told which test to - // blame. - return; - } - } - } - - private void startWedgeWatchdog() { - // Not on HTML5. That port drives the suite from the browser's single - // thread through the bridges described on WedgeWatchdog, and its harness - // already bounds the run and force-advances a stalled dispatch. The - // wedges this watchdog exists to name are on the native desktop ports. - if ("HTML5".equals(Display.getInstance().getPlatformName())) { - return; - } - // Display.startThread rather than a bare Thread: the ports only support - // the thread surface the bytecode compliance gate allows, and this hands - // back a CodenameOneThread that the platform names and reaps for us. - Display.getInstance().startThread(new WedgeWatchdog(), "cn1ss-wedge-watchdog").start(); - } - - /// Set once the suite is over so the watchdog thread returns instead of - /// outliving the run. - private volatile boolean suiteFinished; + /// This used to be an in-process watchdog thread. It could not do the job and + /// it caused a worse one. A test that blocks the event dispatch thread in a + /// tight compute loop also stops the collector, so the watchdog's own log + /// call -- which allocates, to build its message -- parks waiting for a + /// collection that cannot happen until the EDT yields. It reported nothing + /// across a 47 minute wedge for exactly that reason. Worse, merely asking to + /// allocate from a second thread during Base64NativePerformanceTest's + /// benchmark loops was enough to deadlock the pair, and the Linux suite -- + /// which completes on master -- stopped dead on that test. + /// + /// The harness reads the suite's output from outside the process, so it can + /// name the last announced test whatever state the app is in, and cannot + /// perturb it. See lastStarted in CleanTargetLinuxIntegrationTest. private void runNextTest(int index) { int offset = prependedTest != null ? 1 : 0; @@ -607,8 +546,6 @@ private void runNextTest(int index) { CN.callSerially(() -> { Cn1ssDeviceRunnerHelper.clearTransportFailure(); log("CN1SS:INFO:suite starting test=" + testName); - activeTestName = testName; - activeTestDeadline = System.currentTimeMillis() + testTimeoutMs(testClass); try { testClass.prepare(); testClass.runTest(); @@ -646,7 +583,6 @@ private void awaitTestCompletion(int index, BaseTest testClass, String testName, } private void finalizeTest(int index, BaseTest testClass, String testName, boolean timedOut) { - activeTestName = null; final Runnable continueToNext = () -> { log("CN1SS:INFO:suite finished test=" + testName); runNextTest(index + 1); @@ -756,7 +692,6 @@ private void finishSuite() { } log("CN1SS:INFO:swift_diag_status=" + status); } finally { - suiteFinished = true; log("CN1SS:SUITE:FINISHED"); } try { From 9ce66958a150ada920efccddca1aa12204e9dbf8 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:28:34 +0700 Subject: [PATCH 34/91] Standardise OAEP on SHA-256 for both halves, the only pairing every port can produce The five-port alignment in the previous commit was aimed at the wrong target: I surveyed five backends and missed the one with no freedom of choice. Web Crypto's RSA-OAEP takes a single hash and uses it for the label and the mask alike, and SubtleCrypto exposes no raw RSA primitive to hand-pad around, so the JCE reading of "OAEPWithSHA-256AndMGF1Padding" -- SHA-256 label, SHA-1 mask -- is not implementable on the JavaScript port at all. Apple's SecKey has the same shape. Any target that keeps the split pairing leaves JavaScript permanently unable to exchange ciphertext with the rest. SHA-256 for both is the only pairing all six ports can produce, so that is what the portable constant now means, said plainly on Cipher.RSA_OAEP_SHA256 rather than inherited from a provider default: - JavaSE and Android pass an explicit OAEPParameterSpec instead of relying on the JCE default. - Linux moves MGF1 to SHA-256; Windows passes the label digest as the mask digest. - iOS goes back to plain SecKey OAEPSHA256, which deletes the manual padding I added last round -- about 130 lines of hand-rolled OAEP, and with it the class of buffer bug the review had just caught in it. - JavaScript is unchanged, because it was the constraint. Verified against OpenSSL on the new pairing: our padding accepted by RSA_padding_check_PKCS1_OAEP_mgf1 with SHA-256/SHA-256 and its padding accepted by ours, at 2048, 3072 and 4096 bits, plus the tamper and DER strictness cases. Cross-port interop is the property that was broken, so it is the one the harness asserts. Also here: iOS getOffset had the same local-standard-time bug already fixed on Windows. It built the NSDate in UTC, so around a transition the calendar kept the previous offset for the zone's whole raw-offset span -- America/New_York reporting EST for the first five hours of EDT. It now subtracts the raw offset before asking. The Linux harness dumps every thread's stack from the live process when it gives up waiting. The workflow's post-mortem only runs on a core file, so a hang -- as opposed to the crash that comment describes -- has produced no evidence at all so far. This is what should finally locate the Base64NativePerformanceTest stall. A redundant null check SpotBugs rejected (RCN_REDUNDANT_NULLCHECK) in the MCP transport. Local `verify` is the gate, not `test`; I had run the latter after the last two changes, which is how a one-line style finding reached CI. core-unittests: 4678 tests, no failures, SpotBugs zero findings. Windows cross-compile links. JavaSE port builds. Co-Authored-By: Claude Opus 5 (1M context) --- .../mcp/MCPLoopbackSocketTransport.java | 6 +- .../src/com/codename1/security/Cipher.java | 12 ++ .../impl/android/AndroidImplementation.java | 26 ++- .../com/codename1/impl/javase/JavaSEPort.java | 26 ++- .../nativeSources/cn1_linux_crypto.c | 14 +- .../nativeSources/cn1_windows_crypto.c | 17 +- Ports/iOSPort/nativeSources/CN1Crypto.m | 199 +----------------- Ports/iOSPort/nativeSources/IOSNative.m | 23 +- .../CleanTargetLinuxIntegrationTest.java | 47 +++++ 9 files changed, 147 insertions(+), 223 deletions(-) diff --git a/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java b/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java index 78b5ac7b9fc..e09425778ff 100644 --- a/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java +++ b/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java @@ -157,10 +157,10 @@ public void open() throws IOException { } } if (closedWhileBinding) { + // No null check on `bound`: the only way past the try above is with a + // listener in hand, and SpotBugs flags the redundant test. clearActiveIfOurs(); - if (bound != null) { - bound.stop(); - } + bound.stop(); throw new IOException("This MCP socket transport was closed before it began listening"); } } diff --git a/CodenameOne/src/com/codename1/security/Cipher.java b/CodenameOne/src/com/codename1/security/Cipher.java index efb7ca214c2..72e7bd4ba3f 100644 --- a/CodenameOne/src/com/codename1/security/Cipher.java +++ b/CodenameOne/src/com/codename1/security/Cipher.java @@ -71,6 +71,18 @@ public final class Cipher { /// `RSA/ECB/OAEPWithSHA-256AndMGF1Padding` -- recommended RSA encryption /// transformation. + /// + /// SHA-256 is used for the label hash **and** for MGF1. That is worth stating + /// because the JCE reading of this name leaves MGF1 on SHA-1, and this API + /// deliberately does not: Web Crypto's `RSA-OAEP` takes a single hash and + /// applies it to both, as does Apple's SecKey, so the split pairing is not + /// expressible on the JavaScript or iOS ports at all. SHA-256 for both is the + /// only choice every port can produce, so it is the one that lets ciphertext + /// cross between them. The JavaSE and Android ports pass an explicit + /// `OAEPParameterSpec` rather than inheriting their provider's default. + /// + /// Interoperating with an external system that uses the JCE default (SHA-1 + /// MGF1) therefore needs that system to name SHA-256 for the mask as well. public static final String RSA_OAEP_SHA256 = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding"; /// `RSA/ECB/PKCS1Padding` -- legacy RSA padding, kept for interop. diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index 63702181cb5..191ae79a48b 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -13939,13 +13939,31 @@ private static byte[] androidAes(String transformation, byte[] key, byte[] iv, b } } + /// The OAEP parameters every port agrees on. + /// + /// The JCE transformation name "OAEPWithSHA-256AndMGF1Padding" leaves MGF1 on + /// SHA-1 by default, which no other backend here can reproduce: Web Crypto's + /// RSA-OAEP uses one hash for the label and the mask, and so does Apple's + /// SecKey. Naming SHA-256 for both is the only pairing all six ports can + /// produce, so it is what the portable constant means -- stated explicitly + /// rather than inherited from a provider default. + private static javax.crypto.spec.OAEPParameterSpec cn1OaepParameters() { + return new javax.crypto.spec.OAEPParameterSpec("SHA-256", "MGF1", + java.security.spec.MGF1ParameterSpec.SHA256, + javax.crypto.spec.PSource.PSpecified.DEFAULT); + } + @Override public byte[] rsaEncrypt(String transformation, byte[] publicKeyX509, byte[] plaintext) { try { javax.crypto.Cipher cipher = javax.crypto.Cipher.getInstance(transformation); java.security.KeyFactory kf = java.security.KeyFactory.getInstance("RSA"); java.security.PublicKey key = kf.generatePublic(new java.security.spec.X509EncodedKeySpec(publicKeyX509)); - cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, key); + if (transformation != null && transformation.toUpperCase().indexOf("OAEP") >= 0) { + cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, key, cn1OaepParameters()); + } else { + cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, key); + } return cipher.doFinal(plaintext); } catch (java.security.GeneralSecurityException e) { throw new RuntimeException("RSA encrypt failed: " + e.getMessage()); @@ -13958,7 +13976,11 @@ public byte[] rsaDecrypt(String transformation, byte[] privateKeyPkcs8, byte[] c javax.crypto.Cipher cipher = javax.crypto.Cipher.getInstance(transformation); java.security.KeyFactory kf = java.security.KeyFactory.getInstance("RSA"); java.security.PrivateKey key = kf.generatePrivate(new java.security.spec.PKCS8EncodedKeySpec(privateKeyPkcs8)); - cipher.init(javax.crypto.Cipher.DECRYPT_MODE, key); + if (transformation != null && transformation.toUpperCase().indexOf("OAEP") >= 0) { + cipher.init(javax.crypto.Cipher.DECRYPT_MODE, key, cn1OaepParameters()); + } else { + cipher.init(javax.crypto.Cipher.DECRYPT_MODE, key); + } return cipher.doFinal(ciphertext); } catch (java.security.GeneralSecurityException e) { throw new RuntimeException("RSA decrypt failed: " + e.getMessage()); diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index 7353168fe4a..90ab635faaa 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -19768,13 +19768,31 @@ private static byte[] javaseAes(String transformation, byte[] key, byte[] iv, by } } + /// The OAEP parameters every port agrees on. + /// + /// The JCE transformation name "OAEPWithSHA-256AndMGF1Padding" leaves MGF1 on + /// SHA-1 by default, which no other backend here can reproduce: Web Crypto's + /// RSA-OAEP uses one hash for the label and the mask, and so does Apple's + /// SecKey. Naming SHA-256 for both is the only pairing all six ports can + /// produce, so it is what the portable constant means -- stated explicitly + /// rather than inherited from a provider default. + private static javax.crypto.spec.OAEPParameterSpec cn1OaepParameters() { + return new javax.crypto.spec.OAEPParameterSpec("SHA-256", "MGF1", + java.security.spec.MGF1ParameterSpec.SHA256, + javax.crypto.spec.PSource.PSpecified.DEFAULT); + } + @Override public byte[] rsaEncrypt(String transformation, byte[] publicKeyX509, byte[] plaintext) { try { javax.crypto.Cipher cipher = javax.crypto.Cipher.getInstance(transformation); java.security.KeyFactory kf = java.security.KeyFactory.getInstance("RSA"); java.security.PublicKey key = kf.generatePublic(new java.security.spec.X509EncodedKeySpec(publicKeyX509)); - cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, key); + if (transformation != null && transformation.toUpperCase().indexOf("OAEP") >= 0) { + cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, key, cn1OaepParameters()); + } else { + cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, key); + } return cipher.doFinal(plaintext); } catch (java.security.GeneralSecurityException e) { throw new RuntimeException("RSA encrypt failed: " + e.getMessage()); @@ -19787,7 +19805,11 @@ public byte[] rsaDecrypt(String transformation, byte[] privateKeyPkcs8, byte[] c javax.crypto.Cipher cipher = javax.crypto.Cipher.getInstance(transformation); java.security.KeyFactory kf = java.security.KeyFactory.getInstance("RSA"); java.security.PrivateKey key = kf.generatePrivate(new java.security.spec.PKCS8EncodedKeySpec(privateKeyPkcs8)); - cipher.init(javax.crypto.Cipher.DECRYPT_MODE, key); + if (transformation != null && transformation.toUpperCase().indexOf("OAEP") >= 0) { + cipher.init(javax.crypto.Cipher.DECRYPT_MODE, key, cn1OaepParameters()); + } else { + cipher.init(javax.crypto.Cipher.DECRYPT_MODE, key); + } return cipher.doFinal(ciphertext); } catch (java.security.GeneralSecurityException e) { throw new RuntimeException("RSA decrypt failed: " + e.getMessage()); diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c b/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c index 4b989a260b0..942fc457c84 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c @@ -304,14 +304,16 @@ static int cn1ApplyRsaPadding(EVP_PKEY_CTX* ctx, const char* transformation) { } if (strstr(transformation, "OAEP") != 0) { const EVP_MD* md = EVP_sha256(); - // The mask function stays on SHA-1 even when the OAEP digest is - // SHA-256. That is what the JCE providers behind the JavaSE and - // Android ports do for this transformation name, and ciphertext has to - // stay readable across ports; naming the digest for both halves would - // make anything sealed here undecryptable there. + // SHA-256 for the label hash AND the mask. The JCE transformation name + // leaves MGF1 on SHA-1 by default, but no other backend in this project + // can reproduce that: Web Crypto's RSA-OAEP takes a single hash and uses + // it for both, and so does Apple's SecKey. SHA-256 for both is the only + // pairing every port can produce, so it is the one the portable constant + // means -- the JavaSE and Android ports name it explicitly rather than + // inheriting their provider's default. if (EVP_PKEY_CTX_set_rsa_padding(ctx, RSA_PKCS1_OAEP_PADDING) <= 0 || EVP_PKEY_CTX_set_rsa_oaep_md(ctx, md) <= 0 || - EVP_PKEY_CTX_set_rsa_mgf1_md(ctx, EVP_sha1()) <= 0) { + EVP_PKEY_CTX_set_rsa_mgf1_md(ctx, md) <= 0) { return 0; } return 1; diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c index 761d6d58fca..95d6fa58f6c 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c +++ b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c @@ -462,13 +462,12 @@ static int cn1Digest(LPCWSTR algorithm, const unsigned char* data, int length, * * Two shapes CNG cannot produce on its own: * - * OAEP -- BCRYPT_OAEP_PADDING_INFO carries one digest, which CNG uses for both - * the label hash and the mask function. The JCE providers behind the JavaSE and - * Android ports pair a SHA-256 label hash with a SHA-1 mask for - * "OAEPWithSHA-256AndMGF1Padding", and the Linux port matches them, so - * ciphertext has to use that pairing to stay readable across ports. Naming one - * digest for both halves either weakens the label hash or breaks interop, so - * the padding is built here and the key operation runs unpadded. + * OAEP -- the padding is built here rather than handed to CNG so the block can + * be validated and reported on our own terms (see cn1OaepDecode's single + * generic failure). Both halves use SHA-256: the JCE transformation name + * leaves MGF1 on SHA-1 by default, but Web Crypto and Apple's SecKey each take + * one hash and use it for label and mask alike, so SHA-256 for both is the only + * pairing every port in this project can produce. * * ECDSA -- NCryptSignHash answers the fixed-width r||s of P1363, while the * portable Signature contract (and Jwt.derToJoseEcdsa) expects ASN.1 DER, so @@ -847,7 +846,7 @@ JAVA_OBJECT com_codename1_impl_windows_WindowsNative_rsaCrypt___java_lang_String goto done; } if (encrypt) { - if (!cn1OaepEncode(labelDigest, BCRYPT_SHA1_ALGORITHM, data, dataLength, block, + if (!cn1OaepEncode(labelDigest, labelDigest, data, dataLength, block, (int) modulusBytes)) { goto done; } @@ -875,7 +874,7 @@ JAVA_OBJECT com_codename1_impl_windows_WindowsNative_rsaCrypt___java_lang_String cn1CryptoFail("out of memory", 0); goto done; } - if (!cn1OaepDecode(labelDigest, BCRYPT_SHA1_ALGORITHM, block, (int) modulusBytes, + if (!cn1OaepDecode(labelDigest, labelDigest, block, (int) modulusBytes, out, &messageLength)) { goto done; } diff --git a/Ports/iOSPort/nativeSources/CN1Crypto.m b/Ports/iOSPort/nativeSources/CN1Crypto.m index 610dd94f6ce..caab266cba5 100644 --- a/Ports/iOSPort/nativeSources/CN1Crypto.m +++ b/Ports/iOSPort/nativeSources/CN1Crypto.m @@ -284,144 +284,11 @@ static int cn1_seckey_op(SecKeyRef key, SecKeyAlgorithm alg, int forEncrypt, return (int) len; } -/* - * OAEP, built here rather than taken from SecKey. - * - * kSecKeyAlgorithmRSAEncryptionOAEPSHA256 uses SHA-256 for the label hash AND - * for MGF1. The transformation this port advertises is the JCE name - * "RSA/ECB/OAEPWithSHA-256AndMGF1Padding", which in JCE means a SHA-256 label - * with a SHA-1 mask -- that is what java.crypto gives the JavaSE and Android - * ports for the same string, and what the Linux and Windows ports produce. iOS - * was the only port pairing SHA-256 with SHA-256, so ciphertext never crossed - * between it and any other port. SecKey cannot express the JCE pairing, so the - * padding is built here and the key operation runs raw. - */ - -static void cn1_mgf1_sha1(const uint8_t* seed, int seedLen, uint8_t* mask, int maskLen) { - uint8_t counter[4]; - uint8_t digest[CC_SHA1_DIGEST_LENGTH]; - int produced = 0; - uint32_t count = 0; - while (produced < maskLen) { - int chunk = maskLen - produced; - CC_SHA1_CTX ctx; - counter[0] = (uint8_t) ((count >> 24) & 0xff); - counter[1] = (uint8_t) ((count >> 16) & 0xff); - counter[2] = (uint8_t) ((count >> 8) & 0xff); - counter[3] = (uint8_t) (count & 0xff); - CC_SHA1_Init(&ctx); - CC_SHA1_Update(&ctx, seed, (CC_LONG) seedLen); - CC_SHA1_Update(&ctx, counter, 4); - CC_SHA1_Final(digest, &ctx); - if (chunk > CC_SHA1_DIGEST_LENGTH) { - chunk = CC_SHA1_DIGEST_LENGTH; - } - memcpy(mask + produced, digest, (size_t) chunk); - produced += chunk; - count++; - } -} - -/* All ones when a == b, zero otherwise, without branching on the values. */ -static uint32_t cn1_ct_eq_mask(uint32_t a, uint32_t b) { - uint32_t diff = a ^ b; - uint32_t nonZero = (diff | (0u - diff)) >> 31; - return nonZero - 1u; -} - -static int cn1_oaep_encode(const uint8_t* message, int messageLen, - uint8_t* block, int blockLen) { - const int hashLen = CC_SHA256_DIGEST_LENGTH; - int dbLen = blockLen - hashLen - 1; - uint8_t seed[CC_SHA256_DIGEST_LENGTH]; - uint8_t* mask; - int i; - /* An OAEP block cannot be shorter than 2*hLen+2; a 512-bit key leaves a DB - * shorter than the label hash, and the label hash would then be written and - * compared past the end of the block. */ - if (blockLen < 2 * hashLen + 2 || messageLen > dbLen - hashLen - 1) { - return 0; - } - mask = (uint8_t*) malloc((size_t) dbLen); - if (!mask) { - return 0; - } - memset(block, 0, (size_t) blockLen); - CC_SHA256("", 0, block + 1 + hashLen); - block[blockLen - messageLen - 1] = 0x01; - if (messageLen > 0) { - memcpy(block + blockLen - messageLen, message, (size_t) messageLen); - } - if (CCRandomGenerateBytes(seed, (size_t) hashLen) != kCCSuccess) { - free(mask); - return 0; - } - cn1_mgf1_sha1(seed, hashLen, mask, dbLen); - for (i = 0; i < dbLen; i++) { - block[1 + hashLen + i] ^= mask[i]; - } - cn1_mgf1_sha1(block + 1 + hashLen, dbLen, mask, hashLen); - for (i = 0; i < hashLen; i++) { - block[1 + i] = (uint8_t) (seed[i] ^ mask[i]); - } - free(mask); - return 1; -} - -/* Every check folds into one accumulator and one generic failure is reported: - * telling a leading-byte error from a label-hash error is enough to mount the - * adaptive attacks OAEP exists to prevent. */ -static int cn1_oaep_decode(uint8_t* block, int blockLen, uint8_t* message, int* messageLen) { - const int hashLen = CC_SHA256_DIGEST_LENGTH; - int dbLen = blockLen - hashLen - 1; - uint8_t labelHash[CC_SHA256_DIGEST_LENGTH]; - uint8_t seed[CC_SHA256_DIGEST_LENGTH]; - uint8_t* mask; - int i; - uint32_t bad = 0; - uint32_t seenDelimiter = 0; - uint32_t messageStart = 0; - if (blockLen < 2 * hashLen + 2) { - return 0; - } - mask = (uint8_t*) malloc((size_t) dbLen); - if (!mask) { - return 0; - } - bad |= (uint32_t) block[0]; - cn1_mgf1_sha1(block + 1 + hashLen, dbLen, mask, hashLen); - for (i = 0; i < hashLen; i++) { - seed[i] = (uint8_t) (block[1 + i] ^ mask[i]); - } - cn1_mgf1_sha1(seed, hashLen, mask, dbLen); - for (i = 0; i < dbLen; i++) { - block[1 + hashLen + i] ^= mask[i]; - } - free(mask); - CC_SHA256("", 0, labelHash); - for (i = 0; i < hashLen; i++) { - bad |= (uint32_t) (labelHash[i] ^ block[1 + hashLen + i]); - } - for (i = 1 + hashLen + hashLen; i < blockLen; i++) { - uint32_t value = block[i]; - uint32_t isDelimiter = cn1_ct_eq_mask(value, 0x01); - uint32_t isZero = cn1_ct_eq_mask(value, 0x00); - uint32_t firstDelimiter = isDelimiter & ~seenDelimiter; - messageStart |= ((uint32_t) (i + 1)) & firstDelimiter; - bad |= ~seenDelimiter & ~isDelimiter & ~isZero; - seenDelimiter |= isDelimiter; - } - bad |= ~seenDelimiter; - if (bad != 0) { - return 0; - } - *messageLen = blockLen - (int) messageStart; - if (*messageLen > 0) { - memcpy(message, block + messageStart, (size_t) *messageLen); - } - return 1; -} - +/* OAEPSHA256 uses SHA-256 for the label hash and for MGF1 alike, which is the + * pairing the portable RSA_OAEP_SHA256 constant means. The JCE transformation + * name it borrows leaves MGF1 on SHA-1 by default, but neither SecKey nor Web + * Crypto can express that split, so the JavaSE and Android ports name SHA-256 + * for both explicitly and every port agrees. */ static SecKeyAlgorithm rsa_padding_alg(int paddingKind) { return paddingKind == 2 ? kSecKeyAlgorithmRSAEncryptionOAEPSHA256 @@ -434,26 +301,6 @@ int cn1_crypto_rsa_encrypt(int paddingKind, uint8_t* out, int outCap) { SecKeyRef key = cn1_load_rsa_public(x509, x509Len); if (!key) return CN1_CRYPTO_E_BAD_KEY; - if (paddingKind == 2) { - /* Pad here and run the key raw, so the mask stays SHA-1 (see the OAEP - * note above). */ - int blockLen = (int) SecKeyGetBlockSize(key); - uint8_t* block = (uint8_t*) malloc((size_t) (blockLen > 0 ? blockLen : 1)); - int rc; - if (!block) { - CFRelease(key); - return CN1_CRYPTO_E_GENERIC; - } - if (!cn1_oaep_encode(in, inLen, block, blockLen)) { - free(block); - CFRelease(key); - return CN1_CRYPTO_E_BAD_INPUT; - } - rc = cn1_seckey_op(key, kSecKeyAlgorithmRSAEncryptionRaw, 1, block, blockLen, out, outCap); - free(block); - CFRelease(key); - return rc; - } int rc = cn1_seckey_op(key, rsa_padding_alg(paddingKind), 1, in, inLen, out, outCap); CFRelease(key); return rc; @@ -465,42 +312,6 @@ int cn1_crypto_rsa_decrypt(int paddingKind, uint8_t* out, int outCap) { SecKeyRef key = cn1_load_rsa_private(pkcs8, pkcs8Len); if (!key) return CN1_CRYPTO_E_BAD_KEY; - if (paddingKind == 2) { - int blockLen = (int) SecKeyGetBlockSize(key); - uint8_t* raw = (uint8_t*) malloc((size_t) (blockLen > 0 ? blockLen : 1)); - uint8_t* block; - int rawLen, messageLen = 0, rc; - if (!raw) { - CFRelease(key); - return CN1_CRYPTO_E_GENERIC; - } - rawLen = cn1_seckey_op(key, kSecKeyAlgorithmRSAEncryptionRaw, 0, in, inLen, raw, blockLen); - CFRelease(key); - if (rawLen < 0) { - free(raw); - return rawLen; - } - /* A raw result may arrive with its leading zero bytes dropped; the OAEP - * block is defined at exactly the modulus width, so restore them. */ - block = (uint8_t*) calloc((size_t) (blockLen > 0 ? blockLen : 1), 1); - if (!block) { - free(raw); - return CN1_CRYPTO_E_GENERIC; - } - if (rawLen > blockLen) { - free(raw); - free(block); - return CN1_CRYPTO_E_BAD_INPUT; - } - memcpy(block + (blockLen - rawLen), raw, (size_t) rawLen); - free(raw); - if (!cn1_oaep_decode(block, blockLen, out, &messageLen) || messageLen > outCap) { - free(block); - return CN1_CRYPTO_E_BAD_INPUT; - } - free(block); - return messageLen; - } int rc = cn1_seckey_op(key, rsa_padding_alg(paddingKind), 0, in, inLen, out, outCap); CFRelease(key); return rc; diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index fcfe7cd9ce7..0ed698a7cf6 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -10020,15 +10020,24 @@ JAVA_INT java_util_TimeZone_getTimezoneOffset___java_lang_String_int_int_int_int [comps setHour:timeOfDayMillis/3600000]; [comps setMinute:(timeOfDayMillis/60000)%60]; [comps setSecond:(timeOfDayMillis/1000)%60]; - // The caller passes UTC fields -- the POSIX implementation of this native - // resolves them with timegm() -- so build the date in UTC too. Reading them - // in the device's own zone (currentCalendar) moved the instant by the - // device offset, which lands on the wrong side of a transition when the - // requested zone changes offset within that window. + // These fields are local STANDARD time, not UTC: GregorianCalendar adds the + // zone's raw offset to the epoch before calling getOffset. Building the date + // in UTC and asking about that instant is therefore off by the raw offset, + // which around a transition returns the previous offset for its whole span + // -- America/New_York keeps reporting EST for the first five hours of EDT. + // + // Reading them in the device's own zone is wrong for a different reason (it + // shifts by the device offset instead), so the calendar stays on UTC and the + // raw offset is subtracted explicitly. NSCalendar* cal = [NSCalendar calendarWithIdentifier:NSCalendarIdentifierGregorian]; [cal setTimeZone:[NSTimeZone timeZoneWithAbbreviation:@"UTC"]]; - NSDate *date = [cal dateFromComponents:comps]; - JAVA_INT result = [tzone secondsFromGMTForDate:date] * 1000; + NSDate *nominal = [cal dateFromComponents:comps]; + NSInteger rawOffset = [tzone secondsFromGMTForDate:nominal]; + if ([tzone isDaylightSavingTimeForDate:nominal]) { + rawOffset -= (NSInteger)[tzone daylightSavingTimeOffsetForDate:nominal]; + } + NSDate *date = [nominal dateByAddingTimeInterval:-(NSTimeInterval)rawOffset]; + JAVA_INT result = (JAVA_INT)([tzone secondsFromGMTForDate:date] * 1000); [comps release]; POOL_END(); return result; diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java index d6b8a8d1540..7a26b087128 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java @@ -465,6 +465,15 @@ void capturesHelloSuiteOverWebSocketLinux() throws Exception { Thread.sleep(3000); } pngs = CleanTargetIntegrationTest.countPngFiles(outDir); + if (!finished.get() && app.isAlive()) { + // The suite is still running and has stopped saying anything. The + // post-mortem in the workflow only fires on a core file, so a HANG -- + // as opposed to the crash that comment describes -- has so far produced + // no evidence at all. Attach to the live process and take every thread's + // stack before killing it; that is the difference between knowing which + // call is stuck and guessing at it. + dumpLiveThreadStacks(); + } if (!finished.get()) { String stoppedIn = lastStarted.get(); System.out.println("CN1SS:HARNESS: suite never emitted CN1SS:SUITE:FINISHED; pngs=" + pngs @@ -580,4 +589,42 @@ static void spliceWindowedDemoLauncher(Path launcherC) throws IOException { s = s.substring(0, start) + body + s.substring(end); Files.write(launcherC, s.getBytes(StandardCharsets.UTF_8)); } + /// Dumps every thread's native stack from the still-running suite process. + /// + /// Best effort by design: gdb may be absent and ptrace may be restricted, and + /// neither should turn a diagnostic into a second failure. Output goes next to + /// the app log so it is uploaded with the screenshot artifact. + private static void dumpLiveThreadStacks() { + try { + String teePath = System.getenv("CN1_APP_LOG_TEE"); + if (teePath == null) { + return; + } + Process pgrep = new ProcessBuilder("pgrep", "-f", "LinuxHelloMain") + .redirectErrorStream(true).start(); + String pid; + try (BufferedReader r = new BufferedReader( + new InputStreamReader(pgrep.getInputStream(), StandardCharsets.UTF_8))) { + pid = r.readLine(); + } + pgrep.waitFor(); + if (pid == null || pid.trim().isEmpty()) { + return; + } + java.io.File out = new java.io.File( + new java.io.File(teePath).getParentFile(), "hang-stacks.txt"); + Process gdb = new ProcessBuilder("gdb", "-p", pid.trim(), "-batch", + "-ex", "set pagination off", + "-ex", "thread apply all bt") + .redirectErrorStream(true) + .redirectOutput(ProcessBuilder.Redirect.appendTo(out)) + .start(); + gdb.waitFor(); + System.out.println("CN1SS:HARNESS: wrote live thread stacks for pid " + pid.trim() + + " to " + out); + } catch (Exception ignore) { + // A missing gdb or a denied ptrace must not mask the real failure. + } + } + } From 5d14fc44f43f26dad2952797be9ce01f08939eca Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 2 Aug 2026 22:15:26 +0700 Subject: [PATCH 35/91] Put getTimezoneOffset back on UTC fields, and match the OAEP name exactly The timezone change I made last round broke TimeApiTest on iOS and mac: expected 2020-03-08T01:30:00-05:00, got 02:30:00-04:00 -- local 01:30 EST resolved as 02:30 EDT, jumping the spring transition. The review was right about java.util.TimeZone.getOffset's signature: those fields are local standard time. But this native does not implement that contract. Every port resolves them as UTC -- POSIX with timegm(), which the iOS comment already said, and the JavaScript and Android ports match -- and TimeApiTest pins it. I changed two ports to match a signature instead of the contract their callers rely on, and broke a test that was passing. Windows got the same change a round earlier, where nothing caught it; both are reverted, with the expected/actual recorded in the comment so the next reader does not make the same correction. OAEP transformation matching A substring test for "OAEP" answered every OAEP name with the SHA-256/SHA-256 parameters, including RSA/ECB/OAEPWithSHA-1AndMGF1Padding -- ciphertext no compliant peer could read under the name it was asked for. JavaSE and Android now match Cipher.RSA_OAEP_SHA256 exactly and refuse anything outside the two supported RSA transformations, which is the rule the native ports already apply. Hang diagnostics The live gdb attach came back "Could not attach to process": Ubuntu ships yama ptrace_scope=1, which forbids attaching to a sibling, and the harness is a sibling of the suite. The workflow now lowers it alongside the core pattern it already sets, and the harness retries under sudo if the direct attach is still refused. Windows cross-compile links. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/linux-build-run.yml | 6 ++++ .../impl/android/AndroidImplementation.java | 24 +++++++++++-- .../com/codename1/impl/javase/JavaSEPort.java | 24 +++++++++++-- Ports/iOSPort/nativeSources/IOSNative.m | 25 +++++-------- vm/ByteCodeTranslator/src/nativeMethods.m | 25 ++++++------- .../CleanTargetLinuxIntegrationTest.java | 35 +++++++++++++++---- 6 files changed, 97 insertions(+), 42 deletions(-) diff --git a/.github/workflows/linux-build-run.yml b/.github/workflows/linux-build-run.yml index ce6e418b229..37a08137f53 100644 --- a/.github/workflows/linux-build-run.yml +++ b/.github/workflows/linux-build-run.yml @@ -250,6 +250,12 @@ jobs: # Enable core dumps and post-mortem them into the artifact. ulimit -c unlimited echo '/tmp/cn1-cores/core.%e.%p' | sudo tee /proc/sys/kernel/core_pattern >/dev/null + # Let the harness attach gdb to the still-running suite when it gives up + # waiting. Ubuntu ships yama ptrace_scope=1, which restricts attaching to + # descendants, and the harness is a sibling of the app -- so without this + # the hang dump comes back "Could not attach to process" and a hang (as + # opposed to a crash, which leaves a core) yields no evidence at all. + echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope >/dev/null 2>&1 || true mkdir -p /tmp/cn1-cores rc=0 mvn -B clean package -pl JavaAPI -am -DskipTests diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index 191ae79a48b..cf667b6f536 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -13939,6 +13939,24 @@ private static byte[] androidAes(String transformation, byte[] key, byte[] iv, b } } + /// The RSA transformations this port implements, matched exactly. + /// + /// A substring test for "OAEP" would answer every OAEP name -- including + /// RSA/ECB/OAEPWithSHA-1AndMGF1Padding -- with the SHA-256 parameters below, + /// producing ciphertext no standards-compliant peer could read under the name + /// it asked for. The native ports already accept only these two, so refusing + /// anything else here keeps every port answering the same question. + private static boolean cn1IsOaepTransformation(String transformation) { + return com.codename1.security.Cipher.RSA_OAEP_SHA256.equals(transformation); + } + + private static void cn1CheckRsaTransformation(String transformation) { + if (!cn1IsOaepTransformation(transformation) + && !com.codename1.security.Cipher.RSA_PKCS1.equals(transformation)) { + throw new RuntimeException("unsupported cipher transformation: " + transformation); + } + } + /// The OAEP parameters every port agrees on. /// /// The JCE transformation name "OAEPWithSHA-256AndMGF1Padding" leaves MGF1 on @@ -13959,7 +13977,8 @@ public byte[] rsaEncrypt(String transformation, byte[] publicKeyX509, byte[] pla javax.crypto.Cipher cipher = javax.crypto.Cipher.getInstance(transformation); java.security.KeyFactory kf = java.security.KeyFactory.getInstance("RSA"); java.security.PublicKey key = kf.generatePublic(new java.security.spec.X509EncodedKeySpec(publicKeyX509)); - if (transformation != null && transformation.toUpperCase().indexOf("OAEP") >= 0) { + cn1CheckRsaTransformation(transformation); + if (cn1IsOaepTransformation(transformation)) { cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, key, cn1OaepParameters()); } else { cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, key); @@ -13976,7 +13995,8 @@ public byte[] rsaDecrypt(String transformation, byte[] privateKeyPkcs8, byte[] c javax.crypto.Cipher cipher = javax.crypto.Cipher.getInstance(transformation); java.security.KeyFactory kf = java.security.KeyFactory.getInstance("RSA"); java.security.PrivateKey key = kf.generatePrivate(new java.security.spec.PKCS8EncodedKeySpec(privateKeyPkcs8)); - if (transformation != null && transformation.toUpperCase().indexOf("OAEP") >= 0) { + cn1CheckRsaTransformation(transformation); + if (cn1IsOaepTransformation(transformation)) { cipher.init(javax.crypto.Cipher.DECRYPT_MODE, key, cn1OaepParameters()); } else { cipher.init(javax.crypto.Cipher.DECRYPT_MODE, key); diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index 90ab635faaa..92dc66fce31 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -19768,6 +19768,24 @@ private static byte[] javaseAes(String transformation, byte[] key, byte[] iv, by } } + /// The RSA transformations this port implements, matched exactly. + /// + /// A substring test for "OAEP" would answer every OAEP name -- including + /// RSA/ECB/OAEPWithSHA-1AndMGF1Padding -- with the SHA-256 parameters below, + /// producing ciphertext no standards-compliant peer could read under the name + /// it asked for. The native ports already accept only these two, so refusing + /// anything else here keeps every port answering the same question. + private static boolean cn1IsOaepTransformation(String transformation) { + return com.codename1.security.Cipher.RSA_OAEP_SHA256.equals(transformation); + } + + private static void cn1CheckRsaTransformation(String transformation) { + if (!cn1IsOaepTransformation(transformation) + && !com.codename1.security.Cipher.RSA_PKCS1.equals(transformation)) { + throw new RuntimeException("unsupported cipher transformation: " + transformation); + } + } + /// The OAEP parameters every port agrees on. /// /// The JCE transformation name "OAEPWithSHA-256AndMGF1Padding" leaves MGF1 on @@ -19788,7 +19806,8 @@ public byte[] rsaEncrypt(String transformation, byte[] publicKeyX509, byte[] pla javax.crypto.Cipher cipher = javax.crypto.Cipher.getInstance(transformation); java.security.KeyFactory kf = java.security.KeyFactory.getInstance("RSA"); java.security.PublicKey key = kf.generatePublic(new java.security.spec.X509EncodedKeySpec(publicKeyX509)); - if (transformation != null && transformation.toUpperCase().indexOf("OAEP") >= 0) { + cn1CheckRsaTransformation(transformation); + if (cn1IsOaepTransformation(transformation)) { cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, key, cn1OaepParameters()); } else { cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, key); @@ -19805,7 +19824,8 @@ public byte[] rsaDecrypt(String transformation, byte[] privateKeyPkcs8, byte[] c javax.crypto.Cipher cipher = javax.crypto.Cipher.getInstance(transformation); java.security.KeyFactory kf = java.security.KeyFactory.getInstance("RSA"); java.security.PrivateKey key = kf.generatePrivate(new java.security.spec.PKCS8EncodedKeySpec(privateKeyPkcs8)); - if (transformation != null && transformation.toUpperCase().indexOf("OAEP") >= 0) { + cn1CheckRsaTransformation(transformation); + if (cn1IsOaepTransformation(transformation)) { cipher.init(javax.crypto.Cipher.DECRYPT_MODE, key, cn1OaepParameters()); } else { cipher.init(javax.crypto.Cipher.DECRYPT_MODE, key); diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index 0ed698a7cf6..05daf0dbab2 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -10020,24 +10020,17 @@ JAVA_INT java_util_TimeZone_getTimezoneOffset___java_lang_String_int_int_int_int [comps setHour:timeOfDayMillis/3600000]; [comps setMinute:(timeOfDayMillis/60000)%60]; [comps setSecond:(timeOfDayMillis/1000)%60]; - // These fields are local STANDARD time, not UTC: GregorianCalendar adds the - // zone's raw offset to the epoch before calling getOffset. Building the date - // in UTC and asking about that instant is therefore off by the raw offset, - // which around a transition returns the previous offset for its whole span - // -- America/New_York keeps reporting EST for the first five hours of EDT. - // - // Reading them in the device's own zone is wrong for a different reason (it - // shifts by the device offset instead), so the calendar stays on UTC and the - // raw offset is subtracted explicitly. + // The fields are UTC, not local standard time. That is this native's contract + // across every port -- the POSIX implementation resolves them with timegm() + // and the JavaScript and Android ports match -- and TimeApiTest pins it: + // asking about the local-standard instant instead resolves 2020-03-08T01:30 + // EST as 02:30 EDT, jumping the spring transition. Reading them in the + // device's own zone (currentCalendar) is wrong for a different reason: it + // moves the instant by the device offset. NSCalendar* cal = [NSCalendar calendarWithIdentifier:NSCalendarIdentifierGregorian]; [cal setTimeZone:[NSTimeZone timeZoneWithAbbreviation:@"UTC"]]; - NSDate *nominal = [cal dateFromComponents:comps]; - NSInteger rawOffset = [tzone secondsFromGMTForDate:nominal]; - if ([tzone isDaylightSavingTimeForDate:nominal]) { - rawOffset -= (NSInteger)[tzone daylightSavingTimeOffsetForDate:nominal]; - } - NSDate *date = [nominal dateByAddingTimeInterval:-(NSTimeInterval)rawOffset]; - JAVA_INT result = (JAVA_INT)([tzone secondsFromGMTForDate:date] * 1000); + NSDate *date = [cal dateFromComponents:comps]; + JAVA_INT result = [tzone secondsFromGMTForDate:date] * 1000; [comps release]; POOL_END(); return result; diff --git a/vm/ByteCodeTranslator/src/nativeMethods.m b/vm/ByteCodeTranslator/src/nativeMethods.m index 00e7377674b..62d667bf7cc 100644 --- a/vm/ByteCodeTranslator/src/nativeMethods.m +++ b/vm/ByteCodeTranslator/src/nativeMethods.m @@ -2751,21 +2751,16 @@ JAVA_INT java_util_TimeZone_getTimezoneOffset___java_lang_String_int_int_int_int cn1_timezone_offset_ctx ctx; #ifdef _WIN32 { - /* These fields are local STANDARD time, which is what GregorianCalendar - * hands down (it adds the raw offset to the epoch before calling). The - * instant they name is therefore fields - rawOffset, not the fields read - * as UTC: asking about the wrong instant makes the calendar keep the old - * offset for roughly the zone's raw-offset span either side of a - * transition, so America/New_York entering DST reports EST for the first - * five hours of EDT. */ - int rawOffset = 0; - long long nominal = cn1WinUtcMillis(year, month, day, timeOfDayMillis); - if (cn1WinZoneOffsetMillis(buffer, nominal, 0, 0, &rawOffset)) { - int offset = 0; - if (cn1WinZoneOffsetMillis(buffer, nominal - (long long) rawOffset, - &offset, 0, 0)) { - return offset; - } + /* The fields are UTC, matching the POSIX path below (timegm) and every + * other port. Reading them as local standard time instead -- which is + * what java.util.TimeZone.getOffset's signature suggests -- moves the + * instant by the raw offset and jumps DST transitions: TimeApiTest + * resolves 2020-03-08T01:30 EST as 02:30 EDT. The contract this native + * actually has is the one its callers and that test rely on. */ + int offset = 0; + if (cn1WinZoneOffsetMillis(buffer, cn1WinUtcMillis(year, month, day, timeOfDayMillis), + &offset, 0, 0)) { + return offset; } } #endif diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java index 7a26b087128..c6c760d2621 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java @@ -589,6 +589,27 @@ static void spliceWindowedDemoLauncher(Path launcherC) throws IOException { s = s.substring(0, start) + body + s.substring(end); Files.write(launcherC, s.getBytes(StandardCharsets.UTF_8)); } + private static int runGdbAttach(java.io.File out, String pid, boolean viaSudo) throws Exception { + java.util.List cmd = new java.util.ArrayList<>(); + if (viaSudo) { + cmd.add("sudo"); + cmd.add("-n"); + } + cmd.add("gdb"); + cmd.add("-p"); + cmd.add(pid); + cmd.add("-batch"); + cmd.add("-ex"); + cmd.add("set pagination off"); + cmd.add("-ex"); + cmd.add("thread apply all bt"); + Process gdb = new ProcessBuilder(cmd) + .redirectErrorStream(true) + .redirectOutput(ProcessBuilder.Redirect.appendTo(out)) + .start(); + return gdb.waitFor(); + } + /// Dumps every thread's native stack from the still-running suite process. /// /// Best effort by design: gdb may be absent and ptrace may be restricted, and @@ -613,13 +634,13 @@ private static void dumpLiveThreadStacks() { } java.io.File out = new java.io.File( new java.io.File(teePath).getParentFile(), "hang-stacks.txt"); - Process gdb = new ProcessBuilder("gdb", "-p", pid.trim(), "-batch", - "-ex", "set pagination off", - "-ex", "thread apply all bt") - .redirectErrorStream(true) - .redirectOutput(ProcessBuilder.Redirect.appendTo(out)) - .start(); - gdb.waitFor(); + // Plain gdb first; if yama still refuses the attach, retry through sudo, + // which the runner allows passwordless. Either way a refusal must not + // become a second failure. + int rc = runGdbAttach(out, pid.trim(), false); + if (rc != 0) { + runGdbAttach(out, pid.trim(), true); + } System.out.println("CN1SS:HARNESS: wrote live thread stacks for pid " + pid.trim() + " to " + out); } catch (Exception ignore) { From ee689372f1548e6e6e39165f1d4b39b9ca972cd7 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 2 Aug 2026 23:20:34 +0700 Subject: [PATCH 36/91] Mark each stage of a test's dispatch so a dead suite says where it stopped The gdb attach works now that the workflow lowers ptrace_scope, and what it shows rules out every theory I had been working from: at the hang every thread is idle and the event dispatch thread is parked in mainEDTLoop's Object.wait -- the normal "nothing queued" state. The suite is not stuck inside the test and not spinning; the chain that schedules the next step simply stopped. That is hard to square with the dispatch, which catches Throwable and arms the completion poll unconditionally afterwards, so more inference from the same evidence is not going to converge. The log ends on "suite starting test=X" and says nothing about which call failed to come back. So each boundary is now named: prepared, ran, awaiting. One line per test, and the next run says whether prepare() returned, whether runTest() returned, and whether the poll was armed -- which is the fact I have been guessing at for three rounds. Cn1ssDeviceRunner's synthetic lambda set stays identical to master's. Co-Authored-By: Claude Opus 5 (1M context) --- .../hellocodenameone/tests/Cn1ssDeviceRunner.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java index bdd89876bbe..74696b897a2 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java @@ -546,15 +546,24 @@ private void runNextTest(int index) { CN.callSerially(() -> { Cn1ssDeviceRunnerHelper.clearTransportFailure(); log("CN1SS:INFO:suite starting test=" + testName); + // Stage markers. When a suite stops dead the log ends on the + // "starting" line and says nothing about which call did not come + // back -- prepare(), runTest(), or the poll that follows. Naming + // each boundary costs one line per test and turns "stopped in X" + // into "stopped inside X's runTest", which is the difference + // between reading a stack and guessing at one. try { testClass.prepare(); + log("CN1SS:INFO:stage=prepared test=" + testName); testClass.runTest(); + log("CN1SS:INFO:stage=ran test=" + testName); } catch (Throwable t) { log("CN1SS:ERR:suite test=" + testName + " failed=" + t); t.printStackTrace(); logThrowable("runTest:" + testName, t); testClass.fail(String.valueOf(t)); } + log("CN1SS:INFO:stage=awaiting test=" + testName); awaitTestCompletion(index, testClass, testName, System.currentTimeMillis() + testTimeoutMs(testClass)); }); } From 63a2a31ce329a39bbf57cae2cdff53a6393b2ac9 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 3 Aug 2026 00:16:41 +0700 Subject: [PATCH 37/91] Give the Linux suite the time its repaired tests now need The stage markers answered it, and the answer is that nothing is hanging. The cut-off point moves. It was Base64NativePerformanceTest; with the watchdog gone and the earlier tests faster it is now MutableImageReadbackTest, about test 165 of 170. And the marker that never arrives is stage=prepared, whose implementation in AbstractTest is an empty method -- it cannot block. The suite is simply still running when the harness's 40-minute cap expires and kills it, and the log ends wherever the app happened to be. It needs longer because this branch made the tests it repaired do real work instead of failing in milliseconds: CryptoApiTest generates an RSA-2048 key pair, AudioMixerApiTest mixes audio, SurfacesPublishTest rasterizes, BrowserComponentScreenshotTest starts WebKit. On master those threw almost immediately, which is most of why the old budget fit. Honest tests cost wall clock. The harness cap goes to 70 minutes and the job timeout to 130, which keeps the job bounded while leaving room for the five tests still queued when the axe fell. This also retires the "silently DIES mid-run" reading of these runs: with gdb able to attach, every thread is idle and the EDT is parked in mainEDTLoop's Object.wait -- the app is alive and being killed, not crashing. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/linux-build-run.yml | 2 +- .../CleanTargetLinuxIntegrationTest.java | 19 ++++++++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/.github/workflows/linux-build-run.yml b/.github/workflows/linux-build-run.yml index 37a08137f53..993b631d363 100644 --- a/.github/workflows/linux-build-run.yml +++ b/.github/workflows/linux-build-run.yml @@ -121,7 +121,7 @@ jobs: name: build + run suite (${{ matrix.arch }}) needs: prepare-suite runs-on: ${{ matrix.runner }} - timeout-minutes: 90 + timeout-minutes: 130 strategy: fail-fast: false matrix: diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java index c6c760d2621..fc3e2bda1fc 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java @@ -420,7 +420,7 @@ void capturesHelloSuiteOverWebSocketLinux() throws Exception { // Finish once the bulk of screenshots have landed and none has arrived // for a stabilization window (the trailing non-rendering API tests burn - // their per-test timeout after the last image). 40-minute hard cap. + // their per-test timeout after the last image). 70-minute hard cap. int minPngs = 100; // The stability window must outlast the suite's longest legitimate // no-new-screenshot stretch: the ~30 non-rendering API tests between @@ -430,9 +430,22 @@ void capturesHelloSuiteOverWebSocketLinux() throws Exception { // (the suite was force-killed mid-run, gate rc=17). Healthy runs // never wait this out: SUITE:FINISHED breaks the loop first. Only a // genuinely wedged suite pays the longer window, bounded by the - // 40-minute hard cap either way. + // 70-minute hard cap either way. long stableMs = 300_000L; - long deadline = System.currentTimeMillis() + 40L * 60 * 1000; + // 70 minutes, not 40. The suite is not hanging: the stage markers show + // the cut-off point moving forward as earlier tests get faster (it was + // Base64NativePerformanceTest, now MutableImageReadbackTest, ~165 of + // 170), and prepare() is an empty method that cannot block. The app is + // simply still running when the harness gives up and kills it. + // + // It needs the room because the tests this branch repaired now do real + // work instead of failing in milliseconds -- CryptoApiTest generates an + // RSA-2048 key pair, AudioMixerApiTest mixes actual audio, + // SurfacesPublishTest rasterizes, BrowserComponentScreenshotTest starts + // WebKit. That is the suite getting more honest, not slower for no + // reason, and the budget has to cover it. The job timeout above bounds + // this in turn. + long deadline = System.currentTimeMillis() + 70L * 60 * 1000; // Screenshot stabilization is a weak completion signal: DesktopMode, // the VideoIO grid, the VR scene and the 360 panorama all capture // AFTER the non-rendering API tail, so a slow tail trips the window From f53b1bdb3beab214c1a456d751f895a5d9d7debe Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 3 Aug 2026 03:39:53 +0700 Subject: [PATCH 38/91] Line-buffer the generated main's stdout so a killed run's log is not stale Every "the Linux suite hangs in X" reading in this branch has been wrong, and this is why. C streams block-buffer when they are not a tty, so an app logging into a pipe -- which is how CI captures it -- emits in 4KB chunks. When the harness kills a run mid-flight the captured log ends wherever the last chunk happened to flush, thousands of lines behind the process. Every diagnosis made from that tail named the wrong place: Base64NativePerformanceTest, then MutableImageReadbackTest, and the "missing" marker in the latest run is stage=prepared, whose implementation is an empty method that cannot block. The generated main now sets _IOLBF on stdout and stderr, so the log says where the process actually is. One flush per line, in return for diagnostics that mean what they say. This also revises the previous commit's conclusion: raising the budget to 70 minutes did not move the stopping point, so the suite is not merely slow. What it is doing will only be answerable from a log that is not lagging, which is what this provides. Verified through the clean target: the generated main compiles with the setvbuf calls and the probe still passes. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/tools/translator/ByteCodeClass.java | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeClass.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeClass.java index 22ebf24476b..7b5d6ef8c7e 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeClass.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeClass.java @@ -1146,7 +1146,19 @@ public String generateCCode(List allClasses) { clInitMethod = clsName + "_" + m.getMethodName() + "__"; } if(m.isMain()) { - b.append("\nint main(int argc, char *argv[]) {\n initConstantPool();\n"); + b.append("\nint main(int argc, char *argv[]) {\n"); + // Line-buffer stdout/stderr. C streams block-buffer when they are + // not a tty, so everything an app logs into a pipe -- which is + // how CI captures it -- arrives in 4KB chunks. A run that is + // killed mid-flight then shows a log ending thousands of lines + // behind where the process actually was, and every diagnosis + // made from that tail names the wrong place. Three separate + // "the suite hangs in X" readings of the Linux job came from + // exactly this. Costs a flush per line; buys logs that mean + // what they say. + b.append(" setvbuf(stdout, NULL, _IOLBF, 0);\n"); + b.append(" setvbuf(stderr, NULL, _IOLBF, 0);\n"); + b.append(" initConstantPool();\n"); // With the nursery, the main thread allocates and must cooperate with // the concurrent GC's stop-the-world pause (so the GC never scans its // nursery while a minor collection runs). Lightweight threads are the From 7d0ad8cf43ba09733aad6eb55458cb75eb2e18fd Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 3 Aug 2026 03:41:14 +0700 Subject: [PATCH 39/91] Ask Gradle for the stack when Android packaging fails Build Android has failed intermittently across JDK legs with nothing but "A failure occurred while executing PackageAndroidArtifact$IncrementalSplitterRunnable" and no cause, and it passes on a re-run, so there has been nothing to act on. --stacktrace costs nothing on a successful build and prints the actual exception the next time it happens. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/build-android-app.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/build-android-app.sh b/scripts/build-android-app.sh index 3dda2eb77d6..2e95564ee49 100755 --- a/scripts/build-android-app.sh +++ b/scripts/build-android-app.sh @@ -192,7 +192,11 @@ export JAVA_HOME="${JDK_HOME:-$JAVA17_HOME}" yes | "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" "platforms;android-36" "build-tools;36.0.0" >/dev/null 2>&1 || ba_log "Warning: unable to install Android SDK 36 components" yes | "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" --licenses >/dev/null 2>&1 || true fi - ./gradlew --no-daemon assembleDebug + # --stacktrace: packageDebug has failed intermittently on CI reporting only + # "A failure occurred while executing PackageAndroidArtifact$IncrementalSplitterRunnable" + # with no cause, which is not enough to fix anything. The flag costs nothing on + # a successful build and prints the actual exception when it does happen. + ./gradlew --no-daemon --stacktrace assembleDebug ) export JAVA_HOME="$ORIGINAL_JAVA_HOME" From 39fa46bad2e17547ef70eca994af100da390dac0 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 3 Aug 2026 07:40:57 +0700 Subject: [PATCH 40/91] Use _IONBF: the MSVC CRT fail-fasts on a line-buffered request My previous commit made every Windows clean-target binary die on its first instruction. Exit code -1073740791 is 0xC0000409, the MSVC fail-fast: the CRT rejects setvbuf(stream, NULL, _IOLBF, 0) because a buffered mode demands a size of at least 2, and it answers an invalid parameter by killing the process rather than returning non-zero. That took clean-target on both architectures, the Windows capture and the cross-built exe run. _IONBF ignores the size argument, is valid on every CRT, and is what the diagnostics actually want -- unbuffered rather than merely line-buffered. Verified this time against the real thing rather than assuming: the exact two calls compile and link into a PE with clang-cl against the xwin MSVC CRT headers, and the clean target still builds and runs through the probe on this machine. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/tools/translator/ByteCodeClass.java | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeClass.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeClass.java index 7b5d6ef8c7e..629dfd97fd0 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeClass.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeClass.java @@ -1156,8 +1156,15 @@ public String generateCCode(List allClasses) { // "the suite hangs in X" readings of the Linux job came from // exactly this. Costs a flush per line; buys logs that mean // what they say. - b.append(" setvbuf(stdout, NULL, _IOLBF, 0);\n"); - b.append(" setvbuf(stderr, NULL, _IOLBF, 0);\n"); + // _IONBF, not _IOLBF: the MSVC CRT rejects a line-buffered + // request with a NULL buffer and size 0 -- it demands a size of + // at least 2 -- and answers the invalid parameter by fail-fasting + // the process (0xC0000409), so every Windows clean-target binary + // died on its first instruction. _IONBF ignores the size argument + // and is valid on every CRT, and unbuffered is what the + // diagnostics actually want. + b.append(" setvbuf(stdout, NULL, _IONBF, 0);\n"); + b.append(" setvbuf(stderr, NULL, _IONBF, 0);\n"); b.append(" initConstantPool();\n"); // With the nursery, the main thread allocates and must cooperate with // the concurrent GC's stop-the-world pause (so the GC never scans its From a70e121b0231e939dbe4358ec845ed1bbe280174 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 3 Aug 2026 09:05:49 +0700 Subject: [PATCH 41/91] Revert the timeout inflation; my own evidence had already disproved it I raised the harness cap from 40 to 70 minutes and the job timeout from 90 to 130 on the theory that the Linux suite was merely slow. The very next run used 78 minutes and stopped at the same test, which disproved it. I should have reverted then instead of leaving a 130-minute timeout in the workflow. Both are back to 40 and 90. A longer timeout does not fix anything here, it just makes every future run of this job slower to fail and hides how long the suite really takes. The suite not finishing is still unexplained. What changed for real is that the generated main is now unbuffered, so the next Linux log will show where the process actually is rather than a stale chunk -- which is the evidence the budget theory was invented in the absence of. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/linux-build-run.yml | 2 +- .../CleanTargetLinuxIntegrationTest.java | 19 +++---------------- 2 files changed, 4 insertions(+), 17 deletions(-) diff --git a/.github/workflows/linux-build-run.yml b/.github/workflows/linux-build-run.yml index 993b631d363..37a08137f53 100644 --- a/.github/workflows/linux-build-run.yml +++ b/.github/workflows/linux-build-run.yml @@ -121,7 +121,7 @@ jobs: name: build + run suite (${{ matrix.arch }}) needs: prepare-suite runs-on: ${{ matrix.runner }} - timeout-minutes: 130 + timeout-minutes: 90 strategy: fail-fast: false matrix: diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java index fc3e2bda1fc..c6c760d2621 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java @@ -420,7 +420,7 @@ void capturesHelloSuiteOverWebSocketLinux() throws Exception { // Finish once the bulk of screenshots have landed and none has arrived // for a stabilization window (the trailing non-rendering API tests burn - // their per-test timeout after the last image). 70-minute hard cap. + // their per-test timeout after the last image). 40-minute hard cap. int minPngs = 100; // The stability window must outlast the suite's longest legitimate // no-new-screenshot stretch: the ~30 non-rendering API tests between @@ -430,22 +430,9 @@ void capturesHelloSuiteOverWebSocketLinux() throws Exception { // (the suite was force-killed mid-run, gate rc=17). Healthy runs // never wait this out: SUITE:FINISHED breaks the loop first. Only a // genuinely wedged suite pays the longer window, bounded by the - // 70-minute hard cap either way. + // 40-minute hard cap either way. long stableMs = 300_000L; - // 70 minutes, not 40. The suite is not hanging: the stage markers show - // the cut-off point moving forward as earlier tests get faster (it was - // Base64NativePerformanceTest, now MutableImageReadbackTest, ~165 of - // 170), and prepare() is an empty method that cannot block. The app is - // simply still running when the harness gives up and kills it. - // - // It needs the room because the tests this branch repaired now do real - // work instead of failing in milliseconds -- CryptoApiTest generates an - // RSA-2048 key pair, AudioMixerApiTest mixes actual audio, - // SurfacesPublishTest rasterizes, BrowserComponentScreenshotTest starts - // WebKit. That is the suite getting more honest, not slower for no - // reason, and the budget has to cover it. The job timeout above bounds - // this in turn. - long deadline = System.currentTimeMillis() + 70L * 60 * 1000; + long deadline = System.currentTimeMillis() + 40L * 60 * 1000; // Screenshot stabilization is a weak completion signal: DesktopMode, // the VideoIO grid, the VR scene and the 360 panorama all capture // AFTER the non-rendering API tail, so a slow tail trips the window From 7f440ddd3f8d84fca14de61600699b9e88bf9ce3 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 3 Aug 2026 09:10:14 +0700 Subject: [PATCH 42/91] Photograph the Linux stall while it is stalled, not once it has settled Sampling only at the 40-minute cap is why the stacks were uninformative: by then every thread is idle and the EDT is parked in mainEDTLoop, which is what "nothing queued" looks like and says nothing about how it got there. The harness now tracks when the app last produced any output and takes a gdb thread dump after two minutes of silence, up to six times across a run, each sample separated by a timestamped header in hang-stacks.txt. Two minutes is far longer than the gap between any two tests in a healthy run, so a healthy run never triggers it; a stalled one gets photographed repeatedly while it is stuck and the samples show whether it is frozen on one call or crawling through something. Paired with the unbuffered stdout from the previous commits, this is the first setup that can actually answer the question instead of inviting another theory. Kept regardless of what it finds: it costs nothing on a healthy run and this job has a history of failing with no evidence. Co-Authored-By: Claude Opus 5 (1M context) --- .../CleanTargetLinuxIntegrationTest.java | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java index c6c760d2621..50abe2aa3ee 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java @@ -383,6 +383,13 @@ void capturesHelloSuiteOverWebSocketLinux() throws Exception { // announcement from outside the process always works, and "stopped in X" is // the difference between a diagnosable failure and a silent one. final java.util.concurrent.atomic.AtomicReference lastStarted = new java.util.concurrent.atomic.AtomicReference<>(); + // When the app last said anything at all. A stall is only diagnosable + // from the state it stalls IN: by the time the 40-minute cap expires + // the picture has settled and every thread looks idle. Sampling while + // it is stuck is what distinguishes "waiting for a callback that never + // came" from "still working". + final java.util.concurrent.atomic.AtomicLong lastOutputAt = + new java.util.concurrent.atomic.AtomicLong(System.currentTimeMillis()); final Process appF = app; Thread areader = new Thread(() -> { // Tee the app's merged stdout/stderr to CN1_APP_LOG_TEE when @@ -406,6 +413,7 @@ void capturesHelloSuiteOverWebSocketLinux() throws Exception { if (tee != null) { tee.println(line); } if (line.contains("CN1SS:SUITE:FINISHED")) { finished.set(true); } if (line.contains("CN1SS:SUITE:WEDGED")) { wedged.set(line); } + lastOutputAt.set(System.currentTimeMillis()); int startedAt = line.indexOf("CN1SS:INFO:suite starting test="); if (startedAt >= 0) { lastStarted.set(line.substring( @@ -442,6 +450,7 @@ void capturesHelloSuiteOverWebSocketLinux() throws Exception { // suite's own completion marker instead. boolean requireSuite = Boolean.parseBoolean(System.getenv("CN1_REQUIRE_SUITE")); int pngs = 0, lastPngs = -1; + int stallSamples = 0; long lastChange = System.currentTimeMillis(); while (System.currentTimeMillis() < deadline) { if (finished.get()) { break; } @@ -460,6 +469,17 @@ void capturesHelloSuiteOverWebSocketLinux() throws Exception { } pngs = CleanTargetIntegrationTest.countPngFiles(outDir); if (pngs != lastPngs) { lastPngs = pngs; lastChange = System.currentTimeMillis(); } + long silentMs = System.currentTimeMillis() - lastOutputAt.get(); + if (silentMs >= STALL_SAMPLE_AFTER_MS && stallSamples < MAX_STALL_SAMPLES) { + stallSamples++; + System.out.println("CN1SS:HARNESS: no output for " + (silentMs / 1000) + + "s after " + lastStarted.get() + "; sampling thread stacks (" + + stallSamples + "/" + MAX_STALL_SAMPLES + ")"); + dumpLiveThreadStacks(); + // Re-arm so the next sample needs another quiet stretch rather + // than firing on every poll. + lastOutputAt.set(System.currentTimeMillis()); + } if (!requireSuite && pngs >= minPngs && (System.currentTimeMillis() - lastChange) >= stableMs) { break; } Thread.sleep(3000); @@ -589,6 +609,13 @@ static void spliceWindowedDemoLauncher(Path launcherC) throws IOException { s = s.substring(0, start) + body + s.substring(end); Files.write(launcherC, s.getBytes(StandardCharsets.UTF_8)); } + /// How long the suite may say nothing before it is worth photographing, and + /// how many photographs to take. Two minutes is far longer than the gap + /// between any two tests in a healthy run, and a handful of samples spread + /// across the stall shows whether it is stuck or merely crawling. + private static final long STALL_SAMPLE_AFTER_MS = 120_000L; + private static final int MAX_STALL_SAMPLES = 6; + private static int runGdbAttach(java.io.File out, String pid, boolean viaSudo) throws Exception { java.util.List cmd = new java.util.ArrayList<>(); if (viaSudo) { From af9944c3752d2695f8050ec36fc81936ed5eeff2 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 3 Aug 2026 09:10:45 +0700 Subject: [PATCH 43/91] Separate each stall sample with a timestamped header Six dumps appended to one file are unreadable without knowing where each begins, and the timestamps are what show whether the process moved between samples. Co-Authored-By: Claude Opus 5 (1M context) --- .../tools/translator/CleanTargetLinuxIntegrationTest.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java index 50abe2aa3ee..83f4a49bb8d 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java @@ -661,6 +661,11 @@ private static void dumpLiveThreadStacks() { } java.io.File out = new java.io.File( new java.io.File(teePath).getParentFile(), "hang-stacks.txt"); + try (java.io.PrintWriter header = new java.io.PrintWriter( + new java.io.FileWriter(out, true), true)) { + header.println("===== sample at " + new java.util.Date() + + " (pid " + pid.trim() + ") ====="); + } // Plain gdb first; if yama still refuses the attach, retry through sudo, // which the runner allows passwordless. Either way a refusal must not // become a second failure. From 1092abd0bff8d56ebc01e5dc1ae64ad0a6b9e137 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:21:47 +0700 Subject: [PATCH 44/91] Address the review round: OAEP key sizes, DER strictness, verify errors, MCP listeners Windows OAEP was capped at about 8456-bit keys The mask was a fixed 1024-byte array, so a larger key was refused as though the block did not fit it. KeyGenerator.rsa() accepts every byte-aligned size from 1024 bits up and callers can import larger ones, so keys that work on every other port failed here. The mask is now allocated from the modulus and freed on each exit. Checked under AddressSanitizer at 8456 and 12288 bits -- both previously refused, both now round-trip, no leak on any path. Linux accepted DER keys with trailing data d2i_* stops at the end of the first object it recognises, so a buffer holding a valid key followed by extra bytes parsed happily. JavaSE and Android reject that through KeyFactory, so the same bytes validated on one port and not another. Both parsers now require the whole input to be the key. verify() could not tell a bad signature from a bad configuration An unsupported algorithm, malformed key DER or family mismatch came back as plain false, which reads as "this signature was tampered with". JavaSE and Android throw, and Signature.verify turns that into a CryptoException. Both ports gained clearCryptoError() so the slot can be emptied before the call, which is what makes a recorded failure attributable to it, and a configuration error now raises instead of returning false. A retired MCP listener could serve a replacement transport close() released the process-wide registration before stopping the listener, so a connection already accepted could resolve `active` to a transport that took the slot afterwards and hand it a client that dialled the old port. The listener is stopped first now, and attach() refuses streams once the transport is closed rather than wiring them to a dead session. Also the no-video-encoder-on- skip reason, scoped to the Apple ports like its siblings. core-unittests: 4678 tests, no failures, SpotBugs zero findings. Windows cross-compile links. Both port sources compile. Co-Authored-By: Claude Opus 5 (1M context) --- .../mcp/MCPLoopbackSocketTransport.java | 34 ++++++++++---- .../nativeSources/cn1_linux_crypto.c | 29 ++++++++++++ .../impl/linux/LinuxImplementation.java | 17 ++++++- .../com/codename1/impl/linux/LinuxNative.java | 4 ++ .../nativeSources/cn1_windows_crypto.c | 46 +++++++++++++++++-- .../impl/windows/WindowsImplementation.java | 17 ++++++- .../codename1/impl/windows/WindowsNative.java | 4 ++ docs/website/data/port_status_supplement.json | 10 ++++ 8 files changed, 147 insertions(+), 14 deletions(-) diff --git a/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java b/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java index e09425778ff..e393f60522f 100644 --- a/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java +++ b/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java @@ -158,9 +158,10 @@ public void open() throws IOException { } if (closedWhileBinding) { // No null check on `bound`: the only way past the try above is with a - // listener in hand, and SpotBugs flags the redundant test. - clearActiveIfOurs(); + // listener in hand, and SpotBugs flags the redundant test. Stop before + // releasing the slot, for the reason close() gives. bound.stop(); + clearActiveIfOurs(); throw new IOException("This MCP socket transport was closed before it began listening"); } } @@ -180,10 +181,21 @@ void attach(InputStream is, OutputStream os) { InputStream previousIn; // NOPMD closed below, deliberately outside the lock OutputStream previousOut; // NOPMD closed below, outside the lock synchronized (lock) { - previousIn = in; - previousOut = out; - in = is; - out = os; + if (closed) { + // A listener retired by close() can still have a connection in + // flight. Adopting it would serve a client that dialled a port + // this transport no longer owns, so hand the streams back to be + // closed rather than wiring them to a dead session. + previousIn = is; + previousOut = os; + is = null; + os = null; + } else { + previousIn = in; + previousOut = out; + in = is; + out = os; + } lock.notifyAll(); } // Dropping the previous client means closing its streams, not just forgetting @@ -400,11 +412,17 @@ public void close() { out = null; lock.notifyAll(); } - // Only if it is still ours: a transport opened after this one keeps its slot. - clearActiveIfOurs(); + // Stop the listener BEFORE releasing the registration, not after. A + // connection this listener already accepted resolves `active` inside its + // callback, so releasing first leaves a window where a replacement + // transport has taken the slot and the retired listener hands it a client + // that dialled the old port. Stopping first means any in-flight callback + // still finds this transport, and attach() below refuses it because we + // are closed. if (l != null) { l.stop(); } + clearActiveIfOurs(); // Closing the output as well as the input: forgetting the field is not enough, // because a writer that already captured it would go on writing to a session that // has ended, and the socket would stay open until the connection callback unwound. diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c b/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c index 942fc457c84..b245b70375c 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c @@ -68,6 +68,16 @@ static void cn1CryptoFail(const char* what) { ERR_clear_error(); } +/* Clears the per-thread message so a caller can tell whether the operation it + * just ran recorded one. verifyData answers false for an invalid signature -- + * an ordinary result -- and also for an unusable algorithm or key, which is a + * configuration error the caller deserves to see. The only difference between + * them is whether an error was recorded, and that is only readable if the slot + * was empty beforehand. */ +JAVA_VOID com_codename1_impl_linux_LinuxNative_clearCryptoError__(CODENAME_ONE_THREAD_STATE) { + cn1CryptoError[0] = 0; +} + JAVA_OBJECT com_codename1_impl_linux_LinuxNative_lastCryptoError___R_java_lang_String(CODENAME_ONE_THREAD_STATE) { return newStringFromCString(threadStateData, cn1CryptoError[0] ? cn1CryptoError : "unknown crypto error"); @@ -269,11 +279,23 @@ JAVA_OBJECT com_codename1_impl_linux_LinuxNative_aesCrypt___java_lang_String_boo /* ------------------------------------------------------------ keys */ +/* d2i_* stops at the end of the first object it recognises and reports how far + * it got, so a buffer holding a valid key followed by extra bytes -- a + * mis-sliced concatenation, say -- parses happily and the trailing data is + * never seen. JavaSE and Android refuse that through KeyFactory, so accepting + * it here would mean the same bytes validate on one port and not another. The + * whole input has to be the key. */ static EVP_PKEY* cn1PublicKey(const unsigned char* der, int length) { const unsigned char* cursor = der; EVP_PKEY* key = d2i_PUBKEY(0, &cursor, (long) length); if (key == 0) { cn1CryptoFail("public key is not X.509 SubjectPublicKeyInfo DER"); + return 0; + } + if (cursor != der + length) { + cn1CryptoFail("public key has trailing data after the SubjectPublicKeyInfo"); + EVP_PKEY_free(key); + return 0; } return key; } @@ -293,6 +315,13 @@ static EVP_PKEY* cn1PrivateKey(const unsigned char* der, int length) { } if (key == 0) { cn1CryptoFail("private key is not PKCS#8 DER"); + return 0; + } + /* Same rule as the public side: the whole buffer has to be the key. */ + if (cursor != der + length) { + cn1CryptoFail("private key has trailing data after the PKCS#8 structure"); + EVP_PKEY_free(key); + return 0; } return key; } diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java index f83c6c3935c..ff7d4384d78 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java @@ -2813,7 +2813,22 @@ public byte[] cryptoSign(String algorithm, String keyAlgorithm, byte[] privateKe public boolean cryptoVerify(String algorithm, String keyAlgorithm, byte[] publicKeyX509, byte[] data, byte[] signature) { checkKeyFamily(algorithm, keyAlgorithm); - return LinuxNative.verifyData(algorithm, publicKeyX509, data, signature); + // An invalid signature and an unusable algorithm or key both come back + // as false from the native. Only the second is a configuration error, + // and JavaSE and Android raise it -- Signature.verify turns the throw + // into a CryptoException -- so answering plain false here would let a + // mistyped algorithm or malformed key read as "someone tampered with + // this". Clearing the slot first is what makes the two distinguishable. + LinuxNative.clearCryptoError(); + boolean verified = LinuxNative.verifyData(algorithm, publicKeyX509, data, signature); + if (!verified) { + String failure = LinuxNative.lastCryptoError(); + if (failure != null && failure.length() > 0 + && !"unknown crypto error".equals(failure)) { + throw new RuntimeException("verify failed: " + failure); + } + } + return verified; } /// The portable contract pairs an algorithm with a key of its own family, diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java index 641dd1c4a9c..7fa0f44a9da 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java @@ -422,6 +422,10 @@ public static native boolean verifyData(String algorithm, byte[] publicKeyX509, /** Why the most recent crypto call failed, for the CryptoException message. */ public static native String lastCryptoError(); + /// Empties the last-error slot so a following call's failure can be told + /// apart from a stale message. + public static native void clearCryptoError(); + public static native int fileRead(long handle, byte[] buffer, int offset, int length); public static native int fileWrite(long handle, byte[] buffer, int offset, int length); diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c index 95d6fa58f6c..c8910831e2a 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c +++ b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c @@ -74,6 +74,13 @@ static void cn1CryptoFailLast(const char* what) { cn1CryptoFail(what, (NTSTATUS) GetLastError()); } +/* Clears the per-thread message; see the Linux port's note. verifyData reports + * an invalid signature and an unusable algorithm or key the same way, and only + * a cleared slot makes the difference readable. */ +JAVA_VOID com_codename1_impl_windows_WindowsNative_clearCryptoError__(CODENAME_ONE_THREAD_STATE) { + cn1WinCryptoError[0] = 0; +} + JAVA_OBJECT com_codename1_impl_windows_WindowsNative_lastCryptoError___R_java_lang_String(CODENAME_ONE_THREAD_STATE) { return newStringFromCString(threadStateData, cn1WinCryptoError[0] ? cn1WinCryptoError : "unknown crypto error"); @@ -543,22 +550,34 @@ static int cn1OaepEncode(LPCWSTR labelDigest, LPCWSTR maskDigest, const unsigned int hashLength = cn1DigestLength(labelDigest); int dbLength = blockLength - hashLength - 1; unsigned char seed[64]; - unsigned char mask[1024]; + unsigned char* mask; int i; /* An OAEP block cannot be shorter than 2*hLen+2. A 512-bit key leaves a DB * shorter than the label hash itself, and the hash would then be written * and compared past the end of the block. */ - if (blockLength < 2 * hashLength + 2 || dbLength > (int) sizeof(mask)) { + if (blockLength < 2 * hashLength + 2) { cn1CryptoFail("RSA-OAEP block does not fit the key", 0); return 0; } + /* Sized from the modulus, not a fixed array. A 1KB mask capped OAEP at + * about 8456-bit keys, and KeyGenerator.rsa() accepts every byte-aligned + * size from 1024 bits up while callers can import larger ones still -- so + * a key that works on every other port was refused here as though the + * block did not fit it. */ + mask = (unsigned char*) malloc((size_t) dbLength); + if (mask == 0) { + cn1CryptoFail("out of memory", 0); + return 0; + } if (messageLength > dbLength - hashLength - 1) { cn1CryptoFail("RSA-OAEP message is too long for the key", 0); + free(mask); return 0; } memset(block, 0, (size_t) blockLength); /* DB = lHash || PS || 0x01 || M, with an empty label. */ if (!cn1Digest(labelDigest, (const unsigned char*) "", 0, block + 1 + hashLength, hashLength)) { + free(mask); return 0; } block[blockLength - messageLength - 1] = 0x01; @@ -568,20 +587,24 @@ static int cn1OaepEncode(LPCWSTR labelDigest, LPCWSTR maskDigest, const unsigned if (BCryptGenRandom(NULL, seed, (ULONG) hashLength, BCRYPT_USE_SYSTEM_PREFERRED_RNG) != STATUS_SUCCESS) { cn1CryptoFail("RSA-OAEP seed", 0); + free(mask); return 0; } if (!cn1Mgf1(maskDigest, seed, hashLength, mask, dbLength)) { + free(mask); return 0; } for (i = 0; i < dbLength; i++) { block[1 + hashLength + i] ^= mask[i]; } if (!cn1Mgf1(maskDigest, block + 1 + hashLength, dbLength, mask, hashLength)) { + free(mask); return 0; } for (i = 0; i < hashLength; i++) { block[1 + i] = (unsigned char) (seed[i] ^ mask[i]); } + free(mask); return 1; } @@ -609,7 +632,7 @@ static int cn1OaepDecode(LPCWSTR labelDigest, LPCWSTR maskDigest, unsigned char* int blockLength, unsigned char* message, int* messageLength) { int hashLength = cn1DigestLength(labelDigest); int dbLength = blockLength - hashLength - 1; - unsigned char mask[1024]; + unsigned char* mask; unsigned char labelHash[64]; unsigned char seed[64]; int i; @@ -619,25 +642,38 @@ static int cn1OaepDecode(LPCWSTR labelDigest, LPCWSTR maskDigest, unsigned char* /* An OAEP block cannot be shorter than 2*hLen+2. A 512-bit key leaves a DB * shorter than the label hash itself, and the hash would then be written * and compared past the end of the block. */ - if (blockLength < 2 * hashLength + 2 || dbLength > (int) sizeof(mask)) { + if (blockLength < 2 * hashLength + 2) { cn1CryptoFail("RSA-OAEP block does not fit the key", 0); return 0; } + /* Sized from the modulus, not a fixed array. A 1KB mask capped OAEP at + * about 8456-bit keys, and KeyGenerator.rsa() accepts every byte-aligned + * size from 1024 bits up while callers can import larger ones still -- so + * a key that works on every other port was refused here as though the + * block did not fit it. */ + mask = (unsigned char*) malloc((size_t) dbLength); + if (mask == 0) { + cn1CryptoFail("out of memory", 0); + return 0; + } /* The leading byte must be zero; fold it in rather than returning here. */ bad |= (unsigned int) block[0]; if (!cn1Mgf1(maskDigest, block + 1 + hashLength, dbLength, mask, hashLength)) { + free(mask); return 0; } for (i = 0; i < hashLength; i++) { seed[i] = (unsigned char) (block[1 + i] ^ mask[i]); } if (!cn1Mgf1(maskDigest, seed, hashLength, mask, dbLength)) { + free(mask); return 0; } for (i = 0; i < dbLength; i++) { block[1 + hashLength + i] ^= mask[i]; } if (!cn1Digest(labelDigest, (const unsigned char*) "", 0, labelHash, hashLength)) { + free(mask); return 0; } for (i = 0; i < hashLength; i++) { @@ -658,12 +694,14 @@ static int cn1OaepDecode(LPCWSTR labelDigest, LPCWSTR maskDigest, unsigned char* bad |= ~seenDelimiter; /* no delimiter anywhere in the block */ if (bad != 0) { cn1CryptoFail("RSA-OAEP decryption failed", 0); + free(mask); return 0; } *messageLength = blockLength - (int) messageStart; if (*messageLength > 0) { memcpy(message, block + messageStart, (size_t) *messageLength); } + free(mask); return 1; } diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java index 574e91aff89..d2e0e8743f9 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java @@ -2821,7 +2821,22 @@ public byte[] cryptoSign(String algorithm, String keyAlgorithm, byte[] privateKe public boolean cryptoVerify(String algorithm, String keyAlgorithm, byte[] publicKeyX509, byte[] data, byte[] signature) { checkKeyFamily(algorithm, keyAlgorithm); - return WindowsNative.verifyData(algorithm, publicKeyX509, data, signature); + // An invalid signature and an unusable algorithm or key both come back + // as false from the native. Only the second is a configuration error, + // and JavaSE and Android raise it -- Signature.verify turns the throw + // into a CryptoException -- so answering plain false here would let a + // mistyped algorithm or malformed key read as "someone tampered with + // this". Clearing the slot first is what makes the two distinguishable. + WindowsNative.clearCryptoError(); + boolean verified = WindowsNative.verifyData(algorithm, publicKeyX509, data, signature); + if (!verified) { + String failure = WindowsNative.lastCryptoError(); + if (failure != null && failure.length() > 0 + && !"unknown crypto error".equals(failure)) { + throw new RuntimeException("verify failed: " + failure); + } + } + return verified; } /// The portable contract pairs an algorithm with a key of its own family, diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java index 5843a0eded8..b19cb9d3b66 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java @@ -430,6 +430,10 @@ public static native boolean verifyData(String algorithm, byte[] publicKeyX509, /** Why the most recent crypto call failed, for the CryptoException message. */ public static native String lastCryptoError(); + /// Empties the last-error slot so a following call's failure can be told + /// apart from a stale message. + public static native void clearCryptoError(); + public static native int fileRead(long handle, byte[] buffer, int offset, int length); public static native int fileWrite(long handle, byte[] buffer, int offset, int length); diff --git a/docs/website/data/port_status_supplement.json b/docs/website/data/port_status_supplement.json index ef0bc5bfe35..ab58385ae49 100644 --- a/docs/website/data/port_status_supplement.json +++ b/docs/website/data/port_status_supplement.json @@ -41,6 +41,16 @@ "watchos" ] }, + { + "prefix": "no-video-encoder-on-", + "ports": [ + "ios-gl", + "ios-metal", + "mac-native", + "tvos", + "watchos" + ] + }, { "prefix": "encode-write-failed-on-", "ports": [ From 2d07450a9d6e26f7f05e8a7d2a7b4371b6b8e5ba Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:50:39 +0700 Subject: [PATCH 45/91] Run every GtkClipboard call on the GTK main thread The arm64 Linux suite stopped inside ClipboardRoundTripTest. With stdout now unbuffered the stage markers are finally trustworthy: "stage=prepared" was the last line and "stage=ran" never arrived, so runTest() itself was blocked rather than the log being behind. The gdb sample taken while it was stalled names the frame outright: gtk_clipboard_wait_for_text () LinuxNative_clipboardGetText (cn1_linux_services.c:176) LinuxImplementation_getPasteDataFromClipboard ClipboardRoundTripTest_paste -> roundTripFile -> runTest Display_edtLoopImpl -> mainEDTLoop gtk_clipboard_wait_for_text / _image / _uris and gtk_clipboard_store all pump a nested main loop until the selection owner answers. Pumping the default GMainContext from the EDT while the GTK thread owns it leaves the caller blocked in g_main_context_wait() for an acquire that only completes if the GTK thread happens to release the context -- so the EDT wedges whenever the timing lines up, which is why this presented as an intermittent hang rather than a reliable one. Every other GTK-touching unit in this port (browser, peer, print, notify, file dialog, a11y, widgets) already marshals through cn1LinuxRunOnMainAndWait, and this file's own header comment claims clipboard does too. It did not. It does now: the GTK work moved into *OnMain helpers over plain C structs and every JAVA_OBJECT conversion stays on the calling thread, matching the file dialog. All six natives are converted, not just the one that happened to hang -- the two setters block in gtk_clipboard_store for the same reason. Co-Authored-By: Claude Opus 5 (1M context) --- .../nativeSources/cn1_linux_services.c | 176 ++++++++++++------ 1 file changed, 120 insertions(+), 56 deletions(-) diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_services.c b/Ports/LinuxPort/nativeSources/cn1_linux_services.c index d0ea40baac4..285ee571980 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_services.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_services.c @@ -164,39 +164,65 @@ static int cn1LoadGeoclue(void) { /* ----------------------------------------------------------- clipboard */ -JAVA_VOID com_codename1_impl_linux_LinuxNative_clipboardSetText___java_lang_String(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT text) { - const char* t = text == JAVA_NULL ? "" : stringToUTF8(threadStateData, text); +/* Every GtkClipboard call below runs on the GTK main thread via + * cn1LinuxRunOnMainAndWait, never inline on the calling (EDT) thread. + * + * This is not defensive style, it is required: the retrieval calls + * (gtk_clipboard_wait_for_text / _image / _uris) and gtk_clipboard_store all + * pump a nested main loop until the selection owner answers. Pumping the + * default GMainContext from a second thread while the GTK thread owns it makes + * the caller block in g_main_context_wait() for an acquire that only completes + * when the GTK thread happens to release the context -- so the EDT wedges for + * good whenever the timing lines up. It usually does not, which is exactly why + * this presented as an intermittent suite hang (the EDT stack was parked in + * gtk_clipboard_wait_for_text under ClipboardRoundTripTest). + * + * The GTK work therefore happens in the *OnMain helpers over plain C structs; + * every JAVA_OBJECT conversion stays on the calling thread, matching the file + * dialog / notification pattern used elsewhere in this file. */ + +typedef struct { const char* text; } CN1ClipSetText; + +static void cn1ClipSetTextOnMain(void* p) { + CN1ClipSetText* r = (CN1ClipSetText*) p; GtkClipboard* cb = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD); - gtk_clipboard_set_text(cb, t, -1); + gtk_clipboard_set_text(cb, r->text, -1); gtk_clipboard_store(cb); } +JAVA_VOID com_codename1_impl_linux_LinuxNative_clipboardSetText___java_lang_String(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT text) { + CN1ClipSetText r; + r.text = text == JAVA_NULL ? "" : stringToUTF8(threadStateData, text); + cn1LinuxRunOnMainAndWait(cn1ClipSetTextOnMain, &r); +} + +typedef struct { gchar* text; } CN1ClipGetText; + +static void cn1ClipGetTextOnMain(void* p) { + CN1ClipGetText* r = (CN1ClipGetText*) p; + r->text = gtk_clipboard_wait_for_text(gtk_clipboard_get(GDK_SELECTION_CLIPBOARD)); +} + JAVA_OBJECT com_codename1_impl_linux_LinuxNative_clipboardGetText___R_java_lang_String(CODENAME_ONE_THREAD_STATE) { - GtkClipboard* cb = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD); - gchar* text = gtk_clipboard_wait_for_text(cb); - JAVA_OBJECT result = text ? newStringFromCString(threadStateData, text) : JAVA_NULL; - if (text) { - g_free(text); + CN1ClipGetText r; + JAVA_OBJECT result; + r.text = NULL; + cn1LinuxRunOnMainAndWait(cn1ClipGetTextOnMain, &r); + result = r.text ? newStringFromCString(threadStateData, r.text) : JAVA_NULL; + if (r.text) { + g_free(r.text); } return result; } -JAVA_VOID com_codename1_impl_linux_LinuxNative_clipboardSetImage___byte_1ARRAY(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT png) { - unsigned char* bytes; - int len; - GdkPixbufLoader* loader; +typedef struct { const unsigned char* bytes; int len; } CN1ClipSetImage; + +static void cn1ClipSetImageOnMain(void* p) { + CN1ClipSetImage* r = (CN1ClipSetImage*) p; + GdkPixbufLoader* loader = gdk_pixbuf_loader_new(); GdkPixbuf* pix; GtkClipboard* cb; - if (png == JAVA_NULL) { - return; - } - bytes = (unsigned char*) (*(JAVA_ARRAY) png).data; - len = (int) (*(JAVA_ARRAY) png).length; - if (len <= 0) { - return; - } - loader = gdk_pixbuf_loader_new(); - if (!gdk_pixbuf_loader_write(loader, bytes, (gsize) len, NULL)) { + if (!gdk_pixbuf_loader_write(loader, r->bytes, (gsize) r->len, NULL)) { gdk_pixbuf_loader_close(loader, NULL); g_object_unref(loader); return; @@ -212,25 +238,48 @@ JAVA_VOID com_codename1_impl_linux_LinuxNative_clipboardSetImage___byte_1ARRAY(C g_object_unref(loader); } -JAVA_OBJECT com_codename1_impl_linux_LinuxNative_clipboardGetImage___R_byte_1ARRAY(CODENAME_ONE_THREAD_STATE) { - GtkClipboard* cb = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD); - GdkPixbuf* pix = gtk_clipboard_wait_for_image(cb); - gchar* buf = NULL; - gsize len = 0; - JAVA_OBJECT result; +JAVA_VOID com_codename1_impl_linux_LinuxNative_clipboardSetImage___byte_1ARRAY(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT png) { + CN1ClipSetImage r; + if (png == JAVA_NULL) { + return; + } + /* The array stays reachable from this (blocked) frame for the whole call. */ + r.bytes = (const unsigned char*) (*(JAVA_ARRAY) png).data; + r.len = (int) (*(JAVA_ARRAY) png).length; + if (r.len <= 0) { + return; + } + cn1LinuxRunOnMainAndWait(cn1ClipSetImageOnMain, &r); +} + +typedef struct { gchar* buf; gsize len; } CN1ClipGetImage; + +static void cn1ClipGetImageOnMain(void* p) { + CN1ClipGetImage* r = (CN1ClipGetImage*) p; + GdkPixbuf* pix = gtk_clipboard_wait_for_image(gtk_clipboard_get(GDK_SELECTION_CLIPBOARD)); if (!pix) { - return JAVA_NULL; + return; } - if (!gdk_pixbuf_save_to_buffer(pix, &buf, &len, "png", NULL, NULL) || buf == NULL) { - if (buf) { - g_free(buf); + if (!gdk_pixbuf_save_to_buffer(pix, &r->buf, &r->len, "png", NULL, NULL) || r->buf == NULL) { + if (r->buf) { + g_free(r->buf); + r->buf = NULL; } - g_object_unref(pix); - return JAVA_NULL; } - result = cn1LinuxNewByteArray(threadStateData, buf, (int) len); - g_free(buf); g_object_unref(pix); +} + +JAVA_OBJECT com_codename1_impl_linux_LinuxNative_clipboardGetImage___R_byte_1ARRAY(CODENAME_ONE_THREAD_STATE) { + CN1ClipGetImage r; + JAVA_OBJECT result; + r.buf = NULL; + r.len = 0; + cn1LinuxRunOnMainAndWait(cn1ClipGetImageOnMain, &r); + if (!r.buf) { + return JAVA_NULL; + } + result = cn1LinuxNewByteArray(threadStateData, r.buf, (int) r.len); + g_free(r.buf); return result; } @@ -258,14 +307,32 @@ static void cn1UriListClear(GtkClipboard* cb, gpointer userData) { } } +/* Takes ownership of the CN1UriListData: either the clipboard holds it (and the + * clear-func frees it later) or it is released here. */ +static void cn1ClipSetFilesOnMain(void* p) { + CN1UriListData* data = (CN1UriListData*) p; + GtkClipboard* cb = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD); + GtkTargetList* tl = gtk_target_list_new(NULL, 0); + GtkTargetEntry* targets; + gint nTargets = 0; + gtk_target_list_add_uri_targets(tl, 0); + targets = gtk_target_table_new_from_list(tl, &nTargets); + if (!gtk_clipboard_set_with_data(cb, targets, nTargets, cn1UriListGet, cn1UriListClear, data)) { + cn1UriListClear(cb, data); + } else { + gtk_clipboard_set_can_store(cb, targets, nTargets); + gtk_clipboard_store(cb); + } + if (targets) { + gtk_target_table_free(targets, nTargets); + } + gtk_target_list_unref(tl); +} + JAVA_VOID com_codename1_impl_linux_LinuxNative_clipboardSetFiles___java_lang_String_1ARRAY(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT paths) { int n, i; JAVA_OBJECT* elements; CN1UriListData* data; - GtkClipboard* cb; - GtkTargetList* tl; - GtkTargetEntry* targets; - gint nTargets = 0; if (paths == JAVA_NULL) { return; } @@ -287,29 +354,26 @@ JAVA_VOID com_codename1_impl_linux_LinuxNative_clipboardSetFiles___java_lang_Str } data->uris[n] = NULL; - cb = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD); - tl = gtk_target_list_new(NULL, 0); - gtk_target_list_add_uri_targets(tl, 0); - targets = gtk_target_table_new_from_list(tl, &nTargets); - if (!gtk_clipboard_set_with_data(cb, targets, nTargets, cn1UriListGet, cn1UriListClear, data)) { - cn1UriListClear(cb, data); - } else { - gtk_clipboard_set_can_store(cb, targets, nTargets); - gtk_clipboard_store(cb); - } - if (targets) { - gtk_target_table_free(targets, nTargets); - } - gtk_target_list_unref(tl); + cn1LinuxRunOnMainAndWait(cn1ClipSetFilesOnMain, data); +} + +typedef struct { gchar** uris; } CN1ClipGetFiles; + +static void cn1ClipGetFilesOnMain(void* p) { + CN1ClipGetFiles* r = (CN1ClipGetFiles*) p; + r->uris = gtk_clipboard_wait_for_uris(gtk_clipboard_get(GDK_SELECTION_CLIPBOARD)); } JAVA_OBJECT com_codename1_impl_linux_LinuxNative_clipboardGetFiles___R_java_lang_String_1ARRAY(CODENAME_ONE_THREAD_STATE) { - GtkClipboard* cb = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD); - gchar** uris = gtk_clipboard_wait_for_uris(cb); + CN1ClipGetFiles r; + gchar** uris; int n = 0; int i; JAVA_OBJECT arr; JAVA_OBJECT* elements; + r.uris = NULL; + cn1LinuxRunOnMainAndWait(cn1ClipGetFilesOnMain, &r); + uris = r.uris; if (!uris) { return JAVA_NULL; } From c3d1c988759c201e8e1ea85a679aced21c892dc5 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:52:49 +0700 Subject: [PATCH 46/91] Keep publishing the Linux port status when the suite fails With CN1_REQUIRE_SUITE armed, a leg that misses CN1SS:SUITE:FINISHED now fails build-run -- and a job whose `if:` never mentions always()/cancelled() is skipped when a dependency fails. So the one run that most needs reporting was the one that silently produced none: cn1ss_process_and_report and "Upload Linux port status" never executed, and the public table kept serving the previous green report. That is the same failure-masked-as-pass shape the strict gate exists to remove. Gate the job on !cancelled() instead. Normalization now runs after a failed leg and publishes the real fail / not-run counts; build-run stays red, so the workflow still fails. The artifact downloads are already continue-on-error and the upload step is already if: always(), so a partial capture reports what it has. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/linux-build-run.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/linux-build-run.yml b/.github/workflows/linux-build-run.yml index 37a08137f53..abc047fd5c0 100644 --- a/.github/workflows/linux-build-run.yml +++ b/.github/workflows/linux-build-run.yml @@ -381,7 +381,16 @@ jobs: compare-comment: name: screenshot-comment needs: build-run - if: github.event_name == 'pull_request' || github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + # !cancelled() rather than a plain event check: without it GitHub skips this + # job whenever a build-run leg fails, and skipping it is precisely the wrong + # response to a failing suite. Normalization is what publishes the fail / + # not-run counts, so being skipped leaves the public table showing the last + # green report -- a failure masked as a pass. The workflow still goes red + # because build-run itself failed; this only keeps the reporting honest. + if: >- + !cancelled() && + (github.event_name == 'pull_request' || github.event_name == 'push' || + github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') runs-on: ubuntu-latest permissions: contents: read From 6ff6b69742b8632b0a152119ced124281935abcf Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:01:12 +0700 Subject: [PATCH 47/91] Serialise the iOS path-renderer's process-wide setup build-mac-native failed on SVGStaticScreenshotTest. Chasing it turned up a pre-existing race rather than anything this branch changed. Evidence it is not from this PR: master fails the same way (run 30742491642 SVGStatic, run 30732882535 VectorMapShapesScreenshotTest -- both vector tests), roughly one run in five. And master's failing capture is pixel-identical to this branch's: 0 differing pixels between the two, each exactly 534 pixels from the golden. Two stable outcomes, not drifting noise. Where those 534 pixels sit is the tell. They are only on antialiased edges -- the outer rim of path_arrow.svg's stroke and the wave curves -- while every saturated interior pixel, every glyph and the four non-stroked SVGs match exactly. Edge samples are the ones that index alphaMap, the coverage->alpha table Renderer_setup builds into process-wide globals. The guard raised its flag before doing the work: if (!rendererIsSetup) { rendererIsSetup = YES; Renderer_setup(1,1); } so a second thread entering mid-setup saw the flag already up, skipped initialisation and rasterised against half-built globals. setMaxAlpha made it worse by assigning alphaMap straight from malloc and publishing sMaxAlpha first, so a reader could index an unfilled table -- or a NULL one. Demonstrated against the product source, not by inspection: a harness that #includes Renderer.c and polls alphaMap from a second thread while Renderer_setup runs reports "reader saw a partially filled alphaMap: YES" on every run beforehand, and "no" on six consecutive runs after. (The harness widens the fill loop to make the window observable; the ordering is identical at the shipped Renderer_setup(1,1).) Fix both ends: cn1EnsureRendererSetup takes a mutex and raises the flag only once setup returns, so a racing caller blocks until the globals are whole; setMaxAlpha fills the table before publishing it, and publishes alphaMap before sMaxAlpha. This is the strongest candidate for the intermittent vector-screenshot mismatch and it matches the symptom exactly, but I have not reproduced the screenshot failure itself locally -- CI frequency over subsequent runs is what will confirm it. Co-Authored-By: Claude Opus 5 (1M context) --- Ports/iOSPort/nativeSources/IOSNative.m | 34 ++++++++++++++++++------- Ports/iOSPort/nativeSources/Renderer.c | 18 ++++++++++--- 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index 05daf0dbab2..03a2be0bbf4 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -27,6 +27,7 @@ // end Pisces imports #include "xmlvm.h" #include "java_lang_String.h" +#include #import "CN1ES2compat.h" #if TARGET_OS_WATCH #import "CN1CGGraphics.h" @@ -11147,12 +11148,31 @@ JAVA_LONG com_codename1_impl_ios_IOSNative_nativePathStrokerGetConsumer___long(J static BOOL rendererIsSetup = NO; -JAVA_LONG com_codename1_impl_ios_IOSNative_nativePathRendererCreate___int_int_int_int_int(JAVA_OBJECT instanceObject, JAVA_INT pix_boundsX, JAVA_INT pix_boundsY, JAVA_INT pix_boundsWidth, JAVA_INT pix_boundsHeight, JAVA_INT windingRule) -{ - if ( !rendererIsSetup ){ +static pthread_mutex_t rendererSetupLock = PTHREAD_MUTEX_INITIALIZER; + +// Renderer_setup installs process-wide globals (the subpixel constants and the +// coverage->alpha table alphaMap) that every subsequent rasterisation reads. +// The original guard set rendererIsSetup *before* calling it, so a second +// thread entering here mid-setup saw the flag already raised, skipped the +// initialisation and went straight to rasterising against half-built globals: +// an alphaMap that was allocated but not yet filled, or still NULL. That +// corrupts exactly the antialiased edge samples of a shape while leaving its +// saturated interior correct. +// +// Serialise instead, and raise the flag only once setup has completed, so a +// racing caller blocks until the globals are whole. +static void cn1EnsureRendererSetup(JAVA_INT lgPositionsX, JAVA_INT lgPositionsY) { + pthread_mutex_lock(&rendererSetupLock); + if (!rendererIsSetup) { + Renderer_setup(lgPositionsX, lgPositionsY); rendererIsSetup = YES; - Renderer_setup(1,1); } + pthread_mutex_unlock(&rendererSetupLock); +} + +JAVA_LONG com_codename1_impl_ios_IOSNative_nativePathRendererCreate___int_int_int_int_int(JAVA_OBJECT instanceObject, JAVA_INT pix_boundsX, JAVA_INT pix_boundsY, JAVA_INT pix_boundsWidth, JAVA_INT pix_boundsHeight, JAVA_INT windingRule) +{ + cn1EnsureRendererSetup(1, 1); Renderer *renderer = (Renderer*)malloc(sizeof(Renderer)); Renderer_init(renderer); Renderer_reset(renderer, pix_boundsX, pix_boundsY, pix_boundsWidth, pix_boundsHeight, windingRule); @@ -11162,11 +11182,7 @@ JAVA_LONG com_codename1_impl_ios_IOSNative_nativePathRendererCreate___int_int_in //native void nativePathRendererSetup(int subpixelLgPositionsX, int subpixelLgPositionsY); void com_codename1_impl_ios_IOSNative_nativePathRendererSetup___int_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT subpixelLgPositionsX, JAVA_INT subpixelLgPositionsY) { - if ( !rendererIsSetup ){ - rendererIsSetup = YES; - - Renderer_setup(subpixelLgPositionsX, subpixelLgPositionsY); - } + cn1EnsureRendererSetup(subpixelLgPositionsX, subpixelLgPositionsY); } //native void nativePathRendererCleanup(long ptr); void com_codename1_impl_ios_IOSNative_nativePathRendererCleanup___long(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_LONG ptr) diff --git a/Ports/iOSPort/nativeSources/Renderer.c b/Ports/iOSPort/nativeSources/Renderer.c index 8c8717e2350..079eb3ae31f 100644 --- a/Ports/iOSPort/nativeSources/Renderer.c +++ b/Ports/iOSPort/nativeSources/Renderer.c @@ -610,12 +610,22 @@ void Renderer_produceAlphas(Renderer *pRenderer, AlphaConsumer *pAC) { static jint sMaxAlpha = 0; static void setMaxAlpha(jint maxalpha) { jint i; - - sMaxAlpha = maxalpha; - alphaMap = malloc(maxalpha+1); + jbyte *map; + + // Fill the table before publishing it, and publish alphaMap before + // sMaxAlpha. produceAlphas reads both from whatever thread is painting: + // assigning alphaMap straight from malloc let a reader index a table that + // was still uninitialized (garbage coverage on antialiased edges), and + // setting sMaxAlpha first let a reader see a non-zero max while alphaMap + // was still NULL (a null dereference). The caller-side guard in + // IOSNative.m serializes setup properly; this ordering is the second line + // of defence. + map = malloc(maxalpha+1); for (i = 0; i <= maxalpha; i++) { - alphaMap[i] = (jbyte) ((i*255 + maxalpha/2)/maxalpha); + map[i] = (jbyte) ((i*255 + maxalpha/2)/maxalpha); } + alphaMap = map; + sMaxAlpha = maxalpha; } static void setAndClearRelativeAlphas(AlphaConsumer *pAC, From 3213bd3202100face0f46ec8e6a7bb3099d66726 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:06:25 +0700 Subject: [PATCH 48/91] Fix the sweep's jq invocation and a rejected-transformation key leak Two review findings, both confirmed rather than taken on faith. gh run list forwards no jq CLI options to --jq, so `--jq --arg horizon ...` made gh treat "horizon" as a subcommand. Reproduced against the real CLI: $ gh run list ... --jq --arg horizon "x" '.[].databaseId' unknown command "horizon" for "gh run list" The nightly sweep would have died before looking at a single run. Piping to a separate `jq -r --arg` instead; checked against a fixture that the filter still selects only push/schedule/dispatch runs with a success/failure conclusion inside the horizon, newest first. rsaCrypt imported the key before validating the transformation, and the unsupported-transformation branch returns directly instead of falling through to `done`, so each rejected call leaked a BCrypt/NCrypt key handle. Validate first and import only afterwards, which removes the leaking path rather than adding a second cleanup to keep in sync. Verified by cross-compiling the port into a real Windows PE with clang-cl + lld-link against an xwin sysroot: 1 test, 0 failures, 0 skipped. Co-Authored-By: Claude Opus 5 (1M context) --- Ports/WindowsPort/nativeSources/cn1_windows_crypto.c | 10 ++++++++-- .../conformance/backfill_port_status.sh | 4 +++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c index c8910831e2a..1619fa99bd4 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c +++ b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c @@ -836,8 +836,8 @@ JAVA_OBJECT com_codename1_impl_windows_WindowsNative_rsaCrypt___java_lang_String int keyLength = 0, dataLength = 0; unsigned char* keyDer = cn1Bytes(keyArray, &keyLength); unsigned char* data = cn1Bytes(dataArray, &dataLength); - BCRYPT_KEY_HANDLE publicKey = encrypt ? cn1PublicKey(keyDer, keyLength) : NULL; - NCRYPT_KEY_HANDLE privateKey = encrypt ? 0 : cn1PrivateKey(keyDer, keyLength, 0); + BCRYPT_KEY_HANDLE publicKey = NULL; + NCRYPT_KEY_HANDLE privateKey = 0; int oaepMode = strstr(mode, "OAEP") != 0; LPCWSTR labelDigest = BCRYPT_SHA256_ALGORITHM; unsigned char* out = 0; @@ -851,6 +851,12 @@ JAVA_OBJECT com_codename1_impl_windows_WindowsNative_rsaCrypt___java_lang_String cn1CryptoFail("unsupported cipher transformation", 0); return JAVA_NULL; } + /* Import only once the transformation is known good. Importing first and + * then rejecting the mode returned without reaching the `done` cleanup, so + * every rejected call leaked a BCrypt/NCrypt key handle -- unbounded in a + * long-running app that keeps retrying a bad transformation. */ + publicKey = encrypt ? cn1PublicKey(keyDer, keyLength) : NULL; + privateKey = encrypt ? 0 : cn1PrivateKey(keyDer, keyLength, 0); if (encrypt ? (publicKey == NULL) : (privateKey == 0)) { return JAVA_NULL; } diff --git a/scripts/hellocodenameone/conformance/backfill_port_status.sh b/scripts/hellocodenameone/conformance/backfill_port_status.sh index 6f2cc28c467..7eeae0ce138 100755 --- a/scripts/hellocodenameone/conformance/backfill_port_status.sh +++ b/scripts/hellocodenameone/conformance/backfill_port_status.sh @@ -115,9 +115,11 @@ while IFS= read -r workflow; do # wider net costs nothing when the newest run is complete. horizon="$(date -u -d "${sweep_stale_days} days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \ || date -u -v-"${sweep_stale_days}"d +%Y-%m-%dT%H:%M:%SZ)" + # gh's --jq takes one expression and forwards no jq CLI options, so --arg has + # to go to a separate jq invocation rather than being smuggled in after --jq. candidates="$(gh run list --workflow "${workflow}" --branch master --limit 100 \ --json databaseId,event,conclusion,updatedAt \ - --jq --arg horizon "${horizon}" '[.[] | select((.event == "push" or .event == "schedule" or .event == "workflow_dispatch") and + | jq -r --arg horizon "${horizon}" '[.[] | select((.event == "push" or .event == "schedule" or .event == "workflow_dispatch") and (.conclusion == "success" or .conclusion == "failure") and (.updatedAt >= $horizon))] | sort_by(.updatedAt) | reverse | .[].databaseId')" From 427c4ee7057a0eef0a24006333768d4f890926e5 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:22:19 +0700 Subject: [PATCH 49/91] Publish a crashed suite's report instead of rejecting it CommonWorkloadBenchmarkTest runs late in Cn1ssDeviceRunner, so a suite that crashes or times out never reaches it and its performance section is partial by construction. The acceptance gate then called the whole report unusable, the backfill kept the checked-in fallback, and the table went on serving the previous -- possibly green -- result. That discards exactly the evidence the page exists to show, and it is the same failure-masked-as-pass shape as the skipped compare-comment job. Require performance completeness only of a suite that actually finished. A partial section is simply not presentable as performance; it is not grounds for throwing away the conformance results. Structural defects -- an unmeasured workload, a reasonless skip, a non-object results map -- stay loud either way, because those are producer bugs whatever the suite did. Three tests pin the boundary: a crashed suite with three of ten workloads and fail / not-run results is accepted; a suite that claims completion still cannot quietly drop a workload; and a crashed suite still cannot smuggle through an unmeasured benchmark or an empty skip reason. Checked that the first one fails against the unfixed gate rather than merely passing with it. Co-Authored-By: Claude Opus 5 (1M context) --- .../conformance/port_status.py | 27 ++++++--- .../conformance/test_port_status.py | 56 +++++++++++++++++++ 2 files changed, 75 insertions(+), 8 deletions(-) diff --git a/scripts/hellocodenameone/conformance/port_status.py b/scripts/hellocodenameone/conformance/port_status.py index 6742d9ab4c6..16c1d7392c5 100755 --- a/scripts/hellocodenameone/conformance/port_status.py +++ b/scripts/hellocodenameone/conformance/port_status.py @@ -670,13 +670,24 @@ def publishable_report_problems( if not isinstance(performance, dict): malformed.append("report has no performance section") return drift, malformed - if performance.get("status") != "complete": - malformed.append(f"performance run is {performance.get('status')!r}") - if performance.get("missing"): - malformed.append( - "performance workloads never reported: " - + ", ".join(performance["missing"]) - ) + # CommonWorkloadBenchmarkTest runs late in Cn1ssDeviceRunner, so a suite that + # crashed or timed out never reaches it and its performance section is + # partial by construction. Rejecting the report for that would throw away the + # very evidence the page needs -- the fail / not-run counts -- and leave the + # table serving the last green run, which is the failure-masked-as-pass shape + # this gate exists to prevent. Completeness is therefore only required of a + # suite that actually finished; a partial section simply is not presentable + # as performance. Structural defects below stay loud either way, because + # those are producer bugs whatever the suite did. + suite_finished = bool(report.get("suite_finished")) + if suite_finished: + if performance.get("status") != "complete": + malformed.append(f"performance run is {performance.get('status')!r}") + if performance.get("missing"): + malformed.append( + "performance workloads never reported: " + + ", ".join(performance["missing"]) + ) benchmarks = performance.get("benchmarks") skipped = performance.get("skipped") or {} @@ -687,7 +698,7 @@ def publishable_report_problems( # A port may legitimately skip a workload (the iOS simulator skips the # GC-footprint workloads); measured plus skipped has to cover the contract. accounted = sorted(set(benchmarks) | set(skipped)) - if accounted != sorted(expected_benchmarks): + if suite_finished and accounted != sorted(expected_benchmarks): malformed.append( "performance workloads do not match the contract: " + ", ".join(accounted) diff --git a/scripts/hellocodenameone/conformance/test_port_status.py b/scripts/hellocodenameone/conformance/test_port_status.py index 3d0b1418802..99ad73746f3 100755 --- a/scripts/hellocodenameone/conformance/test_port_status.py +++ b/scripts/hellocodenameone/conformance/test_port_status.py @@ -364,6 +364,62 @@ def test_publishable_rejects_an_incomplete_or_mislabelled_run(self): ) self.assertTrue(any(expected in item for item in malformed), malformed) + def test_publishable_accepts_a_crashed_suite_with_partial_performance(self): + # A suite that dies before CommonWorkloadBenchmarkTest (which runs late) + # cannot produce a complete performance section. That report is exactly + # the one the page must publish -- refusing it leaves the table serving + # the previous green run, hiding the failure behind a stale pass. + report = self.publishable_report("linux-x64") + report["suite_finished"] = False + report["performance"].update({ + "status": "partial", + "missing": sorted(self.manifest["performance_benchmarks"])[3:], + "benchmarks": { + benchmark: {"duration_ns": 12000000, "checksum": "42"} + for benchmark in sorted(self.manifest["performance_benchmarks"])[:3] + }, + }) + failed, not_run = "ClipboardRoundTripTest", "MutableImageReadbackTest" + report["tests"][failed]["status"] = "fail" + report["tests"][not_run]["status"] = "not-run" + report["summary"] = { + "pass": len(report["tests"]) - 2, "fail": 1, "skip": 0, "not-run": 1 + } + + self.assertEqual(([], []), port_status.publishable_report_problems( + self.manifest, "linux-x64", report + )) + + def test_publishable_still_rejects_partial_performance_when_the_suite_finished(self): + # The concession above is scoped to a suite that did not finish. A run + # that claims completion may not quietly drop workloads. + report = self.publishable_report("linux-x64") + report["performance"]["status"] = "partial" + del report["performance"]["benchmarks"]["quicksort"] + + _, malformed = port_status.publishable_report_problems( + self.manifest, "linux-x64", report + ) + self.assertTrue(any("partial" in item for item in malformed), malformed) + self.assertTrue( + any("do not match the contract" in item for item in malformed), malformed + ) + + def test_publishable_still_rejects_structural_defects_from_a_crashed_suite(self): + # Producer bugs stay loud whatever the suite did: an unmeasured workload + # and a reasonless skip are defects, not consequences of crashing. + report = self.publishable_report("linux-x64") + report["suite_finished"] = False + report["performance"]["benchmarks"]["recursion"]["duration_ns"] = None + del report["performance"]["benchmarks"]["quicksort"] + report["performance"]["skipped"]["quicksort"] = "" + + _, malformed = port_status.publishable_report_problems( + self.manifest, "linux-x64", report + ) + self.assertTrue(any("recursion" in item for item in malformed), malformed) + self.assertTrue(any("quicksort" in item for item in malformed), malformed) + def test_publishable_matches_every_report_the_site_serves(self): for port in self.manifest["ports"]: report_path = port_status.REPO_ROOT / self.manifest["report_directory"] / ( From 99e95619f67209c8f51646bb230fd2b06d5bad74 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:56:49 +0700 Subject: [PATCH 50/91] Close out the Copilot findings I had not been reading I was querying reviewThreads and reporting "0 unresolved". Copilot does not file threads -- it lists findings as "Suppressed comments" inside the review body, which that query cannot see, so several sat open while I called the PR clean. Pulled every review body and worked the backlog. Character: the category table stopped at ASCII, so assigned Latin-1 characters came back UNASSIGNED and isLetter('e'-acute) was false. Latin-1 is the range this class documents as supported. The 0x80-0xFF rows are generated from a reference JDK rather than hand-written, and isLowerCase / isUpperCase now answer over the same range instead of returning false above 127 -- otherwise isLowerCase would deny a character getType calls a LOWERCASE_LETTER. CharacterLatin1TypeTest runs all 256 code points through both the JDK and ParparVM and compares getType plus seven predicates; it found the last gap itself, the two ordinal indicators, which the JDK treats as lowercase via Unicode's derived Other_Lowercase property despite being category Lo. TimeZone.getTimeZone threw NullPointerException from the middle of the method for a null ID. Copilot suggested returning GMT; I checked the JDK instead -- it throws for null and reserves GMT for an ID it merely cannot parse, and the JavaSE and Android ports reach that behaviour directly. Answering GMT would have put this port out of step, so it fails fast with the same contract. cn1RecordIoError formatted strerror(errno) even when no fopen was attempted, naming a plausible wrong reason from an unrelated earlier call. VideoIORoundTripTest left the VideoWriter open when a write threw, leaking the native encoder into the rest of the suite. port_status: "skipped": [] was coerced to {} by `or {}` and walked past the type check; and my own crashed-suite concession was too broad -- coverage is now still enforced as measured + skipped + missing, so a crashed run may leave workloads unrun but not lose them silently. Both new tests fail against the unfixed gate. Co-Authored-By: Claude Opus 5 (1M context) --- Ports/LinuxPort/nativeSources/cn1_linux_io.c | 10 +- .../tests/VideoIORoundTripTest.java | 13 ++ .../conformance/port_status.py | 35 ++- .../conformance/test_port_status.py | 26 +++ vm/JavaAPI/src/java/lang/Character.java | 87 ++++++-- vm/JavaAPI/src/java/util/TimeZone.java | 12 +- .../translator/CharacterLatin1TypeTest.java | 207 ++++++++++++++++++ .../tools/translator/CharacterLatin1App.java | 53 +++++ 8 files changed, 422 insertions(+), 21 deletions(-) create mode 100644 vm/tests/src/test/java/com/codename1/tools/translator/CharacterLatin1TypeTest.java create mode 100644 vm/tests/src/test/resources/com/codename1/tools/translator/CharacterLatin1App.java diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_io.c b/Ports/LinuxPort/nativeSources/cn1_linux_io.c index 2afadd4168d..f81841494af 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_io.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_io.c @@ -177,8 +177,16 @@ static const char* cn1JStr(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT s) { static __thread char cn1LastIoError[512]; static void cn1RecordIoError(const char* path) { + if (path == 0) { + /* No fopen was attempted, so errno belongs to some earlier unrelated + * call and strerror would name a plausible-looking wrong reason -- + * worse than saying nothing, since the whole point of this buffer is + * to explain a failure without another CI round trip. */ + snprintf(cn1LastIoError, sizeof(cn1LastIoError), "%s", + "no path supplied"); + return; + } snprintf(cn1LastIoError, sizeof(cn1LastIoError), "%s", strerror(errno)); - (void) path; } JAVA_OBJECT com_codename1_impl_linux_LinuxNative_lastIoError___R_java_lang_String(CODENAME_ONE_THREAD_STATE) { diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/VideoIORoundTripTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/VideoIORoundTripTest.java index 3eca87011c2..65c999b1b12 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/VideoIORoundTripTest.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/VideoIORoundTripTest.java @@ -187,7 +187,20 @@ private void runRoundTrip() { } } writer.close(); + writer = null; } catch (Throwable t) { + // The writer is still open whenever the throw came from a write + // rather than from close(). Leaving it open leaks the native + // encoder into the rest of the suite, which shares this process; + // best-effort close, and ignore a secondary failure because we are + // already reporting the first one. + if (writer != null) { + try { + writer.close(); + } catch (Throwable ignored) { + // reporting the original failure below + } + } cleanup(path); // Still a skip, with its own reason code. The Apple simulators // obtain a writer and then fail to finalize the file, which is the diff --git a/scripts/hellocodenameone/conformance/port_status.py b/scripts/hellocodenameone/conformance/port_status.py index 16c1d7392c5..4a4bd5037d4 100755 --- a/scripts/hellocodenameone/conformance/port_status.py +++ b/scripts/hellocodenameone/conformance/port_status.py @@ -690,7 +690,12 @@ def publishable_report_problems( ) benchmarks = performance.get("benchmarks") - skipped = performance.get("skipped") or {} + skipped = performance.get("skipped") + if skipped is None: + # Absent is tolerated; a wrong type is not. `or {}` coerced a falsy + # non-dict -- "skipped": [] -- into {} and walked it straight past the + # isinstance check below. + skipped = {} if not isinstance(benchmarks, dict) or not isinstance(skipped, dict): malformed.append("performance results are not objects") return drift, malformed @@ -698,11 +703,29 @@ def publishable_report_problems( # A port may legitimately skip a workload (the iOS simulator skips the # GC-footprint workloads); measured plus skipped has to cover the contract. accounted = sorted(set(benchmarks) | set(skipped)) - if suite_finished and accounted != sorted(expected_benchmarks): - malformed.append( - "performance workloads do not match the contract: " - + ", ".join(accounted) - ) + if suite_finished: + if accounted != sorted(expected_benchmarks): + malformed.append( + "performance workloads do not match the contract: " + + ", ".join(accounted) + ) + else: + # A crashed suite is allowed to leave workloads unrun, but not to lose + # them silently: normalize computes `missing` as the contract minus what + # was measured or skipped, so measured + skipped + missing still has to + # name every workload. Dropping that check entirely would let a + # structurally broken section through unnoticed. + declared_missing = performance.get("missing") + if declared_missing is None: + declared_missing = [] + if not isinstance(declared_missing, list): + malformed.append("performance missing list is not an array") + else: + covered = sorted(set(accounted) | set(declared_missing)) + if covered != sorted(expected_benchmarks): + malformed.append( + "performance workloads unaccounted for: " + ", ".join(covered) + ) for name, measurement in benchmarks.items(): duration = measurement.get("duration_ns") if isinstance(measurement, dict) else None if isinstance(duration, bool) or not isinstance(duration, int) or duration < 0: diff --git a/scripts/hellocodenameone/conformance/test_port_status.py b/scripts/hellocodenameone/conformance/test_port_status.py index 99ad73746f3..db6a31541f7 100755 --- a/scripts/hellocodenameone/conformance/test_port_status.py +++ b/scripts/hellocodenameone/conformance/test_port_status.py @@ -420,6 +420,32 @@ def test_publishable_still_rejects_structural_defects_from_a_crashed_suite(self) self.assertTrue(any("recursion" in item for item in malformed), malformed) self.assertTrue(any("quicksort" in item for item in malformed), malformed) + def test_publishable_rejects_a_crashed_suite_that_loses_a_workload(self): + # The crashed-suite concession is not a hole: normalize derives + # `missing` from the contract, so measured + skipped + missing must + # still name every workload even when the suite died. + report = self.publishable_report("linux-x64") + report["suite_finished"] = False + report["performance"]["status"] = "partial" + del report["performance"]["benchmarks"]["quicksort"] + report["performance"]["missing"] = [] + + _, malformed = port_status.publishable_report_problems( + self.manifest, "linux-x64", report + ) + self.assertTrue(any("unaccounted for" in item for item in malformed), malformed) + + def test_publishable_rejects_a_wrongly_typed_skipped_section(self): + # "skipped": [] is malformed, not "no skips" -- `or {}` used to coerce + # it past the type check. + report = self.publishable_report("linux-x64") + report["performance"]["skipped"] = [] + + _, malformed = port_status.publishable_report_problems( + self.manifest, "linux-x64", report + ) + self.assertTrue(any("not objects" in item for item in malformed), malformed) + def test_publishable_matches_every_report_the_site_serves(self): for port in self.manifest["ports"]: report_path = port_status.REPO_ROOT / self.manifest["report_directory"] / ( diff --git a/vm/JavaAPI/src/java/lang/Character.java b/vm/JavaAPI/src/java/lang/Character.java index 21571ce31f5..dd2d4596513 100644 --- a/vm/JavaAPI/src/java/lang/Character.java +++ b/vm/JavaAPI/src/java/lang/Character.java @@ -462,6 +462,22 @@ public static boolean isLowerCase(int codePoint) { if (codePoint < 128) { return false; } + // Answer over the rest of the range this class documents as supported. + // Returning false for every code point above 127 contradicted both that + // documentation and getType, which now classifies Latin-1 properly -- + // isLowerCase would have said false for a character getType calls a + // LOWERCASE_LETTER. + // + // The two ordinal indicators are lowercase without being in category + // Ll: Unicode gives them the derived Other_Lowercase property, and the + // JDK agrees, so a plain category test would disagree with it on + // exactly these two code points. + if (codePoint == 0x00AA || codePoint == 0x00BA) { + return true; + } + if (codePoint < LATIN1_TYPES.length) { + return LATIN1_TYPES[codePoint] == LOWERCASE_LETTER; + } //return isLowerCaseImpl(codePoint); return false; } @@ -489,10 +505,15 @@ public static boolean isUpperCase(int codePoint) { if ('A' <= codePoint && codePoint <= 'Z') { return true; } - /*if (codePoint < 128) { + if (codePoint < 128) { return false; } - return isUpperCaseImpl(codePoint);*/ + // See isLowerCase: answer over the documented Latin-1 range rather than + // reporting false for every assigned character above ASCII. + if (codePoint < LATIN1_TYPES.length) { + return LATIN1_TYPES[codePoint] == UPPERCASE_LETTER; + } + /*return isUpperCaseImpl(codePoint);*/ return false; } @@ -1312,17 +1333,25 @@ public static boolean isSurrogate(char ch) { } /** - * General category of every ASCII code point, indexed by code point. + * General category of every ISO Latin-1 code point, indexed by code point. * - * The rest of this class is deliberately ASCII-only (isDigit, isLowerCase - * and isUpperCase all answer false above 127), so the category table is - * too. It used to be absent altogether, and getType threw - * UnsupportedOperationException -- which meant isLetter, isLetterOrDigit, + * This table used to be absent altogether and getType threw + * UnsupportedOperationException, which meant isLetter, isLetterOrDigit, * isJavaIdentifierStart, isJavaIdentifierPart and isIdentifierIgnorable - * threw for every input on the ports that use this runtime, rather than - * answering for the ASCII text they are almost always asked about. + * threw for every input on the ports that use this runtime. + * + * It covers Latin-1 rather than just ASCII because that is the range this + * class documents as supported (see the class comment and isLowerCase / + * isUpperCase / toLowerCase). Stopping at 127 answered UNASSIGNED for + * assigned characters, so isLetter('e' with an acute) and + * isJavaIdentifierStart came back false and a currency sign such as the + * pound got the wrong category. + * + * The 0x80-0xFF rows are not hand-written: they were generated from + * java.lang.Character.getType on a reference JDK and are verified against + * it -- CharacterLatin1TypeTest walks all 256 code points and compares. */ - private static final byte[] ASCII_TYPES = { + private static final byte[] LATIN1_TYPES = { /* 00-0f */ CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, /* 10-1f */ CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, @@ -1350,15 +1379,47 @@ public static boolean isSurrogate(char ch) { /* p-w */ LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, /* xyz{ */ LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, START_PUNCTUATION, - /* |}~del */ MATH_SYMBOL, END_PUNCTUATION, MATH_SYMBOL, CONTROL + /* |}~del */ MATH_SYMBOL, END_PUNCTUATION, MATH_SYMBOL, CONTROL, + /* 0x80-0x83 */ CONTROL, CONTROL, CONTROL, CONTROL, + /* 0x84-0x87 */ CONTROL, CONTROL, CONTROL, CONTROL, + /* 0x88-0x8B */ CONTROL, CONTROL, CONTROL, CONTROL, + /* 0x8C-0x8F */ CONTROL, CONTROL, CONTROL, CONTROL, + /* 0x90-0x93 */ CONTROL, CONTROL, CONTROL, CONTROL, + /* 0x94-0x97 */ CONTROL, CONTROL, CONTROL, CONTROL, + /* 0x98-0x9B */ CONTROL, CONTROL, CONTROL, CONTROL, + /* 0x9C-0x9F */ CONTROL, CONTROL, CONTROL, CONTROL, + /* 0xA0-0xA3 */ SPACE_SEPARATOR, OTHER_PUNCTUATION, CURRENCY_SYMBOL, CURRENCY_SYMBOL, + /* 0xA4-0xA7 */ CURRENCY_SYMBOL, CURRENCY_SYMBOL, OTHER_SYMBOL, OTHER_PUNCTUATION, + /* 0xA8-0xAB */ MODIFIER_SYMBOL, OTHER_SYMBOL, OTHER_LETTER, INITIAL_QUOTE_PUNCTUATION, + /* 0xAC-0xAF */ MATH_SYMBOL, FORMAT, OTHER_SYMBOL, MODIFIER_SYMBOL, + /* 0xB0-0xB3 */ OTHER_SYMBOL, MATH_SYMBOL, OTHER_NUMBER, OTHER_NUMBER, + /* 0xB4-0xB7 */ MODIFIER_SYMBOL, LOWERCASE_LETTER, OTHER_PUNCTUATION, OTHER_PUNCTUATION, + /* 0xB8-0xBB */ MODIFIER_SYMBOL, OTHER_NUMBER, OTHER_LETTER, FINAL_QUOTE_PUNCTUATION, + /* 0xBC-0xBF */ OTHER_NUMBER, OTHER_NUMBER, OTHER_NUMBER, OTHER_PUNCTUATION, + /* 0xC0-0xC3 */ UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, + /* 0xC4-0xC7 */ UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, + /* 0xC8-0xCB */ UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, + /* 0xCC-0xCF */ UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, + /* 0xD0-0xD3 */ UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, + /* 0xD4-0xD7 */ UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, MATH_SYMBOL, + /* 0xD8-0xDB */ UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, + /* 0xDC-0xDF */ UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, LOWERCASE_LETTER, + /* 0xE0-0xE3 */ LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, + /* 0xE4-0xE7 */ LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, + /* 0xE8-0xEB */ LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, + /* 0xEC-0xEF */ LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, + /* 0xF0-0xF3 */ LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, + /* 0xF4-0xF7 */ LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, MATH_SYMBOL, + /* 0xF8-0xFB */ LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, + /* 0xFC-0xFF */ LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER }; public static int getType(int codePoint) { if (isBmpCodePoint(codePoint) && isSurrogate((char) codePoint)) { return SURROGATE; } - if (codePoint >= 0 && codePoint < ASCII_TYPES.length) { - return ASCII_TYPES[codePoint]; + if (codePoint >= 0 && codePoint < LATIN1_TYPES.length) { + return LATIN1_TYPES[codePoint]; } // Above ASCII this runtime carries no category table, so answer from // the primitives it does implement instead of failing the call. The diff --git a/vm/JavaAPI/src/java/util/TimeZone.java b/vm/JavaAPI/src/java/util/TimeZone.java index 39e6e048c8b..5e155418df5 100644 --- a/vm/JavaAPI/src/java/util/TimeZone.java +++ b/vm/JavaAPI/src/java/util/TimeZone.java @@ -165,7 +165,17 @@ public java.lang.String getID(){ * Gets the TimeZone for the given ID. */ public static java.util.TimeZone getTimeZone(final java.lang.String ID){ - if(ID != null && ID.equalsIgnoreCase("gmt")) { + if (ID == null) { + // Fail here rather than three statements down, where the first + // unguarded equalsIgnoreCase used to throw from the middle of the + // method. NullPointerException is the right answer rather than GMT: + // the JDK throws for a null ID and reserves GMT for an ID it merely + // cannot parse, and the JavaSE and Android ports reach that JDK + // behaviour directly -- so answering GMT here would put this port + // out of step with them. + throw new NullPointerException("ID"); + } + if(ID.equalsIgnoreCase("gmt")) { return GMT; } TimeZone custom = customTimeZone(ID); diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/CharacterLatin1TypeTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/CharacterLatin1TypeTest.java new file mode 100644 index 00000000000..4bdc4f59422 --- /dev/null +++ b/vm/tests/src/test/java/com/codename1/tools/translator/CharacterLatin1TypeTest.java @@ -0,0 +1,207 @@ +/* + * Copyright (c) 2026, 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 org.junit.jupiter.api.Test; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +/** + * Pins vm/JavaAPI's Character against the JDK over the ISO Latin-1 range. + * + * getType used to throw UnsupportedOperationException, so isLetter, + * isLetterOrDigit, isJavaIdentifierStart, isJavaIdentifierPart and + * isIdentifierIgnorable threw for every input. Giving it a category table fixed + * that, but an ASCII-only table then answered UNASSIGNED for assigned Latin-1 + * characters -- isLetter('e' with an acute) came back false. The table now + * covers Latin-1, which is the range this class documents as supported, and + * this test is what keeps it honest: a hand-edited entry shows up as a + * mismatch against the JDK rather than as a subtly wrong answer in the field. + * + * The host JDK is the oracle. java.lang classes cannot be replaced from the + * classpath, so the JavaSE leg really does run the JDK's Character while the + * ParparVM leg runs ours. + */ +class CharacterLatin1TypeTest { + + @Test + void latin1CharacterPropertiesMatchTheJdk() throws Exception { + Parser.cleanup(); + + Path sourceDir = Files.createTempDirectory("character-latin1-sources"); + Path classesDir = Files.createTempDirectory("character-latin1-classes"); + Path javaApiDir = Files.createTempDirectory("character-latin1-japi"); + + Path source = sourceDir.resolve("CharacterLatin1App.java"); + Files.write(source, loadAppSource().getBytes(StandardCharsets.UTF_8)); + + CompilerHelper.CompilerConfig config = selectCompiler(); + if (config == null) { + fail("No compatible compiler available for the Latin-1 Character test"); + } + + assertTrue(CompilerHelper.isJavaApiCompatible(config), + "JDK " + config.jdkVersion + " must target matching bytecode level for JavaAPI"); + + CompilerHelper.compileJavaAPI(javaApiDir, config); + + List compileArgs = new ArrayList<>(); + compileArgs.add("-source"); + compileArgs.add(config.targetVersion); + compileArgs.add("-target"); + compileArgs.add(config.targetVersion); + if (CompilerHelper.useClasspath(config)) { + compileArgs.add("-classpath"); + compileArgs.add(javaApiDir.toString()); + } else { + compileArgs.add("-bootclasspath"); + compileArgs.add(javaApiDir.toString()); + compileArgs.add("-Xlint:-options"); + } + compileArgs.add("-d"); + compileArgs.add(classesDir.toString()); + compileArgs.add(source.toString()); + + int compileResult = CompilerHelper.compile(config.jdkHome, compileArgs); + assertEquals(0, compileResult, + "CharacterLatin1App should compile. " + CompilerHelper.getLastErrorLog()); + + String jdkResult = extractResultLine(runJavaMain(config, classesDir)); + assertTrue(jdkResult.startsWith("RESULT="), + "The JDK leg should produce a RESULT line"); + + CompilerHelper.copyDirectory(javaApiDir, classesDir); + + Path outputDir = Files.createTempDirectory("character-latin1-output"); + CleanTargetIntegrationTest.runTranslator(classesDir, outputDir, "CharacterLatin1App"); + + Path distDir = outputDir.resolve("dist"); + Path cmakeLists = distDir.resolve("CMakeLists.txt"); + assertTrue(Files.exists(cmakeLists), "Translator should emit a CMake project"); + + CleanTargetIntegrationTest.replaceLibraryWithExecutableTarget(cmakeLists, "CharacterLatin1App-src"); + + Path buildDir = distDir.resolve("build"); + Files.createDirectories(buildDir); + + CleanTargetIntegrationTest.runCommand(Arrays.asList( + "cmake", + "-S", distDir.toString(), + "-B", buildDir.toString(), + "-DCMAKE_C_COMPILER=clang", + "-DCMAKE_OBJC_COMPILER=clang" + ), distDir); + + CleanTargetIntegrationTest.runCommand(Arrays.asList("cmake", "--build", buildDir.toString()), distDir); + + Path executable = buildDir.resolve("CharacterLatin1App"); + assertTrue(Files.exists(executable), "ParparVM build should produce a runnable executable"); + + String vmResult = extractResultLine( + CleanTargetIntegrationTest.runCommand(Arrays.asList(executable.toString()), buildDir)); + + assertEquals(jdkResult, vmResult, firstDifference(jdkResult, vmResult)); + } + + /** Names the offending code point instead of dumping two 2KB strings. */ + private String firstDifference(String expected, String actual) { + String[] want = expected.substring("RESULT=".length()).split(";"); + String[] got = actual.substring(actual.indexOf('=') + 1).split(";"); + for (int c = 0; c < Math.min(want.length, got.length); c++) { + if (!want[c].equals(got[c])) { + return "Character properties differ at code point " + c + + " (0x" + Integer.toHexString(c) + "): JDK " + want[c] + + ", ParparVM " + got[c] + + ". Fields are getType, isLetter, isLowerCase, isUpperCase," + + " isDigit, isLetterOrDigit, isJavaIdentifierStart," + + " isJavaIdentifierPart."; + } + } + return "ParparVM Character should answer as the JDK does across Latin-1"; + } + + private String loadAppSource() throws Exception { + java.io.InputStream in = CharacterLatin1TypeTest.class.getResourceAsStream( + "/com/codename1/tools/translator/CharacterLatin1App.java"); + assertNotNull(in, "CharacterLatin1App.java test resource should exist"); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { + return reader.lines().collect(Collectors.joining("\n")) + "\n"; + } + } + + private String runJavaMain(CompilerHelper.CompilerConfig config, Path classesDir) throws Exception { + String javaExe = config.jdkHome.resolve("bin").resolve("java").toString(); + if (System.getProperty("os.name").toLowerCase().contains("win")) { + javaExe += ".exe"; + } + + ProcessBuilder pb = new ProcessBuilder(javaExe, "-cp", classesDir.toString(), "CharacterLatin1App"); + pb.redirectErrorStream(true); + + Process process = pb.start(); + String output; + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + output = reader.lines().collect(Collectors.joining("\n")); + } + + int exitCode = process.waitFor(); + assertEquals(0, exitCode, "JVM run should exit cleanly. Output: " + output); + return output; + } + + private String extractResultLine(String output) { + for (String line : output.split("\\R")) { + if (line.startsWith("RESULT=")) { + return line.trim(); + } + } + return ""; + } + + private CompilerHelper.CompilerConfig selectCompiler() { + String[] preferredTargets = {"11", "17", "21", "25", "1.8"}; + for (String target : preferredTargets) { + List configs = CompilerHelper.getAvailableCompilers(target); + for (CompilerHelper.CompilerConfig config : configs) { + if (CompilerHelper.isJavaApiCompatible(config)) { + return config; + } + } + } + return null; + } +} diff --git a/vm/tests/src/test/resources/com/codename1/tools/translator/CharacterLatin1App.java b/vm/tests/src/test/resources/com/codename1/tools/translator/CharacterLatin1App.java new file mode 100644 index 00000000000..8a2751dcfca --- /dev/null +++ b/vm/tests/src/test/resources/com/codename1/tools/translator/CharacterLatin1App.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026, 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. + */ +/** + * Prints Character's answers for every ISO Latin-1 code point. + * + * Run twice by CharacterLatin1TypeTest -- once on the host JDK, once through + * ParparVM against vm/JavaAPI's Character -- and the two RESULT lines must be + * identical. Latin-1 is the range this class documents as supported, and the + * category table backing getType was generated from a JDK, so the JDK is the + * right oracle for it. + */ +public class CharacterLatin1App { + + public static void main(String[] args) { + StringBuilder sb = new StringBuilder("RESULT="); + for (int c = 0; c < 256; c++) { + sb.append(Character.getType(c)); + sb.append(flag(Character.isLetter(c))); + sb.append(flag(Character.isLowerCase(c))); + sb.append(flag(Character.isUpperCase(c))); + sb.append(flag(Character.isDigit(c))); + sb.append(flag(Character.isLetterOrDigit(c))); + sb.append(flag(Character.isJavaIdentifierStart(c))); + sb.append(flag(Character.isJavaIdentifierPart(c))); + sb.append(';'); + } + System.out.println(sb.toString()); + } + + private static char flag(boolean value) { + return value ? 'T' : 'F'; + } +} From af4823230c9e1f143bf487a3c2755f22774d21c3 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:14:17 +0700 Subject: [PATCH 51/91] Work the rest of the Copilot backlog, not just the newest review Pulled every review body on the PR and deduped: 43 distinct findings, not the handful the newest review happened to repeat. Most were already fixed in earlier rounds; these were not. port_status.py: the summary check was suppressed whenever any drift was seen, so a genuinely malformed report could be filed as drift and fall back quietly -- the exact outcome the loud/quiet split exists to avoid. The summary counts the results the report carries, so it is self-consistent under drift and is now always checked. Unioning benchmarks with skipped also let a report claim a workload as both and still look complete. And the gap messages listed the workloads that were fine rather than the one that was not, so they now name what is missing or unexpected. backfill_port_status.sh: `owned` is newline-separated, so the space-delimited membership test only ever matched the first and last port a workflow owns. `base64 --decode` is GNU-only in a script that already carries a BSD `date` fallback. And a non-zero `accept` was reported as "not usable" whether it was drift (11) or corruption (12), which are meant to read differently. validate_port_status.mjs: the note-marker check counted every on the page, including the legend's own, so a cell that had lost its marker was masked by it. Counted per documented-skip cell now. cn1_windows_io.c: both open paths returned without recording anything when the path conversion failed, so lastIoError reported a stale code from an unrelated call. Also captures GetLastError before free(), which is entitled to clobber it. cn1_linux_crypto.c: the PKCS#8 attempt leaves its failure on the thread's OpenSSL error queue. For a valid PKCS#1/SEC1 key the fallback then succeeds and that stale entry is reported by the next unrelated operation. DateTimeSupport: getOffset takes a year *of an era*, and this always passed AD, so instants before 1 CE described a date that does not exist. LinuxBrowserComponent: the comment described the PNG capture as working. browserCapturePng returns null pending the async WebKit snapshot bridge. Not taken: the "errata account for" grammar report. "Errata" is the plural of "erratum", so the verb already agrees. Co-Authored-By: Claude Opus 5 (1M context) --- .../nativeSources/cn1_linux_crypto.c | 8 ++- .../impl/linux/LinuxBrowserComponent.java | 15 +++-- .../nativeSources/cn1_windows_io.c | 29 +++++++++- .../conformance/backfill_port_status.sh | 56 +++++++++++++++---- .../conformance/port_status.py | 37 +++++++++++- .../conformance/test_port_status.py | 35 ++++++++++++ scripts/website/validate_port_status.mjs | 17 +++++- vm/JavaAPI/src/java/time/DateTimeSupport.java | 9 ++- 8 files changed, 180 insertions(+), 26 deletions(-) diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c b/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c index b245b70375c..e0b9383c693 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c @@ -309,7 +309,13 @@ static EVP_PKEY* cn1PrivateKey(const unsigned char* der, int length) { PKCS8_PRIV_KEY_INFO_free(info); } if (key == 0) { - /* Tolerate a bare PKCS#1/SEC1 key as well; some callers keep those. */ + /* Tolerate a bare PKCS#1/SEC1 key as well; some callers keep those. + * The PKCS#8 attempt above queued its failure on this thread's OpenSSL + * error queue. For a valid PKCS#1/SEC1 key that failure is expected and + * the fallback succeeds, but the stale entry stays queued and the next + * unrelated operation to read the queue reports it -- so drop it before + * trying again. */ + ERR_clear_error(); cursor = der; key = d2i_AutoPrivateKey(0, &cursor, (long) length); } diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxBrowserComponent.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxBrowserComponent.java index b1f2ea6b855..cd175bb3f1d 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxBrowserComponent.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxBrowserComponent.java @@ -32,14 +32,19 @@ import com.codename1.ui.util.UITimer; /// Native Linux BrowserComponent peer backed by a WebKitGTK WebView (the native -/// lifecycle lives in cn1_linux_browser.c). The component is rendered from a -/// cached image: the native side captures the view to PNG bytes after each -/// navigation, which `generatePeerImage()` turns into the peer image that -/// `PeerComponent.paint()` draws, so it appears in the offscreen screenshot -/// where the live WebKit widget would not. The peer polls the native event +/// lifecycle lives in cn1_linux_browser.c). The peer polls the native event /// queue to fire `onLoad` and to route the JS return-value bridge into the /// BrowserComponent's navigation callbacks. /// +/// `generatePeerImage()` is wired to a native PNG capture so the view can be +/// drawn into an offscreen screenshot, where the live WebKit widget would not +/// appear. That capture is **not implemented yet**: WebKit snapshots are async +/// (webkit_web_view_get_snapshot) and `browserCapturePng` currently returns +/// null pending that bridge, so `generatePeerImage()` returns null and the peer +/// falls back to the live widget. Do not read this as a description of working +/// behaviour -- it is the shape the capture will take once the snapshot bridge +/// lands. +/// /// (This description previously named WebView2, Direct2D and a .cpp file, none /// of which exist here -- it had been copied from the Windows port.) class LinuxBrowserComponent extends PeerComponent { diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_io.c b/Ports/WindowsPort/nativeSources/cn1_windows_io.c index 96b000d487c..214d2a45725 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_io.c +++ b/Ports/WindowsPort/nativeSources/cn1_windows_io.c @@ -88,9 +88,27 @@ static JAVA_OBJECT cn1WinWideToJavaString(CODENAME_ONE_THREAD_STATE, const WCHAR /* Per-thread: two threads failing an open at once would otherwise overwrite * each other and lastIoError() could report the wrong reason. */ static __declspec(thread) DWORD cn1WinLastIoError; +/* Set when the open failed before CreateFileW was ever reached, so the thread's + * last-error value belongs to some earlier unrelated call. Reporting that value + * would name a plausible but wrong reason, which is worse than saying nothing: + * the whole point of this record is to explain a failure without another run. */ +static __declspec(thread) int cn1WinLastIoHadNoPath; + +static void cn1WinRecordIoError(DWORD error) { + cn1WinLastIoHadNoPath = 0; + cn1WinLastIoError = error; +} + +static void cn1WinRecordMissingPath(void) { + cn1WinLastIoHadNoPath = 1; + cn1WinLastIoError = 0; +} JAVA_OBJECT com_codename1_impl_windows_WindowsNative_lastIoError___R_java_lang_String(CODENAME_ONE_THREAD_STATE) { char buffer[256]; + if (cn1WinLastIoHadNoPath) { + return newStringFromCString(threadStateData, "no path supplied"); + } _snprintf(buffer, sizeof(buffer), "Windows error %lu", (unsigned long) cn1WinLastIoError); buffer[sizeof(buffer) - 1] = 0; return newStringFromCString(threadStateData, buffer); @@ -100,14 +118,18 @@ JAVA_LONG com_codename1_impl_windows_WindowsNative_fileOpenRead___java_lang_Stri UINT32 len = 0; WCHAR* path = cn1WinJavaStringToWide(threadStateData, __cn1Arg1, &len); HANDLE h; + DWORD error; if (path == NULL) { + cn1WinRecordMissingPath(); return 0; } h = CreateFileW(path, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); + /* Captured before free(), which is free to clobber the thread's last error. */ + error = GetLastError(); free(path); if (h == INVALID_HANDLE_VALUE) { - cn1WinLastIoError = GetLastError(); + cn1WinRecordIoError(error); return 0; } return (JAVA_LONG)(intptr_t)h; @@ -117,7 +139,9 @@ JAVA_LONG com_codename1_impl_windows_WindowsNative_fileOpenWrite___java_lang_Str UINT32 len = 0; WCHAR* path = cn1WinJavaStringToWide(threadStateData, __cn1Arg1, &len); HANDLE h; + DWORD error; if (path == NULL) { + cn1WinRecordMissingPath(); return 0; } if (__cn1Arg2) { @@ -132,9 +156,10 @@ JAVA_LONG com_codename1_impl_windows_WindowsNative_fileOpenWrite___java_lang_Str h = CreateFileW(path, GENERIC_WRITE, FILE_SHARE_READ, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); } + error = GetLastError(); free(path); if (h == INVALID_HANDLE_VALUE) { - cn1WinLastIoError = GetLastError(); + cn1WinRecordIoError(error); return 0; } return (JAVA_LONG)(intptr_t)h; diff --git a/scripts/hellocodenameone/conformance/backfill_port_status.sh b/scripts/hellocodenameone/conformance/backfill_port_status.sh index 7eeae0ce138..e10557224f1 100755 --- a/scripts/hellocodenameone/conformance/backfill_port_status.sh +++ b/scripts/hellocodenameone/conformance/backfill_port_status.sh @@ -43,6 +43,28 @@ REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" MANIFEST="${REPO_ROOT}/docs/website/data/port_status.json" DATA_BRANCH="port-status-data" +# port_status.py accept distinguishes "built against an older contract, wait for +# the next run" from "the report is broken". Both keep the checked-in fallback, +# but only the second is a defect worth shouting about, so the two get different +# messages rather than one blanket "not usable". +ACCEPT_CONTRACT_DRIFT=11 +ACCEPT_UNUSABLE=12 + +# GNU coreutils spells it --decode, BSD (macOS) spells it -D. This script has a +# BSD `date` fallback already, so it is meant to run in both places. +decode_base64() { + base64 --decode 2>/dev/null || base64 -D +} + +# Explains a non-zero `accept` status without pretending drift is corruption. +describe_accept_status() { + case "$1" in + "${ACCEPT_CONTRACT_DRIFT}") echo "built against a different test contract; waiting for a run on the current one" ;; + "${ACCEPT_UNUSABLE}") echo "not usable by the website" ;; + *) echo "rejected by the publication gate (status $1)" ;; + esac +} + for tool in gh jq python3; do if ! command -v "${tool}" >/dev/null 2>&1; then echo "backfill-port-status: ${tool} is required." >&2 @@ -157,7 +179,10 @@ while IFS= read -r workflow; do # on its report would otherwise be published straight over that port's # entry -- Linux evidence replacing Android's genuine result, with both # the gate and the freshness check satisfied. - case " ${owned} " in + # owned is newline-separated (one id per jq row); normalise to spaces + # so the space-delimited membership test below actually matches the ids + # in the middle of the list rather than only the first and last. + case " $(printf '%s ' ${owned}) " in *" ${found} "*) ;; *) echo "Ignoring a report naming ${found}: ${workflow} does not produce that port." >&2 @@ -169,9 +194,11 @@ while IFS= read -r workflow; do # the older candidates from being consulted, so the sweep would keep # serving stale data -- or fail its closing freshness assertion -- # while a perfectly good report sat in the run behind it. - if ! python3 "${SCRIPT_DIR}/port_status.py" accept \ - --port "${found}" --report "${downloaded}" >/dev/null 2>&1; then - echo "Ignoring the ${found} report from run ${candidate}: not usable by the website." >&2 + accept_status=0 + python3 "${SCRIPT_DIR}/port_status.py" accept \ + --port "${found}" --report "${downloaded}" >/dev/null 2>&1 || accept_status=$? + if [ "${accept_status}" -ne 0 ]; then + echo "Ignoring the ${found} report from run ${candidate}: $(describe_accept_status "${accept_status}")." >&2 continue fi cp "${downloaded}" "${download_dir}/port-status-${found}.json" @@ -194,14 +221,16 @@ while IFS= read -r workflow; do # an older contract passes the freshness check below but is rejected by the # sync, which would leave the public column on its stale fallback while # this sweep reported success. - if ! python3 "${SCRIPT_DIR}/port_status.py" accept --port "${port}" --report "${report}"; then - echo "Not publishing the ${port} report from run ${run_id}: it is not usable by the website." >&2 + accept_status=0 + python3 "${SCRIPT_DIR}/port_status.py" accept --port "${port}" --report "${report}" || accept_status=$? + if [ "${accept_status}" -ne 0 ]; then + echo "Not publishing the ${port} report from run ${run_id}: $(describe_accept_status "${accept_status}")." >&2 continue fi generated="$(jq -r '.generated_at // empty' "${report}")" current="" if gh api "repos/${GITHUB_REPOSITORY}/contents/ports/${port}.json?ref=${DATA_BRANCH}" \ - --jq '.content' 2>/dev/null | base64 --decode > "${tmp_dir}/current.json" 2>/dev/null; then + --jq '.content' 2>/dev/null | decode_base64 > "${tmp_dir}/current.json" 2>/dev/null; then current="$(jq -r '.generated_at // empty' "${tmp_dir}/current.json" 2>/dev/null || true)" fi # Compare instants rather than strings. The gate accepts any timezone-aware @@ -227,15 +256,17 @@ stale_days="$(jq -r '.stale_after_days' "${MANIFEST}")" problems=() while IFS= read -r port; do if ! gh api "repos/${GITHUB_REPOSITORY}/contents/ports/${port}.json?ref=${DATA_BRANCH}" \ - --jq '.content' 2>/dev/null | base64 --decode > "${tmp_dir}/check.json" 2>/dev/null; then + --jq '.content' 2>/dev/null | decode_base64 > "${tmp_dir}/check.json" 2>/dev/null; then problems+=("${port}: no published report") continue fi # Freshness alone is not enough: a published report the website rejects # leaves the column on its checked-in fallback, which is the state this # sweep exists to detect. - if ! python3 "${SCRIPT_DIR}/port_status.py" accept --port "${port}" --report "${tmp_dir}/check.json" >/dev/null; then - problems+=("${port}: published report is not usable by the website") + accept_status=0 + python3 "${SCRIPT_DIR}/port_status.py" accept --port "${port}" --report "${tmp_dir}/check.json" >/dev/null || accept_status=$? + if [ "${accept_status}" -ne 0 ]; then + problems+=("${port}: published report is $(describe_accept_status "${accept_status}")") continue fi generated="$(jq -r '.generated_at // empty' "${tmp_dir}/check.json" 2>/dev/null || true)" @@ -260,8 +291,9 @@ AGE # The publication gate deliberately tolerates an hour of clock skew, so a # report it accepted can legitimately carry a timestamp a little ahead of # now. Calling that unreadable here would fail the nightly job over a report - # the same run just published, until wall time caught up. Unparseable stays - # -1 from the helper above and is still a problem. + # the same run just published, until wall time caught up. An unparseable or + # zone-less stamp prints "unreadable" from the helper above and is still a + # problem. if [ "${age_seconds}" = "unreadable" ]; then problems+=("${port}: unreadable generated_at ${generated:-}") elif [ "${age_seconds}" -lt "-${future_skew_seconds}" ]; then diff --git a/scripts/hellocodenameone/conformance/port_status.py b/scripts/hellocodenameone/conformance/port_status.py index 4a4bd5037d4..d1d44227c20 100755 --- a/scripts/hellocodenameone/conformance/port_status.py +++ b/scripts/hellocodenameone/conformance/port_status.py @@ -591,6 +591,22 @@ def strict_report_errors(report: dict) -> list[str]: return errors +def describe_workload_gap(seen: list[str], expected: list[str]) -> str: + """Name what is missing or extra, not what happens to be fine. + + Printing the workloads that *are* covered leaves the reader to diff two + ten-item lists by eye to find the one that is not. + """ + absent = sorted(set(expected) - set(seen)) + unexpected = sorted(set(seen) - set(expected)) + parts = [] + if absent: + parts.append("missing " + ", ".join(absent)) + if unexpected: + parts.append("unexpected " + ", ".join(unexpected)) + return "; ".join(parts) if parts else "counts differ" + + def publishable_report_problems( manifest: dict, port_id: str, report: dict ) -> tuple[list[str], list[str]]: @@ -662,7 +678,12 @@ def publishable_report_problems( expected_summary = { key: statuses.get(key, 0) for key in ("pass", "fail", "skip", "not-run") } - if report.get("summary") != expected_summary and not drift: + # Checked even under drift. The summary counts the results the report + # actually carries, so it stays self-consistent whether or not the report + # predates a test -- suppressing this whenever any drift was seen let a + # genuinely malformed report be filed as mere drift and fall back quietly, + # which is the outcome the loud/quiet split exists to avoid. + if report.get("summary") != expected_summary: malformed.append("summary does not match the test results") expected_benchmarks = manifest.get("performance_benchmarks", []) @@ -700,6 +721,15 @@ def publishable_report_problems( malformed.append("performance results are not objects") return drift, malformed + # A workload is measured or skipped, never both. Unioning the keys let a + # report claim both for the same workload and still look complete, which + # hides the producer bug that wrote it twice. + both = sorted(set(benchmarks) & set(skipped)) + if both: + malformed.append( + "performance workloads both measured and skipped: " + ", ".join(both) + ) + # A port may legitimately skip a workload (the iOS simulator skips the # GC-footprint workloads); measured plus skipped has to cover the contract. accounted = sorted(set(benchmarks) | set(skipped)) @@ -707,7 +737,7 @@ def publishable_report_problems( if accounted != sorted(expected_benchmarks): malformed.append( "performance workloads do not match the contract: " - + ", ".join(accounted) + + describe_workload_gap(accounted, expected_benchmarks) ) else: # A crashed suite is allowed to leave workloads unrun, but not to lose @@ -724,7 +754,8 @@ def publishable_report_problems( covered = sorted(set(accounted) | set(declared_missing)) if covered != sorted(expected_benchmarks): malformed.append( - "performance workloads unaccounted for: " + ", ".join(covered) + "performance workloads unaccounted for: " + + describe_workload_gap(covered, expected_benchmarks) ) for name, measurement in benchmarks.items(): duration = measurement.get("duration_ns") if isinstance(measurement, dict) else None diff --git a/scripts/hellocodenameone/conformance/test_port_status.py b/scripts/hellocodenameone/conformance/test_port_status.py index db6a31541f7..6993a2d6278 100755 --- a/scripts/hellocodenameone/conformance/test_port_status.py +++ b/scripts/hellocodenameone/conformance/test_port_status.py @@ -331,12 +331,47 @@ def test_publishable_allows_a_little_clock_skew(self): def test_publishable_separates_contract_drift_from_a_broken_report(self): report = self.publishable_report("android") del report["tests"]["CameraApiTest"] + # The summary counts the results the report carries, so dropping a test + # means dropping its count too. Leaving it stale would now -- correctly + # -- be a malformed report rather than pure drift. + report["summary"]["pass"] -= 1 drift, malformed = port_status.publishable_report_problems( self.manifest, "android", report ) self.assertEqual([], malformed) self.assertIn("CameraApiTest", drift[0]) + def test_publishable_rejects_a_stale_summary_even_under_drift(self): + # Drift used to suppress the summary check entirely, so a genuinely + # broken report could be filed as drift and fall back quietly. + report = self.publishable_report("android") + del report["tests"]["CameraApiTest"] + + _, malformed = port_status.publishable_report_problems( + self.manifest, "android", report + ) + self.assertTrue(any("summary" in item for item in malformed), malformed) + + def test_publishable_rejects_a_workload_both_measured_and_skipped(self): + report = self.publishable_report("linux-x64") + report["performance"]["skipped"]["quicksort"] = "some-reason" + + _, malformed = port_status.publishable_report_problems( + self.manifest, "linux-x64", report + ) + self.assertTrue( + any("both measured and skipped" in item for item in malformed), malformed + ) + + def test_workload_gap_message_names_the_offender(self): + # The message must point at the workload that is wrong, not list the + # nine that are fine. + message = port_status.describe_workload_gap( + ["a", "c"], ["a", "b"] + ) + self.assertIn("missing b", message) + self.assertIn("unexpected c", message) + def test_publishable_rejects_unaccounted_and_unmeasured_workloads(self): report = self.publishable_report("linux-x64") del report["performance"]["benchmarks"]["quicksort"] diff --git a/scripts/website/validate_port_status.mjs b/scripts/website/validate_port_status.mjs index 41a4cd68877..d4532868933 100644 --- a/scripts/website/validate_port_status.mjs +++ b/scripts/website/validate_port_status.mjs @@ -191,10 +191,23 @@ function validate() { fail(`a cell claims a documented skip the errata do not cover: ${cell}`); } } + // Counted inside each documented-skip cell, not across the whole page. A + // page-wide tally also picks up the legend's own marker, so a cell that had + // lost its was masked by the legend and the check passed while the + // reader saw a green cell with nothing pointing at its explanation. + // // The production build minifies, which drops the quotes around attribute // values, so match the class without assuming them. - if (countMatches(page, /]*\bcn1-port-status__note\b/g) < notedCells.length) { - fail("documented-skip cells must carry a visible note marker"); + const notedCellBodies = Array.from( + page.matchAll(/]*\bdata-feature-cell\b)(?=[^>]*\bhas-documented-skip\b)[^>]*>([\s\S]*?)<\/td>/gi), + (match) => match[1]); + if (notedCellBodies.length !== notedCells.length) { + fail(`could not read the body of every documented-skip cell: ${notedCellBodies.length} of ${notedCells.length}`); + } + const unmarked = notedCellBodies.filter( + (body) => !/]*\bcn1-port-status__note\b/.test(body)).length; + if (unmarked > 0) { + fail(`${unmarked} documented-skip cell(s) carry no visible note marker`); } const manualRows = countMatches(page, /\bdata-manual-feature-row(?:=|\s|>)/g); diff --git a/vm/JavaAPI/src/java/time/DateTimeSupport.java b/vm/JavaAPI/src/java/time/DateTimeSupport.java index aef3a8541cb..391493e85f8 100644 --- a/vm/JavaAPI/src/java/time/DateTimeSupport.java +++ b/vm/JavaAPI/src/java/time/DateTimeSupport.java @@ -223,7 +223,14 @@ public static ZoneOffset offsetFromInstant(Instant instant, ZoneId zone) { LocalDate utcDate = LocalDate.ofEpochDay(epochDay); // Calendar.SUNDAY is 1 and epoch day 0 was a Thursday. int dayOfWeek = (int) floorMod(epochDay + 4, 7) + 1; - int offsetMillis = tz.getOffset(1 /* GregorianCalendar.AD */, utcDate.getYear(), + // getOffset takes an era plus a year *of that era*, not a proleptic ISO + // year. Passing AD with a non-positive year described a date that does + // not exist, so instants before 1 CE resolved against the wrong year. + // ISO 0 is 1 BC, ISO -1 is 2 BC, hence 1 - isoYear. + int isoYear = utcDate.getYear(); + int era = isoYear > 0 ? 1 /* GregorianCalendar.AD */ : 0 /* GregorianCalendar.BC */; + int yearOfEra = isoYear > 0 ? isoYear : 1 - isoYear; + int offsetMillis = tz.getOffset(era, yearOfEra, utcDate.getMonthValue() - 1, utcDate.getDayOfMonth(), dayOfWeek, millisOfDay); return ZoneOffset.ofTotalSeconds(offsetMillis / 1000); } From 54c3aba035d1e919c9ee5b1e446063568538ecb3 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:39:40 +0700 Subject: [PATCH 52/91] Match the JDK's custom time-zone IDs, and three more review findings Custom offset IDs were parsed far more loosely than java.util.TimeZone defines them, so the same string meant different things on different ports -- the split this whole PR exists to remove. "UTC+5" and "GMT+1:2" became real offsets here while JavaSE and Android answer plain GMT. Worse, rejecting them was not enough on its own. A rejected ID fell through to the platform natives, which speak POSIX tz syntax where the sign runs the other way, so "UTC+5" came back as five hours *west*: a ten-hour error against JavaSE for identical application code. A malformed offset ID now resolves to GMT, as it does everywhere else. CustomTimeZoneIdTest compares 30 IDs against the host JDK through the clean target. Writing it was worth it twice over: it caught the sign inversion above, and it caught me encoding "GMT+01:30:00" as valid on the strength of a JDK 25 probe -- the JDK the test actually runs answers GMT for it. JDKs disagree there, so the documented syntax (no seconds field) is the contract rather than whichever JDK the JavaSE side happens to run. publish_port_status.sh refused to publish any report whose performance run was not "complete", which silently undid the acceptance change: a crashed suite's report was accepted by the gate and then dropped here, leaving the table on the last green run. It now defers to port_status.py accept, so there is one acceptance rule rather than two opinions. MCPLoopbackSocketTransport: `listening` cannot represent an in-flight bind, so a close() landing inside Socket.listenLoopback saw null, skipped stop() and released the process-wide slot. A replacement could claim it before the original reached bound.stop(), and a connection the retired listener accepted then resolved `active` to the replacement -- which is not closed, so its attach() adopted the old listener's streams. A `binding` flag keeps the slot claimed until the in-flight bind has been stopped. Verified: 4678 core tests with SpotBugs at verify, and the Character, TimeApi and CustomTimeZone suites through ParparVM. Co-Authored-By: Claude Opus 5 (1M context) --- .../mcp/MCPLoopbackSocketTransport.java | 27 ++- .../conformance/publish_port_status.sh | 21 +- vm/JavaAPI/src/java/util/TimeZone.java | 112 ++++++++-- .../translator/CustomTimeZoneIdTest.java | 197 ++++++++++++++++++ .../tools/translator/CustomTimeZoneApp.java | 54 +++++ 5 files changed, 386 insertions(+), 25 deletions(-) create mode 100644 vm/tests/src/test/java/com/codename1/tools/translator/CustomTimeZoneIdTest.java create mode 100644 vm/tests/src/test/resources/com/codename1/tools/translator/CustomTimeZoneApp.java diff --git a/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java b/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java index e393f60522f..9050d70f374 100644 --- a/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java +++ b/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java @@ -70,6 +70,12 @@ public final class MCPLoopbackSocketTransport implements MCPTransport { private final int port; private final Object lock = new Object(); private Socket.StopListening listening; + /// True from just before Socket.listenLoopback is called until its result has been + /// published or stopped. `listening` cannot represent that interval -- the listener + /// does not exist yet -- but a close() landing inside it must still know a bind is + /// coming, or it releases the process-wide slot to a replacement while a listener + /// nobody has stopped is about to start accepting. + private boolean binding; private InputStream in; private OutputStream out; private boolean closed; @@ -135,9 +141,15 @@ public void open() throws IOException { // and the loser cleans up. Either close() sees a published listener and stops // it, or we see closed and stop it ourselves. Socket.StopListening bound; + synchronized (lock) { + binding = true; + } try { bound = Socket.listenLoopback(port, Connection.class); } catch (RuntimeException ex) { + synchronized (lock) { + binding = false; + } // Two things go wrong if this escapes. The process-wide registration would stay // pointing at a transport that never started listening, so every later open() // would refuse, believing an agent is already served. And the server's reader @@ -151,6 +163,7 @@ public void open() throws IOException { } boolean closedWhileBinding; synchronized (lock) { + binding = false; closedWhileBinding = closed; if (!closedWhileBinding) { listening = bound; @@ -159,7 +172,8 @@ public void open() throws IOException { if (closedWhileBinding) { // No null check on `bound`: the only way past the try above is with a // listener in hand, and SpotBugs flags the redundant test. Stop before - // releasing the slot, for the reason close() gives. + // releasing the slot, for the reason close() gives. close() deliberately + // left the slot claimed for us precisely so this stop() happens first. bound.stop(); clearActiveIfOurs(); throw new IOException("This MCP socket transport was closed before it began listening"); @@ -402,8 +416,10 @@ public void close() { // takes the lock on its way out. InputStream is; // NOPMD closed below, deliberately outside the lock OutputStream os; // NOPMD closed below, outside the lock + boolean bindInFlight; synchronized (lock) { closed = true; + bindInFlight = binding; l = listening; listening = null; is = in; @@ -422,7 +438,14 @@ public void close() { if (l != null) { l.stop(); } - clearActiveIfOurs(); + if (!bindInFlight) { + clearActiveIfOurs(); + } + // Otherwise the slot stays claimed until the in-flight open() has stopped the + // listener it is about to receive. Releasing it here would let a replacement + // transport take the slot first, and a connection the old listener accepts in + // that window resolves `active` to the replacement -- which is not closed, so + // its attach() would adopt the retired listener's streams. // Closing the output as well as the input: forgetting the field is not enough, // because a writer that already captured it would go on writing to a session that // has ended, and the socket would stay open until the connection callback unwound. diff --git a/scripts/hellocodenameone/conformance/publish_port_status.sh b/scripts/hellocodenameone/conformance/publish_port_status.sh index a4f676acfaa..c0611aa6d6d 100755 --- a/scripts/hellocodenameone/conformance/publish_port_status.sh +++ b/scripts/hellocodenameone/conformance/publish_port_status.sh @@ -17,10 +17,25 @@ if [ "${PORT_STATUS_PUBLISH:-}" != "1" ] && { [ "${GITHUB_ACTIONS:-}" != "true" fi branch="port-status-data" +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" port="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1], encoding="utf-8"))["port"])' "$report")" -performance_status="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1], encoding="utf-8")).get("performance", {}).get("status", "missing"))' "$report")" -if [ "$performance_status" != "complete" ]; then - echo "Not publishing incomplete ${port} performance data (${performance_status}); preserving the last complete report." + +# One acceptance rule, shared with the backfill sweep, rather than a second +# opinion here. This used to refuse anything whose performance run was not +# "complete" -- but a suite that crashes never reaches the benchmark, so its +# report is partial by construction and that rule dropped precisely the reports +# carrying fail / not-run evidence, leaving the table on the last green one. +# port_status.py decides; a partial performance section is simply not presented +# as performance. +accept_status=0 +python3 "${script_dir}/port_status.py" accept --port "${port}" --report "${report}" || accept_status=$? +if [ "${accept_status}" -ne 0 ]; then + case "${accept_status}" in + 11) reason="built against a different test contract; waiting for a run on the current one" ;; + 12) reason="not usable by the website" ;; + *) reason="rejected by the publication gate (status ${accept_status})" ;; + esac + echo "Not publishing the ${port} report: ${reason}; preserving the last published one." exit 0 fi diff --git a/vm/JavaAPI/src/java/util/TimeZone.java b/vm/JavaAPI/src/java/util/TimeZone.java index 5e155418df5..9325c06d5f6 100644 --- a/vm/JavaAPI/src/java/util/TimeZone.java +++ b/vm/JavaAPI/src/java/util/TimeZone.java @@ -182,6 +182,14 @@ public static java.util.TimeZone getTimeZone(final java.lang.String ID){ if (custom != null) { return custom; } + if (isOffsetIdAttempt(ID)) { + // A malformed offset ID resolves to GMT, as it does on every other + // port. Handing it to the platform instead was actively harmful: + // the POSIX tz syntax the natives speak reads the sign the other way + // round, so "UTC+5" came back as five hours *west* -- a ten-hour + // error against JavaSE for the same string. + return GMT; + } if (ID.equalsIgnoreCase(getTimezoneId())) { return getDefault(); } else { @@ -257,45 +265,109 @@ private static TimeZone customTimeZone(String ID) { if (sign != '+' && sign != '-') { return null; } + // Only "GMT" takes an offset suffix. java.util.TimeZone defines the + // custom-ID syntax on GMT alone, so JavaSE and Android answer plain GMT + // for "UTC+5" / "UT+5"; accepting them here gave the same ID a different + // offset depending on the port. + if (index != 3 || !ID.startsWith("GMT")) { + return null; + } String digits = ID.substring(index + 1); + String hourPart; + String minutePart; int colon = digits.indexOf(':'); - String hourPart = colon < 0 ? digits : digits.substring(0, colon); - String rest = colon < 0 ? "" : digits.substring(colon + 1); - String minutePart = "0"; - String secondPart = "0"; if (colon < 0) { - // The colon-less forms are h, hh, hmm, hhmm and hhmmss: the last two - // digits are always the minutes once there are more than two, so a - // one-digit hour ("GMT+012" is UTC+00:12) splits the same way. + // Colon-less forms are h, hh, hmm and hhmm only. The five- and + // six-digit forms this used to accept ("GMT+013000") are not custom + // IDs at all -- the JDK falls back to GMT for them. int length = digits.length(); - if (length == 3 || length == 4 || length == 6) { - int hourDigits = length == 6 ? 2 : length - 2; - hourPart = digits.substring(0, hourDigits); - minutePart = digits.substring(hourDigits, hourDigits + 2); - secondPart = length == 6 ? digits.substring(4, 6) : "0"; + if (length == 1 || length == 2) { + hourPart = digits; + minutePart = "0"; + } else if (length == 3 || length == 4) { + hourPart = digits.substring(0, length - 2); + minutePart = digits.substring(length - 2); + } else { + return null; } } else { - int secondColon = rest.indexOf(':'); - minutePart = secondColon < 0 ? rest : rest.substring(0, secondColon); - secondPart = secondColon < 0 ? "0" : rest.substring(secondColon + 1); + String rest = digits.substring(colon + 1); + hourPart = digits.substring(0, colon); + // No seconds field. java.util.TimeZone documents the custom syntax + // as GMT Sign Hours [: Minutes] and nothing more, and JDKs disagree + // in practice -- "GMT+01:30:00" is GMT on 17 and GMT+01:30 on 25 -- + // so honouring it would make this port track whichever JDK the + // JavaSE side happened to run. The documented form is the contract. + if (rest.indexOf(':') >= 0) { + return null; + } + minutePart = rest; + // Hours may be one or two digits, minutes must be exactly two -- + // "GMT+1:2" is not a custom ID, and treating it as UTC+01:02 put + // this port an hour and two minutes away from every other one. + if (hourPart.length() < 1 || hourPart.length() > 2 || minutePart.length() != 2) { + return null; + } + } + if (!isDigits(hourPart) || !isDigits(minutePart)) { + return null; } int hours; int minutes; - int seconds; try { hours = Integer.parseInt(hourPart); minutes = Integer.parseInt(minutePart); - seconds = Integer.parseInt(secondPart); } catch (NumberFormatException notCustom) { return null; } - if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59 || seconds < 0 || seconds > 59) { + if (hours > 23 || minutes > 59) { return null; } - int offset = ((hours * 60 + minutes) * 60 + seconds) * 1000; - return new SimpleTimeZone(sign == '-' ? -offset : offset, ID); + int offset = (hours * 60 + minutes) * 60 * 1000; + // Normalized like the JDK's, so the same custom ID reports the same + // getID() on every port rather than echoing whichever spelling was used. + String canonical = "GMT" + sign + + (hours < 10 ? "0" : "") + hours + ":" + + (minutes < 10 ? "0" : "") + minutes; + return new SimpleTimeZone(sign == '-' ? -offset : offset, canonical); } + /** + * True when the ID reads as an attempt at a GMT/UT/UTC offset ID, well + * formed or not. Those never name a zone in the platform database, so a + * malformed one is GMT rather than something for the natives to guess at. + */ + private static boolean isOffsetIdAttempt(String ID) { + int index; + if (ID.regionMatches(true, 0, "GMT", 0, 3) || ID.regionMatches(true, 0, "UTC", 0, 3)) { + index = 3; + } else if (ID.regionMatches(true, 0, "UT", 0, 2)) { + index = 2; + } else { + return false; + } + if (index >= ID.length()) { + return false; + } + char sign = ID.charAt(index); + return sign == '+' || sign == '-'; + } + + /** True when every character is an ASCII digit and there is at least one. */ + private static boolean isDigits(String value) { + if (value.length() == 0) { + return false; + } + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c < '0' || c > '9') { + return false; + } + } + return true; + } + + /** * Queries if this time zone uses Daylight Savings Time. */ diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/CustomTimeZoneIdTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/CustomTimeZoneIdTest.java new file mode 100644 index 00000000000..02d87030314 --- /dev/null +++ b/vm/tests/src/test/java/com/codename1/tools/translator/CustomTimeZoneIdTest.java @@ -0,0 +1,197 @@ +/* + * Copyright (c) 2026, 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 org.junit.jupiter.api.Test; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +/** + * Pins vm/JavaAPI's custom GMT-offset id parsing against the JDK. + * + * java.util.TimeZone defines the custom-id syntax on GMT alone and in fixed + * field widths. This parser was looser -- it accepted "UTC+5", "GMT+1:2" and + * six-digit "GMT+013000" -- so the same id produced one offset here and a plain + * GMT elsewhere. Since the JavaSE and Android ports reach the JDK's parser + * directly, that is a cross-port split for identical application code, which is + * exactly what this suite exists to catch. + */ +class CustomTimeZoneIdTest { + + @Test + void customTimeZoneIdsResolveAsTheJdkDoes() throws Exception { + Parser.cleanup(); + + Path sourceDir = Files.createTempDirectory("custom-timezone-sources"); + Path classesDir = Files.createTempDirectory("custom-timezone-classes"); + Path javaApiDir = Files.createTempDirectory("custom-timezone-japi"); + + Path source = sourceDir.resolve("CustomTimeZoneApp.java"); + Files.write(source, loadAppSource().getBytes(StandardCharsets.UTF_8)); + + CompilerHelper.CompilerConfig config = selectCompiler(); + if (config == null) { + fail("No compatible compiler available for the Latin-1 Character test"); + } + + assertTrue(CompilerHelper.isJavaApiCompatible(config), + "JDK " + config.jdkVersion + " must target matching bytecode level for JavaAPI"); + + CompilerHelper.compileJavaAPI(javaApiDir, config); + + List compileArgs = new ArrayList<>(); + compileArgs.add("-source"); + compileArgs.add(config.targetVersion); + compileArgs.add("-target"); + compileArgs.add(config.targetVersion); + if (CompilerHelper.useClasspath(config)) { + compileArgs.add("-classpath"); + compileArgs.add(javaApiDir.toString()); + } else { + compileArgs.add("-bootclasspath"); + compileArgs.add(javaApiDir.toString()); + compileArgs.add("-Xlint:-options"); + } + compileArgs.add("-d"); + compileArgs.add(classesDir.toString()); + compileArgs.add(source.toString()); + + int compileResult = CompilerHelper.compile(config.jdkHome, compileArgs); + assertEquals(0, compileResult, + "CustomTimeZoneApp should compile. " + CompilerHelper.getLastErrorLog()); + + String jdkResult = extractResultLine(runJavaMain(config, classesDir)); + assertTrue(jdkResult.startsWith("RESULT="), + "The JDK leg should produce a RESULT line"); + + CompilerHelper.copyDirectory(javaApiDir, classesDir); + + Path outputDir = Files.createTempDirectory("custom-timezone-output"); + CleanTargetIntegrationTest.runTranslator(classesDir, outputDir, "CustomTimeZoneApp"); + + Path distDir = outputDir.resolve("dist"); + Path cmakeLists = distDir.resolve("CMakeLists.txt"); + assertTrue(Files.exists(cmakeLists), "Translator should emit a CMake project"); + + CleanTargetIntegrationTest.replaceLibraryWithExecutableTarget(cmakeLists, "CustomTimeZoneApp-src"); + + Path buildDir = distDir.resolve("build"); + Files.createDirectories(buildDir); + + CleanTargetIntegrationTest.runCommand(Arrays.asList( + "cmake", + "-S", distDir.toString(), + "-B", buildDir.toString(), + "-DCMAKE_C_COMPILER=clang", + "-DCMAKE_OBJC_COMPILER=clang" + ), distDir); + + CleanTargetIntegrationTest.runCommand(Arrays.asList("cmake", "--build", buildDir.toString()), distDir); + + Path executable = buildDir.resolve("CustomTimeZoneApp"); + assertTrue(Files.exists(executable), "ParparVM build should produce a runnable executable"); + + String vmResult = extractResultLine( + CleanTargetIntegrationTest.runCommand(Arrays.asList(executable.toString()), buildDir)); + + assertEquals(jdkResult, vmResult, firstDifference(jdkResult, vmResult)); + } + + /** Names the offending id rather than dumping two long strings. */ + private String firstDifference(String expected, String actual) { + String[] want = expected.substring("RESULT=".length()).split(";"); + String[] got = actual.substring(actual.indexOf('=') + 1).split(";"); + for (int i = 0; i < Math.min(want.length, got.length); i++) { + if (!want[i].equals(got[i])) { + return "Custom time zone offsets differ: JDK " + want[i] + + ", ParparVM " + got[i] + " (offsets in milliseconds)"; + } + } + return "ParparVM should resolve custom GMT offset ids as the JDK does"; + } + + private String loadAppSource() throws Exception { + java.io.InputStream in = CustomTimeZoneIdTest.class.getResourceAsStream( + "/com/codename1/tools/translator/CustomTimeZoneApp.java"); + assertNotNull(in, "CustomTimeZoneApp.java test resource should exist"); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { + return reader.lines().collect(Collectors.joining("\n")) + "\n"; + } + } + + private String runJavaMain(CompilerHelper.CompilerConfig config, Path classesDir) throws Exception { + String javaExe = config.jdkHome.resolve("bin").resolve("java").toString(); + if (System.getProperty("os.name").toLowerCase().contains("win")) { + javaExe += ".exe"; + } + + ProcessBuilder pb = new ProcessBuilder(javaExe, "-cp", classesDir.toString(), "CustomTimeZoneApp"); + pb.redirectErrorStream(true); + + Process process = pb.start(); + String output; + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + output = reader.lines().collect(Collectors.joining("\n")); + } + + int exitCode = process.waitFor(); + assertEquals(0, exitCode, "JVM run should exit cleanly. Output: " + output); + return output; + } + + private String extractResultLine(String output) { + for (String line : output.split("\\R")) { + if (line.startsWith("RESULT=")) { + return line.trim(); + } + } + return ""; + } + + private CompilerHelper.CompilerConfig selectCompiler() { + String[] preferredTargets = {"11", "17", "21", "25", "1.8"}; + for (String target : preferredTargets) { + List configs = CompilerHelper.getAvailableCompilers(target); + for (CompilerHelper.CompilerConfig config : configs) { + if (CompilerHelper.isJavaApiCompatible(config)) { + return config; + } + } + } + return null; + } +} diff --git a/vm/tests/src/test/resources/com/codename1/tools/translator/CustomTimeZoneApp.java b/vm/tests/src/test/resources/com/codename1/tools/translator/CustomTimeZoneApp.java new file mode 100644 index 00000000000..24229a89b21 --- /dev/null +++ b/vm/tests/src/test/resources/com/codename1/tools/translator/CustomTimeZoneApp.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2026, 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. + */ +/** + * Prints the id and raw offset java.util.TimeZone resolves for a set of custom + * GMT-offset ids, including malformed ones. + * + * CustomTimeZoneIdTest runs this on the host JDK and through ParparVM and + * requires identical output. Only offset ids appear here -- a named zone would + * depend on the platform tz database rather than on the custom-id parser. + */ +public class CustomTimeZoneApp { + + private static final String[] IDS = { + "GMT", "GMT+0", "GMT-0", "GMT+5", "GMT-8", "GMT+10", "GMT+23", + "GMT+000", "GMT+123", "GMT+0130", "GMT+2359", + "GMT+01:02", "GMT+5:00", "GMT+23:59", "GMT-08:00", "GMT+00:00", + // "GMT+01:30:00" is deliberately absent: JDK 17 answers GMT and JDK 25 + // answers GMT+01:30 for it, so it cannot serve as a stable oracle. The + // documented syntax has no seconds field and this port rejects it. + // Each of these is malformed and must not become an offset. + "GMT+1:2", "GMT+05:0", "GMT+013000", "GMT+00000", "GMT+12345", + "GMT+24", "GMT+1:60", "GMT+23:60", "GMT+01:30:99", "GMT+01:30xyz", + "GMT+1x", "GMT+", "UTC+5", "UT+5" + }; + + public static void main(String[] args) { + StringBuilder sb = new StringBuilder("RESULT="); + for (int i = 0; i < IDS.length; i++) { + java.util.TimeZone tz = java.util.TimeZone.getTimeZone(IDS[i]); + sb.append(IDS[i]).append('=').append(tz.getRawOffset()).append(';'); + } + System.out.println(sb.toString()); + } +} From f26f73dec3f9a5e40a0887069ef7e8f0328a900a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 4 Aug 2026 00:20:49 +0700 Subject: [PATCH 53/91] Keep close() synchronous while still retiring an in-flight bind My previous commit fixed the reviewed race by having close() leave the process-wide slot claimed for the in-flight open() to release. That traded one race for a worse one: releasing the slot became asynchronous, so MCP.stop() could return with the registration still held, and the next startSocketServer was refused with "already open on port 47899". CI caught it as an order-dependent failure in MCPReleaseBuildGateTest -> MCPLoopbackTransportOpenTest, which is exactly the class-order flakiness the latter test was written to pin. close() now waits for the bind to settle instead of deferring cleanup to another thread. open() marks it settled only after it has stopped the listener and released the slot, so when close() returns the slot really is free -- the contract MCP.stop() and every test that restarts a server depend on -- and the reviewed window stays shut, because the slot is never free while a listener nobody has stopped is about to accept. The wait is bounded at five seconds so a wedged platform bind cannot hang stop(). Verified: MCPReleaseBuildGateTest and MCPLoopbackTransportOpenTest pass in one JVM in the order that failed, and the full 4678 with SpotBugs at verify. Co-Authored-By: Claude Opus 5 (1M context) --- .../mcp/MCPLoopbackSocketTransport.java | 66 ++++++++++++++----- 1 file changed, 49 insertions(+), 17 deletions(-) diff --git a/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java b/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java index 9050d70f374..fd59649e6be 100644 --- a/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java +++ b/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java @@ -147,39 +147,70 @@ public void open() throws IOException { try { bound = Socket.listenLoopback(port, Connection.class); } catch (RuntimeException ex) { - synchronized (lock) { - binding = false; - } + clearActiveIfOurs(); + settleBind(); // Two things go wrong if this escapes. The process-wide registration would stay // pointing at a transport that never started listening, so every later open() // would refuse, believing an agent is already served. And the server's reader // thread only handles IOException around open(), so a runtime exception would // kill that thread before it could clear its running flag, leaving the server // permanently "running" with nothing behind it. - clearActiveIfOurs(); IOException failure = new IOException("Failed to listen on loopback port " + port); failure.initCause(ex); throw failure; } boolean closedWhileBinding; synchronized (lock) { - binding = false; closedWhileBinding = closed; if (!closedWhileBinding) { listening = bound; + settleBind(); } } if (closedWhileBinding) { // No null check on `bound`: the only way past the try above is with a // listener in hand, and SpotBugs flags the redundant test. Stop before - // releasing the slot, for the reason close() gives. close() deliberately - // left the slot claimed for us precisely so this stop() happens first. + // releasing the slot, for the reason close() gives -- and only mark the + // bind settled afterwards, because a close() waiting on it treats that + // as "the listener this transport produced is dealt with". bound.stop(); clearActiveIfOurs(); + settleBind(); throw new IOException("This MCP socket transport was closed before it began listening"); } } + /// How long close() will wait for an in-flight bind to resolve. Bounded so a wedged + /// platform bind cannot hang the caller of stop() indefinitely; on expiry close() + /// carries on and releases the slot, which is the old behaviour. + private static final long BIND_SETTLE_TIMEOUT_MS = 5000; + + /// Marks the in-flight bind resolved and wakes any close() waiting on it. Call only + /// once the listener the bind produced has been published or stopped. + private void settleBind() { + synchronized (lock) { + binding = false; + lock.notifyAll(); + } + } + + /// Waits for an in-flight bind to resolve. Caller must hold `lock`. + private void awaitBindSettled() { + long deadline = System.currentTimeMillis() + BIND_SETTLE_TIMEOUT_MS; + while (binding) { + long remaining = deadline - System.currentTimeMillis(); + if (remaining <= 0) { + return; + } + try { + lock.wait(remaining); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return; + } + } + } + /// Releases the process-wide registration, but only when it is still this transport's. private void clearActiveIfOurs() { synchronized (MCPLoopbackSocketTransport.class) { @@ -416,10 +447,18 @@ public void close() { // takes the lock on its way out. InputStream is; // NOPMD closed below, deliberately outside the lock OutputStream os; // NOPMD closed below, outside the lock - boolean bindInFlight; synchronized (lock) { closed = true; - bindInFlight = binding; + // Wait out an in-flight bind before touching the registration. The listener + // does not exist yet, so there is nothing to stop and nothing `listening` can + // record; releasing the slot now would let a replacement claim it while a + // listener nobody has stopped is about to start accepting, and a connection + // it accepted would resolve `active` to that replacement -- which is not + // closed, so its attach() would adopt these streams. open() marks the bind + // settled only after it has stopped the listener and released the slot, so + // waiting here keeps close() synchronous: the slot really is free when it + // returns, which MCP.stop() and every test that restarts a server rely on. + awaitBindSettled(); l = listening; listening = null; is = in; @@ -438,14 +477,7 @@ public void close() { if (l != null) { l.stop(); } - if (!bindInFlight) { - clearActiveIfOurs(); - } - // Otherwise the slot stays claimed until the in-flight open() has stopped the - // listener it is about to receive. Releasing it here would let a replacement - // transport take the slot first, and a connection the old listener accepts in - // that window resolves `active` to the replacement -- which is not closed, so - // its attach() would adopt the retired listener's streams. + clearActiveIfOurs(); // Closing the output as well as the input: forgetting the field is not enough, // because a writer that already captured it would go on writing to a session that // has ended, and the socket would stay open until the connection callback unwound. From 68d317d9a7400c7d573035b93beb7dc263fb7019 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 4 Aug 2026 00:58:10 +0700 Subject: [PATCH 54/91] Give the Android packager enough heap to package the app "Build Android Default: 8" failed on :app:packageDebug. The --stacktrace I added earlier for exactly this -- the task had been failing intermittently reporting only "A failure occurred while executing PackageAndroidArtifact$IncrementalSplitterRunnable", with no cause -- finally printed one: Caused by: java.lang.OutOfMemoryError: Java heap space at com.android.zipflinger.BytesSource.(BytesSource.java:49) AndroidGradleBuilder writes org.gradle.jvmargs=-Xmx2048m into every generated project. Packaging runs the zipflinger splitter in a Gradle worker that inherits those args and holds each entry's bytes in memory, and an app carrying the larger native libraries -- ARCore, barhopper, the face and image detectors, all listed in the "unable to strip" warning just above the failure -- does not fit. 2048m was marginal rather than wrong, which is why master passes and a branch that adds a few classes tips over: an intermittent failure that is really a ceiling sitting just under what the build needs. Raised to 4096m. -Xmx is a ceiling, not a reservation, so builds that never needed the headroom are unaffected. This lands in the generated project for every Codename One Android build, not just CI, which is where the ceiling was actually being hit. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/builders/AndroidGradleBuilder.java | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index ecbf3da6312..705a97d3fcd 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -5947,11 +5947,23 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) { if(useGradle8 || request.getArg("android.forceJava8Builder", "false").equals("true")) { gradlePropertiesObject.setProperty("org.gradle.java.home", getGradleJavaHome()); } + // 4096m rather than 2048m. Packaging runs the zipflinger splitter inside a + // Gradle worker that inherits these args, and it holds each entry's bytes in + // memory -- an app carrying the larger native libraries (ARCore, barhopper, + // the face and image detectors) exhausted 2048m and failed :app:packageDebug + // with a bare "A failure occurred while executing + // PackageAndroidArtifact$IncrementalSplitterRunnable". The underlying + // "java.lang.OutOfMemoryError: Java heap space" only appears with + // --stacktrace, which is why this read as an unexplained intermittent build + // failure. -Xmx is a ceiling, not a reservation, so raising it costs nothing + // on builds that never needed the headroom. + String heapArgs = "-Xmx4096m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8"; if (useGradle8) { - gradlePropertiesObject.setProperty("org.gradle.jvmargs", "-Xmx2048m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8"); + gradlePropertiesObject.setProperty("org.gradle.jvmargs", heapArgs); gradleWrapperPropertiesObject.setProperty("distributionUrl", gradle8DistributionUrl); } else { - gradlePropertiesObject.setProperty("org.gradle.jvmargs", "-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8"); + gradlePropertiesObject.setProperty("org.gradle.jvmargs", + "-Xmx4096m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8"); gradleWrapperPropertiesObject.setProperty("distributionUrl", gradleDistributionUrl); } if (useAndroidX) { From 7a977f9c36e49ec2c4fd016db86ccd789a00d9fc Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:11:26 +0700 Subject: [PATCH 55/91] Credit each port's report to the run it actually came from The sweep merges reports across candidate runs on purpose -- a failed matrix leg uploads only some ports, so the next candidate supplies the rest -- but run_id held whichever candidate was examined last. Every "Publishing X from run N" line then named that one run, so a port whose report came from an older candidate was credited to a run that never produced it. Misleading exactly when someone is chasing down where a bad report came from. Record the source run per port as the reports are merged, and report that. Co-Authored-By: Claude Opus 5 (1M context) --- .../conformance/backfill_port_status.sh | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/scripts/hellocodenameone/conformance/backfill_port_status.sh b/scripts/hellocodenameone/conformance/backfill_port_status.sh index e10557224f1..68e52cbc8a6 100755 --- a/scripts/hellocodenameone/conformance/backfill_port_status.sh +++ b/scripts/hellocodenameone/conformance/backfill_port_status.sh @@ -203,6 +203,11 @@ while IFS= read -r workflow; do fi cp "${downloaded}" "${download_dir}/port-status-${found}.json" : > "${download_dir}/covered-${found}" + # Remember which run this port's report actually came from. Reports are + # merged across candidates on purpose, so a single run_id would credit + # every port to whichever candidate happened to be examined last -- + # misleading exactly when someone is chasing down a bad report. + printf '%s' "${candidate}" > "${download_dir}/source-run-${found}" done < <(find "${download_dir}/run-${candidate}" -type f -name 'port-status-*.json' | sort) fi done @@ -217,6 +222,10 @@ while IFS= read -r workflow; do echo "Ignoring ${report}: it names no port." >&2 continue fi + source_run="${run_id}" + if [ -f "${download_dir}/source-run-${port}" ]; then + source_run="$(cat "${download_dir}/source-run-${port}")" + fi # Publish only what the website will actually serve. A report built against # an older contract passes the freshness check below but is rejected by the # sync, which would leave the public column on its stale fallback while @@ -224,7 +233,7 @@ while IFS= read -r workflow; do accept_status=0 python3 "${SCRIPT_DIR}/port_status.py" accept --port "${port}" --report "${report}" || accept_status=$? if [ "${accept_status}" -ne 0 ]; then - echo "Not publishing the ${port} report from run ${run_id}: $(describe_accept_status "${accept_status}")." >&2 + echo "Not publishing the ${port} report from run ${source_run}: $(describe_accept_status "${accept_status}")." >&2 continue fi generated="$(jq -r '.generated_at // empty' "${report}")" @@ -241,7 +250,7 @@ while IFS= read -r workflow; do skipped=$((skipped + 1)) continue fi - echo "Publishing ${port} from run ${run_id} of ${workflow} (${generated})." + echo "Publishing ${port} from run ${source_run} of ${workflow} (${generated})." PORT_STATUS_PUBLISH=1 "${SCRIPT_DIR}/publish_port_status.sh" "${report}" published=$((published + 1)) done < <(find "${download_dir}" -maxdepth 1 -type f -name 'port-status-*.json' | sort) From dc68495376189c7f9b04e29439b63a2a42da8971 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:55:17 +0700 Subject: [PATCH 56/91] Close the review round: bind window, verify errors, and stale-report paths MCP: `binding` was raised after the slot was claimed, on a different lock, so a close() landing between the two saw no bind in flight and released `active` while this open() went on to bind. Raised before the claim now, making the interval from "the slot is ours" to "the bind is resolved" continuous. The reviewer also asked that a bind-wait timeout keep the slot claimed. I tried that and it is the worse trade: any bind that never resolves then strands the registration for the life of the process, so the server can never be restarted. It failed MCPReleaseBuildGateTest outright -- a certain, reproducible failure against a race needing a bind to hang past five seconds while already accepting. The slot is released on timeout, with a logged warning; the in-flight open() still stops its listener the moment it sees `closed`, so the exposure is that interval and not the process lifetime. Linux verify conflated three outcomes. EVP_DigestVerifyInit failing meant nothing was verified, and EVP_DigestVerify is tri-state -- negative is an internal error, not a bad signature -- yet both returned a bare false, saying "invalid signature" about a signature never examined. Each records an error now, and the Java side already turns a recorded error into a CryptoException. TimeZone: "GMTZ" / "UTCZ" / "UTZ" name no zone, but this manufactured a SimpleTimeZone keeping that spelling, so getID() and equals() disagreed with JavaSE and Android while the offset matched. They return GMT now. The test compares ids as well as offsets -- which immediately caught GMT+00000, where JDK 11 and 17 answer GMT+00:00 and 21 and 25 answer GMT, so it is excluded as an unstable oracle and this port follows the documented four-digit maximum. Three stale-report paths, all the same shape as the compare-comment fix: windows-cross-build-run.yml skipped its reporting job on failure exactly as Linux did; the Linux job skipped normalization entirely when a leg crashed before its first PNG, though the app log it needs was uploaded; and the sweep logged a malformed newest report and quietly served an older one, staying green over a producer defect -- it now publishes the older report and then fails. The page rendered a crashed run's partial benchmark timings identically to a finished run's, which undid the "partial performance is not presentable" rule the acceptance gate states. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/linux-build-run.yml | 17 ++++- .github/workflows/windows-cross-build-run.yml | 12 +++- .../mcp/MCPLoopbackSocketTransport.java | 72 +++++++++++++++---- .../nativeSources/cn1_linux_crypto.c | 24 +++++-- .../website/layouts/_default/port-status.html | 20 +++++- .../conformance/backfill_port_status.sh | 21 ++++++ vm/JavaAPI/src/java/util/TimeZone.java | 7 +- .../tools/translator/CustomTimeZoneApp.java | 14 +++- 8 files changed, 157 insertions(+), 30 deletions(-) diff --git a/.github/workflows/linux-build-run.yml b/.github/workflows/linux-build-run.yml index abc047fd5c0..1519f1ec9e0 100644 --- a/.github/workflows/linux-build-run.yml +++ b/.github/workflows/linux-build-run.yml @@ -460,7 +460,20 @@ jobs: [ -f "$f" ] || continue entries+=("$(basename "$f" .png)=$f") done - [ ${#entries[@]} -eq 0 ] && continue + if [ ${#entries[@]} -eq 0 ]; then + # A leg that crashed or timed out before its first PNG still uploads + # app-output.log, and that log is precisely what normalization reads to + # produce the fail / not-run counts. Skipping the arch outright meant the + # run that most needed reporting produced none, so the table went on + # serving the previous -- green -- report. Carry on with an empty entry + # list when there is a log to read; only a leg that produced nothing at + # all has nothing to say. + if [ ! -f "$raw/app-output.log" ]; then + echo "[linux-gtk-$arch] no screenshots and no app log; nothing to normalize" + continue + fi + echo "[linux-gtk-$arch] no screenshots captured; normalizing from the app log so the failure reaches the table" + fi if [ "$arch" = "arm64" ]; then REF="scripts/linux/screenshots-arm"; else REF="scripts/linux/screenshots"; fi echo "Posting ${#entries[@]} screenshot(s) for $arch (baseline $REF)" mkdir -p "$ART/previews-$arch" @@ -476,7 +489,7 @@ jobs: "Native Linux port ($arch)" \ "$ART/compare-$arch.json" "$ART/summary-$arch.txt" "$ART/comment-$arch.md" \ "$(pwd)/$REF" "$ART/previews-$arch" "$ART" \ - "${entries[@]}" + ${entries[@]+"${entries[@]}"} gate_rc=$? set -e if [ "$gate_rc" -ne 0 ]; then diff --git a/.github/workflows/windows-cross-build-run.yml b/.github/workflows/windows-cross-build-run.yml index 6b290a2cffc..de4f14579f8 100644 --- a/.github/workflows/windows-cross-build-run.yml +++ b/.github/workflows/windows-cross-build-run.yml @@ -265,7 +265,17 @@ jobs: compare-comment: name: cross-compiled screenshot-comment needs: run-on-windows - if: github.event_name == 'pull_request' || github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + # !cancelled(), for the same reason as linux-build-run.yml: without it GitHub + # skips this job whenever run-on-windows fails, so the run that most needs + # reporting uploads raw evidence but no port-status artifact. The nightly + # sweep then falls back to an older run, and while that one is still inside + # the freshness window the public table keeps showing it -- the current + # Windows failure masked by a stale pass. The workflow still goes red because + # run-on-windows itself failed. + if: >- + !cancelled() && + (github.event_name == 'pull_request' || github.event_name == 'push' || + github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') runs-on: ubuntu-latest permissions: contents: read diff --git a/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java b/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java index fd59649e6be..839e329e543 100644 --- a/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java +++ b/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java @@ -116,13 +116,32 @@ public void open() throws IOException { throw new IOException("This platform cannot bind a loopback server socket, so an " + "MCP agent cannot attach to it"); } - synchronized (MCPLoopbackSocketTransport.class) { - // Identity, not equality: is the open transport a DIFFERENT instance? - if (active != null && active != this) { // NOPMD identity is the question - throw new IOException("Another MCP socket transport is already open on port " - + active.port); + // Declared before the slot is claimed, not after the claim and before the + // bind. Those were two separate critical sections on two different locks, so + // a close() landing between them saw no bind in flight, released `active`, + // and a replacement could claim the slot while this open() went on to bind -- + // the very window the flag exists to cover. Raising it first makes the + // interval from "the slot is ours" to "the bind is resolved" continuous. + synchronized (lock) { + binding = true; + } + boolean claimed = false; + try { + synchronized (MCPLoopbackSocketTransport.class) { + // Identity, not equality: is the open transport a DIFFERENT instance? + if (active != null && active != this) { // NOPMD identity is the question + throw new IOException("Another MCP socket transport is already open on port " + + active.port); + } + active = this; + claimed = true; + } + } finally { + if (!claimed) { + // Refused the slot, so there is no bind coming; release any close() + // that is waiting on one. + settleBind(); } - active = this; } // open() runs on the server's reader thread, so a stop() from another thread can // already have closed this transport, or close it while we are binding. Two @@ -141,9 +160,6 @@ public void open() throws IOException { // and the loser cleans up. Either close() sees a published listener and stops // it, or we see closed and stop it ourselves. Socket.StopListening bound; - synchronized (lock) { - binding = true; - } try { bound = Socket.listenLoopback(port, Connection.class); } catch (RuntimeException ex) { @@ -194,21 +210,34 @@ private void settleBind() { } } - /// Waits for an in-flight bind to resolve. Caller must hold `lock`. - private void awaitBindSettled() { + /// Waits for an in-flight bind to resolve. Caller must hold `lock`. Returns false + /// when the wait expired or was interrupted with the bind still running, which + /// tells close() it may not release the registration. + private boolean awaitBindSettled() { long deadline = System.currentTimeMillis() + BIND_SETTLE_TIMEOUT_MS; while (binding) { long remaining = deadline - System.currentTimeMillis(); if (remaining <= 0) { - return; + return false; } try { lock.wait(remaining); } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); - return; + return false; } } + return true; + } + + /// Logs without letting a logging failure escape. close() runs from stop() and + /// from teardown paths where the platform implementation may not be registered. + private static void logQuietly(String message) { + try { + Log.p(message, Log.WARNING); + } catch (Throwable loggingFailed) { + System.err.println("[cn1.mcp] " + message); + } } /// Releases the process-wide registration, but only when it is still this transport's. @@ -447,6 +476,7 @@ public void close() { // takes the lock on its way out. InputStream is; // NOPMD closed below, deliberately outside the lock OutputStream os; // NOPMD closed below, outside the lock + boolean bindSettled; synchronized (lock) { closed = true; // Wait out an in-flight bind before touching the registration. The listener @@ -458,7 +488,7 @@ public void close() { // settled only after it has stopped the listener and released the slot, so // waiting here keeps close() synchronous: the slot really is free when it // returns, which MCP.stop() and every test that restarts a server rely on. - awaitBindSettled(); + bindSettled = awaitBindSettled(); l = listening; listening = null; is = in; @@ -477,7 +507,21 @@ public void close() { if (l != null) { l.stop(); } + // Released even when the wait expired. Holding the slot back would be the + // safer-looking choice -- a bind still in flight means a listener nobody has + // stopped may be about to accept -- but it is the worse trade in practice: + // any bind that never resolves would strand the registration for the life of + // the process, so every later open() is refused and the server can never be + // restarted. That is a certain, reproducible failure (it broke + // MCPReleaseBuildGateTest) weighed against a race that needs a bind to hang + // past five seconds while already accepting connections. The in-flight open() + // still stops its listener the moment it observes `closed`, so the exposure is + // that interval and not the process lifetime. clearActiveIfOurs(); + if (!bindSettled) { + logQuietly("MCP transport closed while a loopback bind was still in flight after " + + BIND_SETTLE_TIMEOUT_MS + "ms; releasing the registration anyway"); + } // Closing the output as well as the input: forgetting the field is not enough, // because a writer that already captured it would go on writing to a session that // has ended, and the socket would stay open until the connection callback unwound. diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c b/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c index e0b9383c693..e7bc50c183d 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c @@ -520,13 +520,25 @@ JAVA_BOOLEAN com_codename1_impl_linux_LinuxNative_verifyData___java_lang_String_ EVP_PKEY_free(key); return JAVA_FALSE; } - if (EVP_DigestVerifyInit(ctx, 0, cn1SignatureDigest(name), 0, key) > 0 && - EVP_DigestVerify(ctx, signature, (size_t) signatureLength, data, (size_t) dataLength) == 1) { - result = JAVA_TRUE; + if (EVP_DigestVerifyInit(ctx, 0, cn1SignatureDigest(name), 0, key) <= 0) { + /* Setup failed, so nothing was verified. Returning a bare false here + * said "that signature is invalid" about a signature never examined. */ + cn1CryptoFail("could not initialise signature verification"); } else { - /* A rejected signature is a normal answer, not a fault; clear the - * queue so it cannot be reported against a later operation. */ - ERR_clear_error(); + /* EVP_DigestVerify is tri-state: 1 verified, 0 rejected, negative an + * internal error. Folding negative in with zero reported a broken + * verifier as an ordinary bad signature. */ + int verified = EVP_DigestVerify(ctx, signature, (size_t) signatureLength, + data, (size_t) dataLength); + if (verified == 1) { + result = JAVA_TRUE; + } else if (verified < 0) { + cn1CryptoFail("signature verification failed to run"); + } else { + /* A rejected signature is a normal answer, not a fault; clear the + * queue so it cannot be reported against a later operation. */ + ERR_clear_error(); + } } EVP_MD_CTX_free(ctx); EVP_PKEY_free(key); diff --git a/docs/website/layouts/_default/port-status.html b/docs/website/layouts/_default/port-status.html index 3c736ae1642..cf2ca5b3a08 100644 --- a/docs/website/layouts/_default/port-status.html +++ b/docs/website/layouts/_default/port-status.html @@ -261,10 +261,24 @@

{{ $support.benchmark.title }}

{{ .name }}

{{ .description }}

Best measured duration {{- range $contract.ports }} {{- $report := index $reports .id -}} + {{- /* Only a complete benchmark run is presented as a measurement. + A suite that crashed partway through CommonWorkloadBenchmarkTest + publishes whatever workloads it reached -- that report is + deliberately still published, because its fail / not-run counts + are the evidence this page exists to show -- but rendering those + partial timings identically to a finished run would put an + unfinished measurement beside complete ones with nothing to tell + them apart. */ -}} + {{- $measurement := "" -}} + {{- with $report -}}{{- with .performance -}} + {{- if eq .status "complete" -}} + {{- with .benchmarks -}}{{- with (index . $benchmark.id) -}} + {{- $measurement = printf "%.2f ms" (div (float .duration_ns) 1000000.0) -}} + {{- end -}}{{- end -}} + {{- end -}} + {{- end -}}{{- end -}} - {{- with $report -}}{{- with .performance -}}{{- with .benchmarks -}}{{- with (index . $benchmark.id) -}} - {{- printf "%.2f ms" (div (float .duration_ns) 1000000.0) -}} - {{- else -}}Awaiting nightly{{- end -}}{{- else -}}Awaiting nightly{{- end -}}{{- else -}}Awaiting nightly{{- end -}}{{- else -}}Awaiting nightly{{- end -}} + {{- if $measurement -}}{{ $measurement }}{{- else -}}Awaiting nightly{{- end -}} {{- end }} diff --git a/scripts/hellocodenameone/conformance/backfill_port_status.sh b/scripts/hellocodenameone/conformance/backfill_port_status.sh index 68e52cbc8a6..e1447955bb2 100755 --- a/scripts/hellocodenameone/conformance/backfill_port_status.sh +++ b/scripts/hellocodenameone/conformance/backfill_port_status.sh @@ -82,6 +82,9 @@ trap cleanup EXIT published=0 skipped=0 +# Ports whose newest report was rejected as malformed. Collected rather than +# fatal on the spot so the older, usable report is still published first. +unusable=() # Mirrors port_status.py's FUTURE_STAMP_TOLERANCE. The gate publishes a report # stamped slightly ahead of now, so the closing assertion has to accept the same @@ -199,6 +202,17 @@ while IFS= read -r workflow; do --port "${found}" --report "${downloaded}" >/dev/null 2>&1 || accept_status=$? if [ "${accept_status}" -ne 0 ]; then echo "Ignoring the ${found} report from run ${candidate}: $(describe_accept_status "${accept_status}")." >&2 + # A malformed report is a producer defect, and falling back to an older + # run hides it: the fallback is still inside the freshness window, so the + # closing assertion passes and the sweep goes green while the newest run + # is broken. Remember it and fail at the end -- after the older report has + # been preserved, so the table keeps showing something rather than + # nothing. Contract drift stays quiet, because waiting for a run on the + # current contract is the intended behaviour there, not a defect. + if [ "${accept_status}" -eq "${ACCEPT_UNUSABLE}" ] \ + && [ ! -f "${download_dir}/covered-${found}" ]; then + unusable+=("${found}: run ${candidate} uploaded a report the website cannot use") + fi continue fi cp "${downloaded}" "${download_dir}/port-status-${found}.json" @@ -319,4 +333,11 @@ if [ ${#problems[@]} -gt 0 ]; then exit 1 fi +if [ ${#unusable[@]} -gt 0 ]; then + echo "Ports whose newest report was unusable (an older one is still being served):" >&2 + printf ' %s\n' "${unusable[@]}" >&2 + echo "The table is current, but the producer is emitting reports the website cannot read." >&2 + exit 1 +fi + echo "Every port in the contract has a report inside the ${stale_days}-day window." diff --git a/vm/JavaAPI/src/java/util/TimeZone.java b/vm/JavaAPI/src/java/util/TimeZone.java index 9325c06d5f6..99bd26035fc 100644 --- a/vm/JavaAPI/src/java/util/TimeZone.java +++ b/vm/JavaAPI/src/java/util/TimeZone.java @@ -260,7 +260,12 @@ private static TimeZone customTimeZone(String ID) { } char sign = ID.charAt(index); if (sign == 'Z' && index + 1 == ID.length()) { - return new SimpleTimeZone(0, ID); + // "GMTZ" / "UTCZ" / "UTZ" name no zone. Manufacturing a SimpleTimeZone + // that keeps the spelling made getID() and equals() disagree with + // JavaSE and Android, which take the unknown-ID fallback and answer a + // zone whose ID is "GMT" -- the raw offset matched, so the difference + // only surfaced in a serialized configuration or an ID comparison. + return GMT; } if (sign != '+' && sign != '-') { return null; diff --git a/vm/tests/src/test/resources/com/codename1/tools/translator/CustomTimeZoneApp.java b/vm/tests/src/test/resources/com/codename1/tools/translator/CustomTimeZoneApp.java index 24229a89b21..ddbf9fc7c3d 100644 --- a/vm/tests/src/test/resources/com/codename1/tools/translator/CustomTimeZoneApp.java +++ b/vm/tests/src/test/resources/com/codename1/tools/translator/CustomTimeZoneApp.java @@ -38,16 +38,24 @@ public class CustomTimeZoneApp { // answers GMT+01:30 for it, so it cannot serve as a stable oracle. The // documented syntax has no seconds field and this port rejects it. // Each of these is malformed and must not become an offset. - "GMT+1:2", "GMT+05:0", "GMT+013000", "GMT+00000", "GMT+12345", + "GMT+1:2", "GMT+05:0", "GMT+013000", "GMT+12345", + // "GMT+00000" is deliberately absent. Five digits are undefined by the + // documented syntax and the JDKs disagree: 11 and 17 answer GMT+00:00, + // 21 and 25 answer GMT. It cannot be a stable oracle, so this port + // follows the documented form and rejects it. "GMT+24", "GMT+1:60", "GMT+23:60", "GMT+01:30:99", "GMT+01:30xyz", - "GMT+1x", "GMT+", "UTC+5", "UT+5" + "GMT+1x", "GMT+", "UTC+5", "UT+5", + // Z-suffixed pseudo ids name no zone; the JDK takes the unknown-id + // fallback, so the id has to be GMT and not the spelling asked for. + "GMTZ", "UTCZ", "UTZ" }; public static void main(String[] args) { StringBuilder sb = new StringBuilder("RESULT="); for (int i = 0; i < IDS.length; i++) { java.util.TimeZone tz = java.util.TimeZone.getTimeZone(IDS[i]); - sb.append(IDS[i]).append('=').append(tz.getRawOffset()).append(';'); + sb.append(IDS[i]).append('=').append(tz.getRawOffset()) + .append('/').append(tz.getID()).append(';'); } System.out.println(sb.toString()); } From 11dd24bd0d4a607f6e99f2e7c68859225f032de8 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 4 Aug 2026 02:23:04 +0700 Subject: [PATCH 57/91] Round the RSA modulus up, and answer GMT for noncanonical bare zone IDs bits / 8 sized the OAEP block one byte short of any modulus whose strength is not a multiple of eight. PublicKey.rsa() and PrivateKey.rsa() take arbitrary DER, so such a key is reachable, and the raw CNG operation then fails or produces a block no other port can read. Both branches round up now, matching cn1EcCoordinateBytes just above them. Bare "UT", "utc", "ut" and similar kept the caller's spelling as the zone ID. Only exact uppercase "UTC" names a zone of its own; every other spelling takes the unknown-ID fallback and answers GMT. Verified identical on JDK 17 and 25, and the parity test now covers the bare prefixes as well -- it compares ids, not just offsets, which is what makes this class of divergence visible at all. Verified: the timezone parity suite through the clean target, and the Windows port cross-compiled into a real PE with clang-cl + lld-link. Co-Authored-By: Claude Opus 5 (1M context) --- Ports/WindowsPort/nativeSources/cn1_windows_crypto.c | 9 +++++++-- vm/JavaAPI/src/java/util/TimeZone.java | 10 +++++++++- .../codename1/tools/translator/CustomTimeZoneApp.java | 5 ++++- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c index 1619fa99bd4..622f4478c8b 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c +++ b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c @@ -875,14 +875,19 @@ JAVA_OBJECT com_codename1_impl_windows_WindowsNative_rsaCrypt___java_lang_String cn1CryptoFail("RSA key size", status); goto done; } - modulusBytes = bits / 8; + /* Round up: a modulus whose bit length is not a multiple of eight + * still occupies a whole final octet, and bits / 8 sized the OAEP + * block one byte short of it. The DER key material comes from the + * caller, so an unusual strength is reachable, and the raw CNG + * operation then fails or produces a block no other port can read. */ + modulusBytes = (bits + 7) / 8; } else { if (NCryptGetProperty(privateKey, NCRYPT_LENGTH_PROPERTY, (PBYTE) &bits, sizeof(bits), &propertyBytes, 0) != ERROR_SUCCESS) { cn1CryptoFail("RSA key size", 0); goto done; } - modulusBytes = bits / 8; + modulusBytes = (bits + 7) / 8; /* see the public branch above */ } block = (unsigned char*) malloc((size_t) modulusBytes + 1); if (block == 0) { diff --git a/vm/JavaAPI/src/java/util/TimeZone.java b/vm/JavaAPI/src/java/util/TimeZone.java index 99bd26035fc..ce6b0d10918 100644 --- a/vm/JavaAPI/src/java/util/TimeZone.java +++ b/vm/JavaAPI/src/java/util/TimeZone.java @@ -256,7 +256,15 @@ private static TimeZone customTimeZone(String ID) { return null; } if (index >= ID.length()) { - return new SimpleTimeZone(0, ID); + // Exact uppercase "UTC" is the only bare spelling that names a zone of + // its own. "UT", "utc", "ut" and the rest take the unknown-ID fallback + // on JavaSE and Android -- verified identical on JDK 17 and 25 -- so + // keeping the caller's spelling here made getID() and equals() differ + // across ports while the offset agreed. + if ("UTC".equals(ID)) { + return new SimpleTimeZone(0, ID); + } + return GMT; } char sign = ID.charAt(index); if (sign == 'Z' && index + 1 == ID.length()) { diff --git a/vm/tests/src/test/resources/com/codename1/tools/translator/CustomTimeZoneApp.java b/vm/tests/src/test/resources/com/codename1/tools/translator/CustomTimeZoneApp.java index ddbf9fc7c3d..cf82f99a167 100644 --- a/vm/tests/src/test/resources/com/codename1/tools/translator/CustomTimeZoneApp.java +++ b/vm/tests/src/test/resources/com/codename1/tools/translator/CustomTimeZoneApp.java @@ -47,7 +47,10 @@ public class CustomTimeZoneApp { "GMT+1x", "GMT+", "UTC+5", "UT+5", // Z-suffixed pseudo ids name no zone; the JDK takes the unknown-id // fallback, so the id has to be GMT and not the spelling asked for. - "GMTZ", "UTCZ", "UTZ" + "GMTZ", "UTCZ", "UTZ", + // Bare prefixes: only exact uppercase UTC names a zone of its own; the + // rest take the unknown-id fallback and answer GMT. + "UTC", "UT", "utc", "ut", "gmt", "Utc" }; public static void main(String[] args) { From 2692acdf5e734ecbb351b6c91bb55bee114764df Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 4 Aug 2026 03:13:02 +0700 Subject: [PATCH 58/91] Save the capture before asserting it is complete Both suite harnesses copied their screenshots (and, on Windows, app-output.log) to CN1_SHOT_OUTPUT_DIR only after the completeness assertions. Those assertions fire exactly when the suite came up short -- the run whose evidence matters most -- so throwing first left the partial capture stranded in a temporary directory. The `if: always()` upload then had nothing to upload, the reporting job nothing to download, and no normalized report was produced, so the website kept serving the previous green one. Arming the reporting jobs with !cancelled() was necessary but not sufficient: the job ran and still found no artifact. Persist first, assert second, in the Windows harness and the Linux one alike. Co-Authored-By: Claude Opus 5 (1M context) --- .../CleanTargetIntegrationTest.java | 28 ++++++++++++------- .../CleanTargetLinuxIntegrationTest.java | 25 +++++++++++------ 2 files changed, 34 insertions(+), 19 deletions(-) diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetIntegrationTest.java index 60c049e3812..71f5da32920 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetIntegrationTest.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetIntegrationTest.java @@ -1302,16 +1302,13 @@ public void run() { Thread.sleep(3000); } pngs = countPngFiles(outDir); - assertTrue(wedged.get() == null, - "the suite stopped because a test blocked the event dispatch thread: " - + wedged.get()); - assertTrue(finished.get() || (!requireSuite && pngs >= minPngs - && (!requirePerformance || performanceFinished.get())), - "hello suite capture incomplete: pngs=" + pngs + " (need " + minPngs + ")" - + " finishedTests=" + finishedTests.get() + " suiteFinished=" + finished.get() - + " performanceFinished=" + performanceFinished.get() - + " lastLine=" + lastLine.get() + "\n" + serverLog); - + // Persist the capture BEFORE asserting on it. These assertions fire + // exactly when the suite came up short, which is the run whose evidence + // is most wanted -- and throwing first meant the PNGs and app-output.log + // never reached CN1_SHOT_OUTPUT_DIR, so the `if: always()` upload had + // nothing to upload, the reporting job had nothing to download, and no + // normalized port status was produced. The website then kept serving the + // previous, green report for a run that failed. String outEnv = System.getenv("CN1_SHOT_OUTPUT_DIR"); if (outEnv != null) { Path dest = Paths.get(outEnv); @@ -1344,6 +1341,17 @@ public void run() { System.out.println("CN1_SUITE_LOG=" + suiteLog.size() + " lines" + " (performance=" + performanceLog.size() + ")"); } + + assertTrue(wedged.get() == null, + "the suite stopped because a test blocked the event dispatch thread: " + + wedged.get()); + assertTrue(finished.get() || (!requireSuite && pngs >= minPngs + && (!requirePerformance || performanceFinished.get())), + "hello suite capture incomplete: pngs=" + pngs + " (need " + minPngs + ")" + + " finishedTests=" + finishedTests.get() + " suiteFinished=" + finished.get() + + " performanceFinished=" + performanceFinished.get() + + " lastLine=" + lastLine.get() + "\n" + serverLog); + System.out.println("CN1_HELLO_SUITE_PNGS=" + pngs); } finally { if (app != null) { app.destroyForcibly(); } diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java index 83f4a49bb8d..5689af3ccc9 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java @@ -500,15 +500,12 @@ void capturesHelloSuiteOverWebSocketLinux() throws Exception { + "; stopped in " + (stoppedIn == null ? "" : stoppedIn) + " -- that test and every one after it is reported as never run."); } - assertTrue(wedged.get() == null, - "the suite stopped because a test blocked the event dispatch thread: " - + wedged.get()); - assertTrue(finished.get() || (!requireSuite && pngs >= minPngs), - "hello suite capture incomplete: pngs=" + pngs + " (need " + minPngs + ")" - + " suiteFinished=" + finished.get() - + " stoppedIn=" + (lastStarted.get() == null ? "" : lastStarted.get()) - + "\n" + serverLog); - + // Copy the capture out BEFORE asserting on it, for the same reason as + // the Windows harness: these assertions fire precisely when the suite + // came up short, and throwing first left whatever screenshots it did + // manage stranded in the temporary directory. The partial capture is + // what normalization reads to report which tests ran and which never + // did, so losing it is how a failed run ends up with no report at all. String outEnv = System.getenv("CN1_SHOT_OUTPUT_DIR"); if (outEnv != null) { Path dest = Paths.get(outEnv); @@ -521,6 +518,16 @@ void capturesHelloSuiteOverWebSocketLinux() throws Exception { } } } + + assertTrue(wedged.get() == null, + "the suite stopped because a test blocked the event dispatch thread: " + + wedged.get()); + assertTrue(finished.get() || (!requireSuite && pngs >= minPngs), + "hello suite capture incomplete: pngs=" + pngs + " (need " + minPngs + ")" + + " suiteFinished=" + finished.get() + + " stoppedIn=" + (lastStarted.get() == null ? "" : lastStarted.get()) + + "\n" + serverLog); + System.out.println("CN1_HELLO_SUITE_PNGS=" + pngs); } finally { if (app != null) { app.destroyForcibly(); } From 46332511e0e69869ee2bd3e1a91a4410dc868d3e Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 4 Aug 2026 05:05:36 +0700 Subject: [PATCH 59/91] Report CNG verification errors, and fail on misidentified reports BCryptVerifySignature's status was compared only against STATUS_SUCCESS, so an invalid handle, an invalid parameter or an unsupported algorithm read exactly like STATUS_INVALID_SIGNATURE: cryptoVerify found a cleared error slot and answered false, reporting tampering where the fault was configuration. That is the same conflation the Linux port had a commit ago. Only STATUS_INVALID_SIGNATURE stays a plain false now; every other status is recorded and surfaces as a CryptoException. An unreadable signature is reported too, rather than silently counting as invalid. The sweep's ownership check logged a report stamped with a port the workflow does not produce and moved on, bypassing the unusable tracking added for malformed reports. It could then settle on an older correct artifact and, with every stored report still fresh, go green while the producer kept emitting misidentified data. It is tracked as unusable now: the good report is still published, and the sweep fails afterwards. Verified by cross-compiling the Windows port into a real PE with clang-cl + lld-link. Co-Authored-By: Claude Opus 5 (1M context) --- .../nativeSources/cn1_windows_crypto.c | 27 ++++++++++++++----- .../conformance/backfill_port_status.sh | 6 +++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c index 622f4478c8b..5e8767d2fb5 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c +++ b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c @@ -51,6 +51,9 @@ #ifndef STATUS_AUTH_TAG_MISMATCH #define STATUS_AUTH_TAG_MISMATCH ((NTSTATUS) 0xC000A002L) #endif +#ifndef STATUS_INVALID_SIGNATURE +#define STATUS_INVALID_SIGNATURE ((NTSTATUS) 0xC000A000L) +#endif #define CN1_GCM_TAG_BYTES 16 @@ -1104,12 +1107,24 @@ JAVA_BOOLEAN com_codename1_impl_windows_WindowsNative_verifyData___java_lang_Str toVerifyLength = (ULONG) (half * 2); } } - /* A rejected signature is a normal answer here, not a fault. */ - if (usable && BCryptVerifySignature(key, isEc ? NULL : &padding, digest, - (ULONG) digestLength, (PUCHAR) toVerify, - toVerifyLength, - isEc ? 0 : BCRYPT_PAD_PKCS1) == STATUS_SUCCESS) { - result = JAVA_TRUE; + if (!usable) { + cn1CryptoFail("could not read the signature for verification", 0); + } else { + NTSTATUS verifyStatus = BCryptVerifySignature(key, isEc ? NULL : &padding, digest, + (ULONG) digestLength, (PUCHAR) toVerify, + toVerifyLength, + isEc ? 0 : BCRYPT_PAD_PKCS1); + if (verifyStatus == STATUS_SUCCESS) { + result = JAVA_TRUE; + } else if (verifyStatus != STATUS_INVALID_SIGNATURE) { + /* Only STATUS_INVALID_SIGNATURE means "this signature is bad". + * An invalid handle, parameter or unsupported algorithm means + * verification never ran, and answering a bare false for those + * reported tampering where the real fault was configuration -- + * the same conflation the Linux port had. Recording the status + * is what makes cryptoVerify raise a CryptoException instead. */ + cn1CryptoFail("signature verification failed to run", verifyStatus); + } } } BCryptDestroyKey(key); diff --git a/scripts/hellocodenameone/conformance/backfill_port_status.sh b/scripts/hellocodenameone/conformance/backfill_port_status.sh index e1447955bb2..d8f77fffda8 100755 --- a/scripts/hellocodenameone/conformance/backfill_port_status.sh +++ b/scripts/hellocodenameone/conformance/backfill_port_status.sh @@ -189,6 +189,12 @@ while IFS= read -r workflow; do *" ${found} "*) ;; *) echo "Ignoring a report naming ${found}: ${workflow} does not produce that port." >&2 + # Same standing as a malformed report, and for the same reason: the + # sweep can still find an older, correct artifact for the port this + # workflow really owns, and while that one stays fresh the closing + # check passes and the job goes green over a producer that is + # emitting misidentified data. Preserve the good report, then fail. + unusable+=("${workflow}: run ${candidate} uploaded a report naming ${found}, which it does not produce") continue ;; esac From 6a5cf3fb5f81ad2b05dd50ae9f2181ed9df240f5 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 4 Aug 2026 06:15:14 +0700 Subject: [PATCH 60/91] Refuse the MCP slot for a transport that is already closed MCPLoopbackTransportOpenTest failed again on the Java 17 leg with "already open on port 47899" -- the gate test's port, so the slot was still held after its stop(). open() runs on the server's reader thread, so stop() routinely wins that race, and open() claimed the process-wide slot regardless and only released it once the reader thread got as far as noticing `closed`. That release is asynchronous, so a start() straight after a stop() could still find the slot taken. Check `closed` before claiming instead. A transport closed before it began listening now never takes the slot, so there is nothing to release asynchronously and nothing to race with. This is the third turn of this particular screw: the previous two fixes each removed one window and left this one, which is what kept it presenting as class-order flakiness. Verified with all four MCP classes in one JVM and the full 4678 with SpotBugs at verify. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/mcp/MCPLoopbackSocketTransport.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java b/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java index 839e329e543..e67940ce10b 100644 --- a/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java +++ b/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java @@ -123,6 +123,18 @@ public void open() throws IOException { // the very window the flag exists to cover. Raising it first makes the // interval from "the slot is ours" to "the bind is resolved" continuous. synchronized (lock) { + // Never claim the slot for a transport that is already closed. open() + // runs on the server's reader thread, so stop() routinely wins this + // race -- and claiming anyway meant the slot stayed held until the + // reader thread got as far as noticing `closed` and releasing it. That + // release is asynchronous, so a start() immediately after stop() could + // still find the slot taken ("already open on port N"), which is the + // order-dependent failure MCPLoopbackTransportOpenTest exists to pin. + // Refusing up front means there is nothing to release. + if (closed) { + throw new IOException( + "This MCP socket transport was closed before it began listening"); + } binding = true; } boolean claimed = false; From 5ecec7063e9006aceea8fc1d51c4ac7b2afe2e8a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 4 Aug 2026 06:16:21 +0700 Subject: [PATCH 61/91] Carry the log-only and unreadable-report handling into the Windows paths Two follow-ons to fixes that only landed on one side. windows-cross-build-run.yml still bailed out on zero screenshots, so a run that died before its first PNG produced no port-status artifact even though app-output.log -- the input normalization actually reads -- was persisted. It now behaves like the Linux job: carry on with an empty entry list when there is a log, and only skip when there is neither. The sweep skipped an artifact whose port id could not be read at all (invalid JSON, or an object with no "port") before reaching either the acceptance gate or the unusable tracking. An older valid artifact could then cover the workflow and the run finished green over a producer emitting unreadable output. It is recorded as unusable now, like a malformed report and like one naming a port the workflow does not own. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/windows-cross-build-run.yml | 16 +++++++++++++--- .../conformance/backfill_port_status.sh | 11 ++++++++++- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/.github/workflows/windows-cross-build-run.yml b/.github/workflows/windows-cross-build-run.yml index de4f14579f8..c99c63aa625 100644 --- a/.github/workflows/windows-cross-build-run.yml +++ b/.github/workflows/windows-cross-build-run.yml @@ -319,8 +319,18 @@ jobs: done fi if [ ${#entries[@]} -eq 0 ]; then - echo "No screenshots were produced; skipping PR comment." - exit 0 + # A run that died before its first PNG still persists app-output.log, + # and that log is what normalization reads to produce the fail / + # not-run counts. Bailing out here left the failed capture with no + # port-status artifact at all, so the nightly sweep fell back to an + # older report and the table kept showing it. Carry on with an empty + # entry list when there is a log; only a run with neither has nothing + # to say. (Same handling as linux-build-run.yml.) + if [ ! -f "$ART/raw-x64/app-output.log" ]; then + echo "No screenshots and no app log; nothing to normalize." + exit 0 + fi + echo "No screenshots were produced; normalizing from the app log so the failure reaches the table." fi echo "Posting ${#entries[@]} screenshot(s)" mkdir -p "$ART/previews" @@ -349,7 +359,7 @@ jobs: "Native Windows port (cross-compiled)" \ "$ART/compare.json" "$ART/summary.txt" "$ART/comment.md" \ "$REF_DIR" "$ART/previews" "$ART" \ - "${entries[@]}" + ${entries[@]+"${entries[@]}"} gate_rc=$? set -e fail=0 diff --git a/scripts/hellocodenameone/conformance/backfill_port_status.sh b/scripts/hellocodenameone/conformance/backfill_port_status.sh index d8f77fffda8..bfe2738773b 100755 --- a/scripts/hellocodenameone/conformance/backfill_port_status.sh +++ b/scripts/hellocodenameone/conformance/backfill_port_status.sh @@ -174,7 +174,16 @@ while IFS= read -r workflow; do run_id="${candidate}" while IFS= read -r downloaded; do found="$(jq -r '.port // empty' "${downloaded}" 2>/dev/null || true)" - if [ -z "${found}" ] || [ -f "${download_dir}/covered-${found}" ]; then + if [ -z "${found}" ]; then + # Invalid JSON, or an object with no "port": the artifact names nothing, + # so it cannot be matched to a port or gated. Skipping quietly let an + # older valid artifact cover the workflow and the sweep finish green + # while the newest producer output was malformed. + echo "Ignoring $(basename "${downloaded}") from run ${candidate}: it names no port." >&2 + unusable+=("${workflow}: run ${candidate} uploaded $(basename "${downloaded}") with no readable port id") + continue + fi + if [ -f "${download_dir}/covered-${found}" ]; then continue fi # Only ports this workflow is declared to produce. The port is read from From 5e520a3e4df7323c0f928a33ff9448b573c1a1a9 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 4 Aug 2026 07:24:03 +0700 Subject: [PATCH 62/91] Report a newest run with no artifact, and make publication a real CAS A producer run that dies before normalization uploads no port-status artifact at all. The sweep silently walked back to an older one, covered every port, passed its freshness check and finished green -- so a run that produced no evidence whatsoever looked the same as one that produced good evidence. The newest candidate is now reported when it has no artifact; older candidates without artifacts are still just how the merge walks back. publish_port_status.sh read the stored blob's sha but never its timestamp, so its conflict retry overwrote whatever had landed in between. A scheduled producer's workflow_run publisher can write a newer report while the sweep is mid-flight, and the retry would replace it with the older candidate the sweep had already chosen -- then the closing freshness check would pass, because the older report is still inside the window. Both sha and generated_at are now re-read on every attempt, and publication is skipped when the branch already holds something at least as new. The sha remains the compare-and-swap; the timestamp is what makes the swap mean the right thing. Co-Authored-By: Claude Opus 5 (1M context) --- .../conformance/backfill_port_status.sh | 148 ++++++++++-------- .../conformance/publish_port_status.sh | 57 ++++++- 2 files changed, 133 insertions(+), 72 deletions(-) diff --git a/scripts/hellocodenameone/conformance/backfill_port_status.sh b/scripts/hellocodenameone/conformance/backfill_port_status.sh index bfe2738773b..375f3442c46 100755 --- a/scripts/hellocodenameone/conformance/backfill_port_status.sh +++ b/scripts/hellocodenameone/conformance/backfill_port_status.sh @@ -154,6 +154,8 @@ while IFS= read -r workflow; do fi run_id="" + # The newest candidate gets stricter treatment than the ones behind it. + first_candidate=1 download_dir="${tmp_dir}/${workflow}" mkdir -p "${download_dir}" # Merge across candidate runs rather than stopping at the first with any @@ -170,75 +172,87 @@ while IFS= read -r workflow; do if [ "${missing}" -eq 0 ]; then break fi - if gh run download "${candidate}" --pattern 'port-status-*' --dir "${download_dir}/run-${candidate}" >/dev/null 2>&1; then - run_id="${candidate}" - while IFS= read -r downloaded; do - found="$(jq -r '.port // empty' "${downloaded}" 2>/dev/null || true)" - if [ -z "${found}" ]; then - # Invalid JSON, or an object with no "port": the artifact names nothing, - # so it cannot be matched to a port or gated. Skipping quietly let an - # older valid artifact cover the workflow and the sweep finish green - # while the newest producer output was malformed. - echo "Ignoring $(basename "${downloaded}") from run ${candidate}: it names no port." >&2 - unusable+=("${workflow}: run ${candidate} uploaded $(basename "${downloaded}") with no readable port id") - continue - fi - if [ -f "${download_dir}/covered-${found}" ]; then - continue - fi - # Only ports this workflow is declared to produce. The port is read from - # the artifact, so a misconfigured matrix that stamped someone else's id - # on its report would otherwise be published straight over that port's - # entry -- Linux evidence replacing Android's genuine result, with both - # the gate and the freshness check satisfied. - # owned is newline-separated (one id per jq row); normalise to spaces - # so the space-delimited membership test below actually matches the ids - # in the middle of the list rather than only the first and last. - case " $(printf '%s ' ${owned}) " in - *" ${found} "*) ;; - *) - echo "Ignoring a report naming ${found}: ${workflow} does not produce that port." >&2 - # Same standing as a malformed report, and for the same reason: the - # sweep can still find an older, correct artifact for the port this - # workflow really owns, and while that one stays fresh the closing - # check passes and the job goes green over a producer that is - # emitting misidentified data. Preserve the good report, then fail. - unusable+=("${workflow}: run ${candidate} uploaded a report naming ${found}, which it does not produce") - continue - ;; - esac - # Gate before marking the port covered, not after. A newest run that - # uploaded an unusable report would otherwise claim the port and stop - # the older candidates from being consulted, so the sweep would keep - # serving stale data -- or fail its closing freshness assertion -- - # while a perfectly good report sat in the run behind it. - accept_status=0 - python3 "${SCRIPT_DIR}/port_status.py" accept \ - --port "${found}" --report "${downloaded}" >/dev/null 2>&1 || accept_status=$? - if [ "${accept_status}" -ne 0 ]; then - echo "Ignoring the ${found} report from run ${candidate}: $(describe_accept_status "${accept_status}")." >&2 - # A malformed report is a producer defect, and falling back to an older - # run hides it: the fallback is still inside the freshness window, so the - # closing assertion passes and the sweep goes green while the newest run - # is broken. Remember it and fail at the end -- after the older report has - # been preserved, so the table keeps showing something rather than - # nothing. Contract drift stays quiet, because waiting for a run on the - # current contract is the intended behaviour there, not a defect. - if [ "${accept_status}" -eq "${ACCEPT_UNUSABLE}" ] \ - && [ ! -f "${download_dir}/covered-${found}" ]; then - unusable+=("${found}: run ${candidate} uploaded a report the website cannot use") - fi + if ! gh run download "${candidate}" --pattern 'port-status-*' --dir "${download_dir}/run-${candidate}" >/dev/null 2>&1; then + # No report artifact at all. For the newest run that is a producer failure + # in its own right -- a job that died before normalization uploads nothing, + # so falling back to an older artifact covers every port, the closing + # freshness check passes, and the sweep goes green while the current run + # produced no evidence. Only the newest is reported: older candidates + # without artifacts are just how the merge walks back. + if [ "${first_candidate}" = "1" ]; then + unusable+=("${workflow}: newest run ${candidate} uploaded no port-status artifact") + fi + first_candidate=0 + continue + fi + first_candidate=0 + run_id="${candidate}" + while IFS= read -r downloaded; do + found="$(jq -r '.port // empty' "${downloaded}" 2>/dev/null || true)" + if [ -z "${found}" ]; then + # Invalid JSON, or an object with no "port": the artifact names nothing, + # so it cannot be matched to a port or gated. Skipping quietly let an + # older valid artifact cover the workflow and the sweep finish green + # while the newest producer output was malformed. + echo "Ignoring $(basename "${downloaded}") from run ${candidate}: it names no port." >&2 + unusable+=("${workflow}: run ${candidate} uploaded $(basename "${downloaded}") with no readable port id") + continue + fi + if [ -f "${download_dir}/covered-${found}" ]; then + continue + fi + # Only ports this workflow is declared to produce. The port is read from + # the artifact, so a misconfigured matrix that stamped someone else's id + # on its report would otherwise be published straight over that port's + # entry -- Linux evidence replacing Android's genuine result, with both + # the gate and the freshness check satisfied. + # owned is newline-separated (one id per jq row); normalise to spaces + # so the space-delimited membership test below actually matches the ids + # in the middle of the list rather than only the first and last. + case " $(printf '%s ' ${owned}) " in + *" ${found} "*) ;; + *) + echo "Ignoring a report naming ${found}: ${workflow} does not produce that port." >&2 + # Same standing as a malformed report, and for the same reason: the + # sweep can still find an older, correct artifact for the port this + # workflow really owns, and while that one stays fresh the closing + # check passes and the job goes green over a producer that is + # emitting misidentified data. Preserve the good report, then fail. + unusable+=("${workflow}: run ${candidate} uploaded a report naming ${found}, which it does not produce") continue + ;; + esac + # Gate before marking the port covered, not after. A newest run that + # uploaded an unusable report would otherwise claim the port and stop + # the older candidates from being consulted, so the sweep would keep + # serving stale data -- or fail its closing freshness assertion -- + # while a perfectly good report sat in the run behind it. + accept_status=0 + python3 "${SCRIPT_DIR}/port_status.py" accept \ + --port "${found}" --report "${downloaded}" >/dev/null 2>&1 || accept_status=$? + if [ "${accept_status}" -ne 0 ]; then + echo "Ignoring the ${found} report from run ${candidate}: $(describe_accept_status "${accept_status}")." >&2 + # A malformed report is a producer defect, and falling back to an older + # run hides it: the fallback is still inside the freshness window, so the + # closing assertion passes and the sweep goes green while the newest run + # is broken. Remember it and fail at the end -- after the older report has + # been preserved, so the table keeps showing something rather than + # nothing. Contract drift stays quiet, because waiting for a run on the + # current contract is the intended behaviour there, not a defect. + if [ "${accept_status}" -eq "${ACCEPT_UNUSABLE}" ] \ + && [ ! -f "${download_dir}/covered-${found}" ]; then + unusable+=("${found}: run ${candidate} uploaded a report the website cannot use") fi - cp "${downloaded}" "${download_dir}/port-status-${found}.json" - : > "${download_dir}/covered-${found}" - # Remember which run this port's report actually came from. Reports are - # merged across candidates on purpose, so a single run_id would credit - # every port to whichever candidate happened to be examined last -- - # misleading exactly when someone is chasing down a bad report. - printf '%s' "${candidate}" > "${download_dir}/source-run-${found}" - done < <(find "${download_dir}/run-${candidate}" -type f -name 'port-status-*.json' | sort) - fi + continue + fi + cp "${downloaded}" "${download_dir}/port-status-${found}.json" + : > "${download_dir}/covered-${found}" + # Remember which run this port's report actually came from. Reports are + # merged across candidates on purpose, so a single run_id would credit + # every port to whichever candidate happened to be examined last -- + # misleading exactly when someone is chasing down a bad report. + printf '%s' "${candidate}" > "${download_dir}/source-run-${found}" + done < <(find "${download_dir}/run-${candidate}" -type f -name 'port-status-*.json' | sort) done if [ -z "${run_id}" ]; then echo "No recent ${workflow} run has a port status artifact." >&2 diff --git a/scripts/hellocodenameone/conformance/publish_port_status.sh b/scripts/hellocodenameone/conformance/publish_port_status.sh index c0611aa6d6d..777a846335e 100755 --- a/scripts/hellocodenameone/conformance/publish_port_status.sh +++ b/scripts/hellocodenameone/conformance/publish_port_status.sh @@ -39,10 +39,12 @@ if [ "${accept_status}" -ne 0 ]; then exit 0 fi -if ! command -v gh >/dev/null 2>&1; then - echo "GitHub CLI is required to publish port status." >&2 - exit 2 -fi +for tool in gh jq python3; do + if ! command -v "${tool}" >/dev/null 2>&1; then + echo "${tool} is required to publish port status." >&2 + exit 2 + fi +done repo="${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" target="ports/${port}.json" @@ -57,8 +59,53 @@ if ! gh api "repos/${repo}/git/ref/heads/${branch}" >/dev/null 2>&1; then fi content="$(base64 < "$report" | tr -d '\n')" +generated="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1], encoding="utf-8")).get("generated_at", ""))' "$report")" + +# GNU spells it --decode, BSD -D. +decode_base64() { + base64 --decode 2>/dev/null || base64 -D +} + +# True when $1 is strictly later than $2; both ISO-8601 and timezone-aware, so +# they are compared as instants rather than as text. +newer_instant() { + python3 - "$1" "$2" <<'INSTANT' +import sys +from datetime import datetime + +def parse(value): + try: + stamp = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + return stamp if stamp.tzinfo is not None else None + +candidate = parse(sys.argv[1]) +current = parse(sys.argv[2]) +sys.exit(0 if candidate is not None and (current is None or candidate > current) else 1) +INSTANT +} + for attempt in 1 2 3; do - existing_sha="$(gh api "repos/${repo}/contents/${target}?ref=${branch}" --jq .sha 2>/dev/null || true)" + # Read the sha AND the stored timestamp together, and re-read both on every + # retry. A scheduled producer's workflow_run publisher can land a newer report + # between the sweep deciding to publish and this PUT; retrying on the fresh sha + # alone would then overwrite it, and the sweep's own freshness check would still + # pass because the older report it wrote is inside the window. The sha is the + # compare-and-swap; this timestamp check is what makes the swap meaningful. + existing_json="$(gh api "repos/${repo}/contents/${target}?ref=${branch}" 2>/dev/null || true)" + existing_sha="" + existing_generated="" + if [ -n "${existing_json}" ]; then + existing_sha="$(printf '%s' "${existing_json}" | jq -r '.sha // empty' 2>/dev/null || true)" + existing_generated="$(printf '%s' "${existing_json}" | jq -r '.content // empty' 2>/dev/null \ + | decode_base64 2>/dev/null | jq -r '.generated_at // empty' 2>/dev/null || true)" + fi + if [ -n "${existing_generated}" ] && [ -n "${generated}" ] \ + && ! newer_instant "${generated}" "${existing_generated}"; then + echo "Not publishing ${target}: the branch already holds a report at ${existing_generated} (ours is ${generated})." + exit 0 + fi args=( --method PUT "repos/${repo}/contents/${target}" From 3eeda2b835efc68ff7b79d93b864c8121506288a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 4 Aug 2026 08:20:47 +0700 Subject: [PATCH 63/91] Validate the missing field, catch omitted ports, always rebuild the site port_status.py joined performance.missing without checking it. A producer emitting "missing": true raised TypeError, main() catches only ContractError, so the gate crashed with a status the sweep does not treat as unusable and it fell back to an older report and finished green. Validated as an array of strings now, once, and used from that single point. The sweep marked the newest candidate processed as a whole. A multi-port producer -- the iOS suite emits four ports, Linux two -- can upload some and lose the rest when a leg dies before normalization; the merge filled those from an older run and everything looked current. Coverage by the newest run is now tracked per port. port-status-nightly.yml gated the entire publish-browser-evidence job on the JavaScript build, and the site rebuild at the end of that job is the only thing in the workflow that dispatches website-docs.yml. A failed JS build therefore left every report the sweep had just published sitting on the data branch, absent from the public table. The evidence steps stay gated; the rebuild no longer is. Also retry the Windows ffmpeg install. The chocolatey feed has now failed mid-run twice in this PR, choco exits 0 on "installed 0/0 packages", and the failure surfaced far downstream as a FileNotFoundError from the smoke script looking for a binary that was never installed. Retried, and failed at the install with a legible message when it truly cannot be fetched. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/javase-cef-ffmpeg-smoke.yml | 20 +++++++++- .github/workflows/port-status-nightly.yml | 15 +++++++- .../conformance/backfill_port_status.sh | 19 ++++++++++ .../conformance/port_status.py | 38 +++++++++++-------- .../conformance/test_port_status.py | 14 +++++++ 5 files changed, 88 insertions(+), 18 deletions(-) diff --git a/.github/workflows/javase-cef-ffmpeg-smoke.yml b/.github/workflows/javase-cef-ffmpeg-smoke.yml index ce43520cdcb..a9505de8f5f 100644 --- a/.github/workflows/javase-cef-ffmpeg-smoke.yml +++ b/.github/workflows/javase-cef-ffmpeg-smoke.yml @@ -67,10 +67,28 @@ jobs: if: runner.os == 'macOS' run: brew install ffmpeg + # The community.chocolatey.org feed has failed mid-run more than once + # ("Failed to fetch results from V2 feed ... Chocolatey installed 0/0 + # packages"), and choco exits 0 on that, so the failure only surfaced far + # downstream as a FileNotFoundError from the smoke script hunting for a + # binary that was never installed. Retry the transient outage, and if it + # really cannot be installed, fail here where the reason is legible. - name: Install ffmpeg on Windows if: runner.os == 'Windows' shell: powershell - run: choco install ffmpeg -y + run: | + $ErrorActionPreference = 'Continue' + for ($attempt = 1; $attempt -le 3; $attempt++) { + choco install ffmpeg -y --no-progress + if (Get-Command ffmpeg -ErrorAction SilentlyContinue) { + Write-Host "ffmpeg is installed." + exit 0 + } + Write-Host "ffmpeg not on PATH after attempt $attempt; retrying..." + Start-Sleep -Seconds (15 * $attempt) + } + Write-Error "ffmpeg could not be installed from chocolatey after three attempts." + exit 1 - name: Run JavaSE CEF/FFmpeg smoke test env: diff --git a/.github/workflows/port-status-nightly.yml b/.github/workflows/port-status-nightly.yml index f377bede1b3..6a3fe20f2c4 100644 --- a/.github/workflows/port-status-nightly.yml +++ b/.github/workflows/port-status-nightly.yml @@ -101,28 +101,39 @@ jobs: publish-browser-evidence: # Ordered after the report sweep (and tolerant of it failing) so the site # rebuild at the end of this job picks up everything published tonight. - if: always() && needs.build-javascript-app.result == 'success' + # Runs whatever happened upstream. The browser-evidence steps still need the + # JavaScript build, and stay gated on it -- but the site rebuild at the end is + # the only thing in this workflow that dispatches website-docs.yml, and gating + # the whole job meant a failed JS build left every report the sweep had just + # published sitting on the data branch, absent from the public table until + # some later deployment. The two concerns are now separated. + if: always() needs: [build-javascript-app, browser-lifecycle, publish-latest-port-reports] runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: actions/download-artifact@v4 + if: needs.build-javascript-app.result == 'success' with: pattern: browser-evidence-* path: artifacts/browser-evidence - name: Assemble static browser evidence + if: needs.build-javascript-app.result == 'success' run: node scripts/website/collect_browser_evidence.mjs artifacts/browser-evidence artifacts/port-status-environment.json - uses: actions/upload-artifact@v4 + if: needs.build-javascript-app.result == 'success' with: name: port-status-environment path: artifacts/port-status-environment.json retention-days: 30 - name: Publish master evidence to the data branch - if: github.ref == 'refs/heads/master' + if: needs.build-javascript-app.result == 'success' && github.ref == 'refs/heads/master' env: GH_TOKEN: ${{ github.token }} run: scripts/hellocodenameone/conformance/publish_port_status_environment.sh artifacts/port-status-environment.json - name: Rebuild the static website snapshot + # Deliberately not gated on the JavaScript build: the reports published + # tonight reach the public table only through this dispatch. if: github.ref == 'refs/heads/master' env: GH_TOKEN: ${{ github.token }} diff --git a/scripts/hellocodenameone/conformance/backfill_port_status.sh b/scripts/hellocodenameone/conformance/backfill_port_status.sh index 375f3442c46..9dd2f8f26d6 100755 --- a/scripts/hellocodenameone/conformance/backfill_port_status.sh +++ b/scripts/hellocodenameone/conformance/backfill_port_status.sh @@ -156,6 +156,7 @@ while IFS= read -r workflow; do run_id="" # The newest candidate gets stricter treatment than the ones behind it. first_candidate=1 + newest_candidate="$(printf '%s\n' ${candidates} | head -1)" download_dir="${tmp_dir}/${workflow}" mkdir -p "${download_dir}" # Merge across candidate runs rather than stopping at the first with any @@ -247,6 +248,9 @@ while IFS= read -r workflow; do fi cp "${downloaded}" "${download_dir}/port-status-${found}.json" : > "${download_dir}/covered-${found}" + if [ "${candidate}" = "${newest_candidate}" ]; then + : > "${download_dir}/newest-covered-${found}" + fi # Remember which run this port's report actually came from. Reports are # merged across candidates on purpose, so a single run_id would credit # every port to whichever candidate happened to be examined last -- @@ -259,6 +263,21 @@ while IFS= read -r workflow; do continue fi + # A multi-port producer (the iOS suite emits four, Linux two) can upload some + # of its ports and lose the rest when one leg dies before normalization. The + # merge then fills those from an older run and everything looks current, so + # the newest leg's failure is masked. Only reported when the newest run + # produced something: a newest run with no artifact at all is already recorded + # above, and saying both would be the same defect twice. + if [ -d "${download_dir}/run-${newest_candidate}" ]; then + for port in ${owned}; do + if [ -f "${download_dir}/covered-${port}" ] \ + && [ ! -f "${download_dir}/newest-covered-${port}" ]; then + unusable+=("${workflow}: newest run ${newest_candidate} did not report ${port}; an older run supplied it") + fi + done + fi + while IFS= read -r report; do port="$(jq -r '.port // empty' "${report}")" if [ -z "${port}" ]; then diff --git a/scripts/hellocodenameone/conformance/port_status.py b/scripts/hellocodenameone/conformance/port_status.py index d1d44227c20..0a8308ce09d 100755 --- a/scripts/hellocodenameone/conformance/port_status.py +++ b/scripts/hellocodenameone/conformance/port_status.py @@ -701,13 +701,27 @@ def publishable_report_problems( # as performance. Structural defects below stay loud either way, because # those are producer bugs whatever the suite did. suite_finished = bool(report.get("suite_finished")) + + # Validated before anything joins or iterates it. A producer emitting + # "missing": true or a number raised TypeError out of the join below, and + # main() only catches ContractError -- so the gate crashed instead of + # answering ACCEPT_UNUSABLE, the sweep saw a status it does not treat as + # unusable, fell back to an older report and finished green. + declared_missing = performance.get("missing") + if declared_missing is None: + declared_missing = [] + if not isinstance(declared_missing, list) or not all( + isinstance(item, str) for item in declared_missing + ): + malformed.append("performance missing list is not an array of workload names") + declared_missing = [] + if suite_finished: if performance.get("status") != "complete": malformed.append(f"performance run is {performance.get('status')!r}") - if performance.get("missing"): + if declared_missing: malformed.append( - "performance workloads never reported: " - + ", ".join(performance["missing"]) + "performance workloads never reported: " + ", ".join(declared_missing) ) benchmarks = performance.get("benchmarks") @@ -745,18 +759,12 @@ def publishable_report_problems( # was measured or skipped, so measured + skipped + missing still has to # name every workload. Dropping that check entirely would let a # structurally broken section through unnoticed. - declared_missing = performance.get("missing") - if declared_missing is None: - declared_missing = [] - if not isinstance(declared_missing, list): - malformed.append("performance missing list is not an array") - else: - covered = sorted(set(accounted) | set(declared_missing)) - if covered != sorted(expected_benchmarks): - malformed.append( - "performance workloads unaccounted for: " - + describe_workload_gap(covered, expected_benchmarks) - ) + covered = sorted(set(accounted) | set(declared_missing)) + if covered != sorted(expected_benchmarks): + malformed.append( + "performance workloads unaccounted for: " + + describe_workload_gap(covered, expected_benchmarks) + ) for name, measurement in benchmarks.items(): duration = measurement.get("duration_ns") if isinstance(measurement, dict) else None if isinstance(duration, bool) or not isinstance(duration, int) or duration < 0: diff --git a/scripts/hellocodenameone/conformance/test_port_status.py b/scripts/hellocodenameone/conformance/test_port_status.py index 6993a2d6278..5e4fee047f4 100755 --- a/scripts/hellocodenameone/conformance/test_port_status.py +++ b/scripts/hellocodenameone/conformance/test_port_status.py @@ -481,6 +481,20 @@ def test_publishable_rejects_a_wrongly_typed_skipped_section(self): ) self.assertTrue(any("not objects" in item for item in malformed), malformed) + def test_publishable_rejects_a_malformed_missing_field(self): + # "missing": true used to raise TypeError out of the join, and main() + # catches only ContractError -- so the gate crashed with a status the + # sweep does not treat as unusable, and it fell back quietly. + for bad in (True, 7, "quicksort", ["quicksort", 3]): + with self.subTest(bad=bad): + report = self.publishable_report("linux-x64") + report["performance"]["missing"] = bad + _, malformed = port_status.publishable_report_problems( + self.manifest, "linux-x64", report + ) + self.assertTrue( + any("missing list" in item for item in malformed), malformed) + def test_publishable_matches_every_report_the_site_serves(self): for port in self.manifest["ports"]: report_path = port_status.REPO_ROOT / self.manifest["report_directory"] / ( From 55104ccb45e379c1ca71f01c0224de3667d1dbc1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:09:26 +0700 Subject: [PATCH 64/91] Resolve TimeZone.getOffset in the frame its callers actually use getOffset(era, year, month, day, dayOfWeek, millis) takes LOCAL STANDARD time fields. Every caller passes them that way -- GregorianCalendar decomposes a local time, DateUtil and both SimpleDateFormats read the fields off a Calendar in the zone -- while the ports' natives answer a different question: the offset at the instant a set of UTC fields denotes. The two were used interchangeably, so America/New_York at 2020-03-08 02:30 standard time answered UTC-05:00 where JavaSE and Android answer UTC-04:00. I declined an earlier version of this finding because the symptom did not reproduce. It did not reproduce because the only caller I probed through was DateTimeSupport, which passed UTC fields into a native that read UTC fields -- self-consistent, and wrong only for everybody else. Absence of a reproducer was not absence of the bug, and the new test shows exactly that: its instant half passes against the unfixed code while its local-standard half is an hour out. Converted in TimeZone rather than in each port's native. The natives' real contract stays intact and all six ports are fixed at once: the instant the fields denote is (fields read as UTC) minus the raw offset, and the native is then asked about that instant in the UTC fields it expects. DateTimeSupport, the one caller that genuinely starts from an instant, now converts into local standard time first instead of relying on the frames cancelling out. TimeZoneOffsetFrameTest walks both frames across the 2020 US spring-forward transition in three zones and requires the JDK's answers exactly. Against the unfixed code it reports JDK America/New_York@2=-14400000, ParparVM America/New_York@2=-18000000 which is the reviewer's case. TimeApiIntegrationTest -- which my earlier one-line native flip broke -- passes, as do the custom-id and Latin-1 suites and 4754 core tests with SpotBugs. Also: arm CN1SS_FAIL_ON_TEST_PROBLEMS for the Windows producer, the one producer that did not set it, so an assertion-only failure can no longer leave the workflow green while publishing a report full of failed tests. And keep contract drift out of the newest-run omission check -- a drifting report means the newest run DID report that port, so counting it as omitted turned the documented quiet fallback into a hard failure. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/windows-cross-build-run.yml | 7 + .../conformance/backfill_port_status.sh | 13 +- vm/JavaAPI/src/java/time/DateTimeSupport.java | 13 +- vm/JavaAPI/src/java/util/TimeZone.java | 67 +++++- .../translator/TimeZoneOffsetFrameTest.java | 197 ++++++++++++++++++ .../translator/TimeZoneOffsetFrameApp.java | 60 ++++++ 6 files changed, 350 insertions(+), 7 deletions(-) create mode 100644 vm/tests/src/test/java/com/codename1/tools/translator/TimeZoneOffsetFrameTest.java create mode 100644 vm/tests/src/test/resources/com/codename1/tools/translator/TimeZoneOffsetFrameApp.java diff --git a/.github/workflows/windows-cross-build-run.yml b/.github/workflows/windows-cross-build-run.yml index c99c63aa625..3c9fefca47e 100644 --- a/.github/workflows/windows-cross-build-run.yml +++ b/.github/workflows/windows-cross-build-run.yml @@ -354,6 +354,13 @@ jobs: export CN1SS_SUITE_LOG="$ART/raw-x64/app-output.log" export CN1SS_BINARY_PATH="$ART/raw-x64/benchmark-app.exe" if [ "${{ github.event_name }}" != "pull_request" ]; then export CN1SS_SKIP_COMMENT=1; fi + # Assertion-only failures count here as they do on every other producer. + # A test like CryptoApiTest can fail while the suite still emits its + # completion marker and every screenshot matches, and without this the + # step exited zero: the workflow stayed green and published a report + # containing failed tests. Linux, Android, JavaScript, iOS and Mac all + # set this; Windows was the one producer that did not. + export CN1SS_FAIL_ON_TEST_PROBLEMS=1 set +e cn1ss_process_and_report \ "Native Windows port (cross-compiled)" \ diff --git a/scripts/hellocodenameone/conformance/backfill_port_status.sh b/scripts/hellocodenameone/conformance/backfill_port_status.sh index 9dd2f8f26d6..0843d977994 100755 --- a/scripts/hellocodenameone/conformance/backfill_port_status.sh +++ b/scripts/hellocodenameone/conformance/backfill_port_status.sh @@ -233,6 +233,16 @@ while IFS= read -r workflow; do --port "${found}" --report "${downloaded}" >/dev/null 2>&1 || accept_status=$? if [ "${accept_status}" -ne 0 ]; then echo "Ignoring the ${found} report from run ${candidate}: $(describe_accept_status "${accept_status}")." >&2 + if [ "${candidate}" = "${newest_candidate}" ] \ + && [ "${accept_status}" -eq "${ACCEPT_CONTRACT_DRIFT}" ]; then + # The newest run DID report this port; its report is simply built + # against another revision of the contract, which is the one case that + # is meant to wait quietly for the next run. Without this marker the + # omission check below would see no newest-covered file, call the port + # omitted and fail the sweep -- turning the documented quiet fallback + # into a hard failure every time the merge behind it succeeded. + : > "${download_dir}/newest-drift-${found}" + fi # A malformed report is a producer defect, and falling back to an older # run hides it: the fallback is still inside the freshness window, so the # closing assertion passes and the sweep goes green while the newest run @@ -272,7 +282,8 @@ while IFS= read -r workflow; do if [ -d "${download_dir}/run-${newest_candidate}" ]; then for port in ${owned}; do if [ -f "${download_dir}/covered-${port}" ] \ - && [ ! -f "${download_dir}/newest-covered-${port}" ]; then + && [ ! -f "${download_dir}/newest-covered-${port}" ] \ + && [ ! -f "${download_dir}/newest-drift-${port}" ]; then unusable+=("${workflow}: newest run ${newest_candidate} did not report ${port}; an older run supplied it") fi done diff --git a/vm/JavaAPI/src/java/time/DateTimeSupport.java b/vm/JavaAPI/src/java/time/DateTimeSupport.java index 391493e85f8..c904655a29b 100644 --- a/vm/JavaAPI/src/java/time/DateTimeSupport.java +++ b/vm/JavaAPI/src/java/time/DateTimeSupport.java @@ -215,11 +215,16 @@ public static ZoneOffset offsetFromInstant(Instant instant, ZoneId zone) { // from a raw offset plus a fixed one-hour daylight guess, which loses // the saving on the desktop ports (Europe/Berlin in June came back as // UTC), while TimeZone.getOffset consults the platform's own rules. - // The fields below are UTC, which is the reference frame every port's - // getOffset native resolves against. + // getOffset takes LOCAL STANDARD time fields, which is what its javadoc + // says and what every other caller passes. This used to hand it UTC + // fields, which happened to work only because the natives read them that + // way; now that TimeZone converts properly, an instant has to be turned + // into local standard time first -- add the raw offset -- or it would be + // shifted by that offset twice. long epochMilli = instant.toEpochMilli(); - long epochDay = floorDiv(epochMilli, MILLIS_PER_DAY); - int millisOfDay = (int) floorMod(epochMilli, MILLIS_PER_DAY); + long localStandard = epochMilli + tz.getRawOffset(); + long epochDay = floorDiv(localStandard, MILLIS_PER_DAY); + int millisOfDay = (int) floorMod(localStandard, MILLIS_PER_DAY); LocalDate utcDate = LocalDate.ofEpochDay(epochDay); // Calendar.SUNDAY is 1 and epoch day 0 was a Thursday. int dayOfWeek = (int) floorMod(epochDay + 4, 7) + 1; diff --git a/vm/JavaAPI/src/java/util/TimeZone.java b/vm/JavaAPI/src/java/util/TimeZone.java index ce6b0d10918..846bbe275ca 100644 --- a/vm/JavaAPI/src/java/util/TimeZone.java +++ b/vm/JavaAPI/src/java/util/TimeZone.java @@ -63,6 +63,67 @@ public static java.lang.String[] getAvailableIDs(){ } } + /// Resolves the offset for calendar fields expressed in local *standard* time, + /// which is what java.util.TimeZone.getOffset(era, year, month, day, dayOfWeek, + /// millis) documents and what every caller here passes: GregorianCalendar + /// decomposes a local time, DateUtil and both SimpleDateFormats read the fields + /// off a Calendar in the zone. + /// + /// The natives answer a different question -- "what is the offset at the instant + /// these UTC fields denote" -- and feeding local fields to them straight through + /// mixed the two frames. America/New_York at 2020-03-08 02:30 standard time is + /// UTC-04:00, but read as 02:30 UTC it lands the previous evening and answers + /// UTC-05:00. + /// + /// Converting here rather than in each port's native keeps the natives' actual + /// contract intact and fixes every port at once: the instant the fields denote is + /// (fields read as UTC) minus the raw offset, and the natives are then asked + /// about that instant in the UTC fields they expect. + static int offsetForLocalStandardFields(String id, int rawOffset, int era, int year, + int month, int day, int timeOfDayMillis) { + int isoYear = era > 0 ? year : 1 - year; + long fieldsAsUtc = daysFromCivil(isoYear, month + 1, day) * 86400000L + timeOfDayMillis; + long instant = fieldsAsUtc - rawOffset; + long epochDay = floorDiv(instant, 86400000L); + int millisOfDay = (int) (instant - epochDay * 86400000L); + int[] civil = civilFromDays(epochDay); + return getTimezoneOffset(id, civil[0], civil[1], civil[2], millisOfDay); + } + + private static long floorDiv(long value, long divisor) { + long q = value / divisor; + if ((value % divisor != 0) && ((value < 0) != (divisor < 0))) { + q--; + } + return q; + } + + /// Days since 1970-01-01 for a proleptic Gregorian date (Howard Hinnant's + /// civil-from-days inverse). Integer only, so it is exact for every year the + /// callers can produce. + private static long daysFromCivil(int y, int m, int d) { + int adjusted = y - (m <= 2 ? 1 : 0); + long era = (adjusted >= 0 ? adjusted : adjusted - 399) / 400; + int yoe = (int) (adjusted - era * 400); + int doy = (153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1; + int doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + return era * 146097L + doe - 719468L; + } + + /// Inverse of daysFromCivil: { year, month 1-12, day }. + private static int[] civilFromDays(long z) { + long shifted = z + 719468L; + long era = (shifted >= 0 ? shifted : shifted - 146096) / 146097; + long doe = shifted - era * 146097; + long yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + long y = yoe + era * 400; + long doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + long mp = (5 * doy + 2) / 153; + long d = doy - (153 * mp + 2) / 5 + 1; + long m = mp + (mp < 10 ? 3 : -9); + return new int[] { (int) (y + (m <= 2 ? 1 : 0)), (int) m, (int) d }; + } + private static native String getTimezoneId(); private static native int getTimezoneOffset(String name, int year, int month, int day, int timeOfDayMillis); private static native int getTimezoneRawOffset(String name); @@ -107,7 +168,8 @@ public static java.util.TimeZone getDefault(){ defaultTimeZone = new TimeZone() { @Override public int getOffset(int era, int year, int month, int day, int dayOfWeek, int timeOfDayMillis) { - return getTimezoneOffset(tzone, year, month + 1, day, timeOfDayMillis); + return offsetForLocalStandardFields(tzone, getTimezoneRawOffset(tzone), + era, year, month, day, timeOfDayMillis); } @Override @@ -196,7 +258,8 @@ public static java.util.TimeZone getTimeZone(final java.lang.String ID){ TimeZone out = new TimeZone() { @Override public int getOffset(int era, int year, int month, int day, int dayOfWeek, int timeOfDayMillis) { - return getTimezoneOffset(ID, year, month + 1, day, timeOfDayMillis); + return offsetForLocalStandardFields(ID, getTimezoneRawOffset(ID), + era, year, month, day, timeOfDayMillis); } @Override diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/TimeZoneOffsetFrameTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/TimeZoneOffsetFrameTest.java new file mode 100644 index 00000000000..67985d46710 --- /dev/null +++ b/vm/tests/src/test/java/com/codename1/tools/translator/TimeZoneOffsetFrameTest.java @@ -0,0 +1,197 @@ +/* + * Copyright (c) 2026, 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 org.junit.jupiter.api.Test; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +/** + * Pins the reference frame of TimeZone.getOffset against the JDK. + * + * The six-argument getOffset takes LOCAL STANDARD time fields. The ports' + * natives answer a different question -- the offset at the instant a set of UTC + * fields denotes -- and the two were being used interchangeably, so + * America/New_York at 2020-03-08 02:30 standard time answered UTC-05:00 where + * JavaSE and Android answer UTC-04:00. This walks both frames across that + * transition, in three zones, and requires the JDK's answers exactly. + */ +class TimeZoneOffsetFrameTest { + + @Test + void offsetFramesMatchTheJdkAcrossATransition() throws Exception { + Parser.cleanup(); + + Path sourceDir = Files.createTempDirectory("timezone-frame-sources"); + Path classesDir = Files.createTempDirectory("timezone-frame-classes"); + Path javaApiDir = Files.createTempDirectory("timezone-frame-japi"); + + Path source = sourceDir.resolve("TimeZoneOffsetFrameApp.java"); + Files.write(source, loadAppSource().getBytes(StandardCharsets.UTF_8)); + + CompilerHelper.CompilerConfig config = selectCompiler(); + if (config == null) { + fail("No compatible compiler available for the Latin-1 Character test"); + } + + assertTrue(CompilerHelper.isJavaApiCompatible(config), + "JDK " + config.jdkVersion + " must target matching bytecode level for JavaAPI"); + + CompilerHelper.compileJavaAPI(javaApiDir, config); + + List compileArgs = new ArrayList<>(); + compileArgs.add("-source"); + compileArgs.add(config.targetVersion); + compileArgs.add("-target"); + compileArgs.add(config.targetVersion); + if (CompilerHelper.useClasspath(config)) { + compileArgs.add("-classpath"); + compileArgs.add(javaApiDir.toString()); + } else { + compileArgs.add("-bootclasspath"); + compileArgs.add(javaApiDir.toString()); + compileArgs.add("-Xlint:-options"); + } + compileArgs.add("-d"); + compileArgs.add(classesDir.toString()); + compileArgs.add(source.toString()); + + int compileResult = CompilerHelper.compile(config.jdkHome, compileArgs); + assertEquals(0, compileResult, + "TimeZoneOffsetFrameApp should compile. " + CompilerHelper.getLastErrorLog()); + + String jdkResult = extractResultLine(runJavaMain(config, classesDir)); + assertTrue(jdkResult.startsWith("RESULT="), + "The JDK leg should produce a RESULT line"); + + CompilerHelper.copyDirectory(javaApiDir, classesDir); + + Path outputDir = Files.createTempDirectory("timezone-frame-output"); + CleanTargetIntegrationTest.runTranslator(classesDir, outputDir, "TimeZoneOffsetFrameApp"); + + Path distDir = outputDir.resolve("dist"); + Path cmakeLists = distDir.resolve("CMakeLists.txt"); + assertTrue(Files.exists(cmakeLists), "Translator should emit a CMake project"); + + CleanTargetIntegrationTest.replaceLibraryWithExecutableTarget(cmakeLists, "TimeZoneOffsetFrameApp-src"); + + Path buildDir = distDir.resolve("build"); + Files.createDirectories(buildDir); + + CleanTargetIntegrationTest.runCommand(Arrays.asList( + "cmake", + "-S", distDir.toString(), + "-B", buildDir.toString(), + "-DCMAKE_C_COMPILER=clang", + "-DCMAKE_OBJC_COMPILER=clang" + ), distDir); + + CleanTargetIntegrationTest.runCommand(Arrays.asList("cmake", "--build", buildDir.toString()), distDir); + + Path executable = buildDir.resolve("TimeZoneOffsetFrameApp"); + assertTrue(Files.exists(executable), "ParparVM build should produce a runnable executable"); + + String vmResult = extractResultLine( + CleanTargetIntegrationTest.runCommand(Arrays.asList(executable.toString()), buildDir)); + + assertEquals(jdkResult, vmResult, firstDifference(jdkResult, vmResult)); + } + + /** Names the offending id rather than dumping two long strings. */ + private String firstDifference(String expected, String actual) { + String[] want = expected.substring("RESULT=".length()).split(";"); + String[] got = actual.substring(actual.indexOf('=') + 1).split(";"); + for (int i = 0; i < Math.min(want.length, got.length); i++) { + if (!want[i].equals(got[i])) { + return "Time zone offset frames differ: JDK " + want[i] + + ", ParparVM " + got[i] + " (offsets in milliseconds)"; + } + } + return "ParparVM should resolve offsets in the same frame as the JDK"; + } + + private String loadAppSource() throws Exception { + java.io.InputStream in = TimeZoneOffsetFrameTest.class.getResourceAsStream( + "/com/codename1/tools/translator/TimeZoneOffsetFrameApp.java"); + assertNotNull(in, "TimeZoneOffsetFrameApp.java test resource should exist"); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { + return reader.lines().collect(Collectors.joining("\n")) + "\n"; + } + } + + private String runJavaMain(CompilerHelper.CompilerConfig config, Path classesDir) throws Exception { + String javaExe = config.jdkHome.resolve("bin").resolve("java").toString(); + if (System.getProperty("os.name").toLowerCase().contains("win")) { + javaExe += ".exe"; + } + + ProcessBuilder pb = new ProcessBuilder(javaExe, "-cp", classesDir.toString(), "TimeZoneOffsetFrameApp"); + pb.redirectErrorStream(true); + + Process process = pb.start(); + String output; + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + output = reader.lines().collect(Collectors.joining("\n")); + } + + int exitCode = process.waitFor(); + assertEquals(0, exitCode, "JVM run should exit cleanly. Output: " + output); + return output; + } + + private String extractResultLine(String output) { + for (String line : output.split("\\R")) { + if (line.startsWith("RESULT=")) { + return line.trim(); + } + } + return ""; + } + + private CompilerHelper.CompilerConfig selectCompiler() { + String[] preferredTargets = {"11", "17", "21", "25", "1.8"}; + for (String target : preferredTargets) { + List configs = CompilerHelper.getAvailableCompilers(target); + for (CompilerHelper.CompilerConfig config : configs) { + if (CompilerHelper.isJavaApiCompatible(config)) { + return config; + } + } + } + return null; + } +} diff --git a/vm/tests/src/test/resources/com/codename1/tools/translator/TimeZoneOffsetFrameApp.java b/vm/tests/src/test/resources/com/codename1/tools/translator/TimeZoneOffsetFrameApp.java new file mode 100644 index 00000000000..63ecce2c25e --- /dev/null +++ b/vm/tests/src/test/resources/com/codename1/tools/translator/TimeZoneOffsetFrameApp.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2026, 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. + */ +/** + * Offsets around the 2020 US spring-forward transition, from both directions. + * + * TimeZoneOffsetFrameTest runs this on the host JDK and through ParparVM and + * requires identical output. The two halves are the two frames that were being + * confused: getOffset takes local STANDARD time fields, while an Instant has to + * be converted into that frame before it can be asked about. + */ +import java.util.Calendar; +import java.util.TimeZone; + +public class TimeZoneOffsetFrameApp { + + public static void main(String[] args) { + StringBuilder sb = new StringBuilder("RESULT="); + String[] zones = { "America/New_York", "Europe/Berlin", "Asia/Tokyo" }; + for (int z = 0; z < zones.length; z++) { + TimeZone tz = TimeZone.getTimeZone(zones[z]); + // Local-standard fields straight across the US transition. + for (int hour = 0; hour < 6; hour++) { + sb.append(zones[z]).append('@').append(hour).append('=') + .append(tz.getOffset(1, 2020, 2, 8, Calendar.SUNDAY, hour * 3600000)) + .append(';'); + } + // The same day approached as instants, one per hour of UTC. + for (int hour = 0; hour < 12; hour++) { + long instant = 1583625600000L + hour * 3600000L; // 2020-03-08T00:00Z + java.util.Date d = new java.util.Date(instant); + Calendar cal = Calendar.getInstance(tz); + cal.setTime(d); + sb.append(zones[z]).append('#').append(hour).append('=') + .append(cal.get(Calendar.HOUR_OF_DAY)).append(':') + .append(cal.get(Calendar.MINUTE)).append(';'); + } + } + System.out.println(sb.toString()); + } +} From fff82c457102c589ef27b5493f1a05d7e1f2e057 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:19:49 +0700 Subject: [PATCH 65/91] Reject AAD outside AES-GCM, and report frames when the port has no stack AAD only binds to ciphertext under an AEAD mode. CBC and ECB ignore it, so the Linux and Windows wrappers validated the IV and then handed it to a native that silently dropped it -- producing ciphertext the caller believed was authenticated and that JavaSE and Android would refuse, since they route the same call through Cipher.updateAAD which rejects a non-AEAD mode. Both ports refuse it now, before the native call. Also: the Windows strict gate armed in the previous commit is working. That producer reports pass=165 fail=4 skip=1 where master reports pass=160 fail=7 not-run=2, so this branch has already fixed CryptoApiTest, FileSystemStorageOpenInputStreamMissingTest, SurfacesPublishTest (the UnicodeHelper.getClasses failure the Character work removed), TimeApiTest and CalendarApiTest, which no longer fails to run. The four that remain are pre-existing Windows defects, and two of them -- KotlinUiTest and SwitchThemeScreenshotTest, both of which construct a Switch -- report only "NullPointerException" with stackLength=0, because the implementation's stack capture comes back empty on that port. A throwable with no location and CI as the only place it reproduces is not something to guess at, so logThrowable now falls back to Throwable's own frames when the implementation yields nothing. No new lambda or anonymous class: the runner's count is unchanged at 11, which is what keeps the JavaScript port's lambda numbering stable. Co-Authored-By: Claude Opus 5 (1M context) --- .../impl/linux/LinuxImplementation.java | 20 +++++++++++++ .../impl/windows/WindowsImplementation.java | 20 +++++++++++++ .../tests/Cn1ssDeviceRunner.java | 29 +++++++++++++++++-- 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java index d274fb39b9a..202e4ef2e16 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java @@ -2781,6 +2781,24 @@ public void secureRandomBytes(byte[] out) { /// Rejects an initialization vector the mode cannot use. A GCM nonce that /// is absent repeats across messages under one key, and a short CBC IV is /// read as a whole block by the platform library. + /// AAD only binds to the ciphertext under an AEAD mode. CBC and ECB ignore it + /// entirely, so passing it here quietly dropped data the caller believed was + /// authenticated -- and produced ciphertext the other ports reject, because + /// JavaSE and Android route the same call through Cipher.updateAAD, which + /// refuses a non-AEAD mode. Refusing it keeps the ports answering alike and + /// keeps a caller from believing in a binding that was never made. + private static void checkAad(String transformation, byte[] aad) { + if (aad == null || aad.length == 0) { + return; + } + String mode = transformation == null ? "" : transformation; + if (mode.indexOf("/GCM/") < 0) { + throw new RuntimeException( + "Additional authenticated data requires an AEAD mode; " + mode + + " cannot bind it"); + } + } + private static void checkIv(String transformation, byte[] iv) { String mode = transformation == null ? "" : transformation; if (mode.indexOf("/GCM/") >= 0) { @@ -2795,6 +2813,7 @@ private static void checkIv(String transformation, byte[] iv) { @Override public byte[] aesEncrypt(String transformation, byte[] key, byte[] iv, byte[] aad, byte[] plaintext) { checkIv(transformation, iv); + checkAad(transformation, aad); return cryptoResult(LinuxNative.aesCrypt(transformation, true, key, iv, aad, plaintext), "AES encrypt"); } @@ -2802,6 +2821,7 @@ public byte[] aesEncrypt(String transformation, byte[] key, byte[] iv, byte[] aa @Override public byte[] aesDecrypt(String transformation, byte[] key, byte[] iv, byte[] aad, byte[] ciphertext) { checkIv(transformation, iv); + checkAad(transformation, aad); return cryptoResult(LinuxNative.aesCrypt(transformation, false, key, iv, aad, ciphertext), "AES decrypt"); } diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java index b242f3204b8..acbcee40da9 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java @@ -2789,6 +2789,24 @@ public void secureRandomBytes(byte[] out) { /// Rejects an initialization vector the mode cannot use. A GCM nonce that /// is absent repeats across messages under one key, and a short CBC IV is /// read as a whole block by the platform library. + /// AAD only binds to the ciphertext under an AEAD mode. CBC and ECB ignore it + /// entirely, so passing it here quietly dropped data the caller believed was + /// authenticated -- and produced ciphertext the other ports reject, because + /// JavaSE and Android route the same call through Cipher.updateAAD, which + /// refuses a non-AEAD mode. Refusing it keeps the ports answering alike and + /// keeps a caller from believing in a binding that was never made. + private static void checkAad(String transformation, byte[] aad) { + if (aad == null || aad.length == 0) { + return; + } + String mode = transformation == null ? "" : transformation; + if (mode.indexOf("/GCM/") < 0) { + throw new RuntimeException( + "Additional authenticated data requires an AEAD mode; " + mode + + " cannot bind it"); + } + } + private static void checkIv(String transformation, byte[] iv) { String mode = transformation == null ? "" : transformation; if (mode.indexOf("/GCM/") >= 0) { @@ -2803,6 +2821,7 @@ private static void checkIv(String transformation, byte[] iv) { @Override public byte[] aesEncrypt(String transformation, byte[] key, byte[] iv, byte[] aad, byte[] plaintext) { checkIv(transformation, iv); + checkAad(transformation, aad); return cryptoResult(WindowsNative.aesCrypt(transformation, true, key, iv, aad, plaintext), "AES encrypt"); } @@ -2810,6 +2829,7 @@ public byte[] aesEncrypt(String transformation, byte[] key, byte[] iv, byte[] aa @Override public byte[] aesDecrypt(String transformation, byte[] key, byte[] iv, byte[] aad, byte[] ciphertext) { checkIv(transformation, iv); + checkAad(transformation, aad); return cryptoResult(WindowsNative.aesCrypt(transformation, false, key, iv, aad, ciphertext), "AES decrypt"); } diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java index 74696b897a2..2f885ea6bb3 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java @@ -775,10 +775,18 @@ private static void logThrowable(String context, Throwable t) { log("CN1SS:ERR:throwable context=" + context + " message=" + String.valueOf(t.getMessage())); String stack = Display.getInstance().getStackTrace(Thread.currentThread(), t); if (stack == null) { - log("CN1SS:ERR:throwable context=" + context + " stack=null"); - return; + stack = ""; } log("CN1SS:ERR:throwable context=" + context + " stackLength=" + stack.length()); + if (stack.length() == 0) { + // The implementation's own capture comes back empty on some ports -- + // ParparVM Windows reports stackLength=0 for every throwable -- which + // leaves a NullPointerException with no location at all, and CI is the + // only place these run. Throwable's own frames are worth asking for + // before giving up; on a port that fills them in this is the difference + // between naming the line and guessing at it. + logThrowableFrames(context, t); + } for (String line : StringUtil.tokenize(stack, '\n')) { if (line.length() > 200) { line = line.substring(0, 200); @@ -787,6 +795,23 @@ private static void logThrowable(String context, Throwable t) { } } + private static void logThrowableFrames(String context, Throwable t) { + StackTraceElement[] frames; + try { + frames = t.getStackTrace(); + } catch (Throwable unsupported) { + log("CN1SS:ERR:throwable context=" + context + " frames=unsupported"); + return; + } + if (frames == null || frames.length == 0) { + log("CN1SS:ERR:throwable context=" + context + " frames=none"); + return; + } + for (int i = 0; i < frames.length && i < 24; i++) { + log("CN1SS:ERR:throwable context=" + context + " frame=" + String.valueOf(frames[i])); + } + } + @Override protected void startApplicationInstance() { Cn1ssDeviceRunnerHelper.runOnEdtSync(() -> { From b7e182170ccc3f883b6a63f7ef6b75ee213ac609 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:15:37 +0700 Subject: [PATCH 66/91] Merge master, and instrument the Windows Switch failure The frame fallback added last commit answered "frames=none": ParparVM Windows populates neither Display.getStackTrace nor Throwable.getStackTrace, so a NullPointerException there carries no location whatsoever and CI is the only place it runs. KotlinUiTest and SwitchThemeScreenshotTest are the two failures, and Switch is what they share -- the other DualAppearance theme tests pass on Windows, and KotlinUiTest fails on its first statement. Reasoning got as far as ruling out the obvious null sources (ImageFactory.defaultFactory is statically initialised, getThemeConstant returns its default, createForm is used by passing tests) without naming the line, and guessing at date/graphics code that only reproduces on a Windows runner is how the Linux hang wasted several rounds. So: breadcrumbs, one per construction, and let the next run say which. Cheap on every other port and removed once the Windows failure is fixed. Co-Authored-By: Claude Opus 5 (1M context) --- .../hellocodenameone/tests/KotlinUiTest.kt | 41 ++++++++++++++----- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt b/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt index 239694cddea..70ce75248fa 100644 --- a/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt +++ b/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt @@ -13,18 +13,39 @@ import com.codename1.ui.TextField import com.codename1.ui.layouts.BoxLayout class KotlinUiTest : BaseTest() { + // Breadcrumbs because the Windows port reports NullPointerException with no + // stack at all -- Display.getStackTrace and Throwable.getStackTrace both come + // back empty there, so CI gives a bare exception and no location. Cheap on + // every other port, and the difference between naming the failing + // construction and guessing at it. Remove once the Windows failure is fixed. + private fun step(name: String) { + System.out.println("CN1SS:INFO:kotlin-step=" + name) + } + override fun runTest(): Boolean { + step("form") val kotlinForm = createForm("Kotlin", BoxLayout.y(), "kotlin") - kotlinForm.addAll( - Label("Kotlin UI Test Components"), - Button("Kotlin Button"), - BoxLayout.encloseX(Switch(), Switch().apply { setOn() }), - TextField("", "Enter name"), - Slider().apply { - isEditable = true - progress = 50 - } - ) + step("label") + val label = Label("Kotlin UI Test Components") + step("button") + val button = Button("Kotlin Button") + step("switch-off") + val switchOff = Switch() + step("switch-on-ctor") + val switchOn = Switch() + step("switch-on-seton") + switchOn.setOn() + step("switch-row") + val switchRow = BoxLayout.encloseX(switchOff, switchOn) + step("textfield") + val textField = TextField("", "Enter name") + step("slider") + val slider = Slider() + slider.isEditable = true + slider.progress = 50 + step("addAll") + kotlinForm.addAll(label, button, switchRow, textField, slider) + step("added") val accordion = Accordion() accordion.addContent("Details", BoxLayout.encloseY( From dc74eb94bed7eb225b802cd2e43fb966ecb2b8fc Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:16:34 +0700 Subject: [PATCH 67/91] Add the missing copyright header to KotlinUiTest Editing it brought a pre-existing header-less file into the gate's scope, and I pushed it without re-reading the gate's output. It reported the failure at the time; a ';' between the check and the commit let it through. Co-Authored-By: Claude Opus 5 (1M context) --- .../hellocodenameone/tests/KotlinUiTest.kt | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt b/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt index 70ce75248fa..1df55fddd96 100644 --- a/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt +++ b/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2026, 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.codenameone.examples.hellocodenameone.tests import com.codename1.components.Accordion From f675b2f4d7e3a008dc3dfef17026691af07026c7 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:38:47 +0700 Subject: [PATCH 68/91] Reject ECB IVs, sweep timed-out runs, and instrument the rest of KotlinUiTest ECB has no IV. Both native desktop ports skipped validation for it, so the native ignored the IV and returned ciphertext while quietly discarding a parameter the caller believed mattered -- and JavaSE and Android reject the same call, because they pass a non-null iv to JCE as an IvParameterSpec and ECB refuses it. Both ports refuse it now. The sweep's candidate filter accepted only success and failure. GitHub reports timed_out and startup_failure separately, and a producer that times out before normalization uploads no report at all -- precisely what the "newest run uploaded no port-status artifact" check exists to catch. Those runs never reached it, so an older still-fresh report covered the port and the sweep stayed green over a producer that emitted nothing. Every terminal conclusion except cancelled and skipped now counts; cancelled stays out because that is a superseded run, not a failing producer. Checked against a fixture: timed_out, startup_failure and success are selected, cancelled and pull_request are not. The KotlinUiTest breadcrumbs reached "added", which rules out Switch construction -- my hypothesis, and wrong. Only the first half of the test was instrumented; the Accordion, MultiButton, TextArea and show() half was not. Instrumented now, so the next run names the statement instead of me guessing at a third theory. Co-Authored-By: Claude Opus 5 (1M context) --- .../impl/linux/LinuxImplementation.java | 11 +++++- .../impl/windows/WindowsImplementation.java | 11 +++++- .../hellocodenameone/tests/KotlinUiTest.kt | 38 ++++++++++++------- .../conformance/backfill_port_status.sh | 11 +++++- 4 files changed, 55 insertions(+), 16 deletions(-) diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java index 202e4ef2e16..a1fb5f84d0f 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java @@ -2805,7 +2805,16 @@ private static void checkIv(String transformation, byte[] iv) { if (iv == null || iv.length == 0) { throw new RuntimeException("AES-GCM requires a nonce"); } - } else if (mode.indexOf("/ECB/") < 0 && (iv == null || iv.length != 16)) { + } else if (mode.indexOf("/ECB/") >= 0) { + // ECB has no IV. The native ignores one, so passing it produced + // ciphertext while quietly discarding a parameter the caller thought + // mattered -- and JavaSE and Android reject the same call, because + // they hand a non-null iv to JCE as an IvParameterSpec and ECB + // refuses it. Refusing here keeps the ports answering alike. + if (iv != null && iv.length > 0) { + throw new RuntimeException("AES-ECB cannot use an initialization vector"); + } + } else if (iv == null || iv.length != 16) { throw new RuntimeException("AES-CBC requires a 16 byte initialization vector"); } } diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java index acbcee40da9..70f6bbbed6f 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java @@ -2813,7 +2813,16 @@ private static void checkIv(String transformation, byte[] iv) { if (iv == null || iv.length == 0) { throw new RuntimeException("AES-GCM requires a nonce"); } - } else if (mode.indexOf("/ECB/") < 0 && (iv == null || iv.length != 16)) { + } else if (mode.indexOf("/ECB/") >= 0) { + // ECB has no IV. The native ignores one, so passing it produced + // ciphertext while quietly discarding a parameter the caller thought + // mattered -- and JavaSE and Android reject the same call, because + // they hand a non-null iv to JCE as an IvParameterSpec and ECB + // refuses it. Refusing here keeps the ports answering alike. + if (iv != null && iv.length > 0) { + throw new RuntimeException("AES-ECB cannot use an initialization vector"); + } + } else if (iv == null || iv.length != 16) { throw new RuntimeException("AES-CBC requires a 16 byte initialization vector"); } } diff --git a/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt b/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt index 1df55fddd96..77193a3c9e5 100644 --- a/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt +++ b/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt @@ -69,31 +69,43 @@ class KotlinUiTest : BaseTest() { kotlinForm.addAll(label, button, switchRow, textField, slider) step("added") + step("accordion-ctor") val accordion = Accordion() - accordion.addContent("Details", BoxLayout.encloseY( - MultiButton("MultiButton Line 1").apply { - setTextLine2("Additional detail line") - }, - MultiButton("MultiButton Line 2").apply { - setTextLine2("More detail for Kotlin UI") - } - )) + step("multibutton-1") + val mb1 = MultiButton("MultiButton Line 1") + mb1.setTextLine2("Additional detail line") + step("multibutton-2") + val mb2 = MultiButton("MultiButton Line 2") + mb2.setTextLine2("More detail for Kotlin UI") + step("accordion-details") + accordion.addContent("Details", BoxLayout.encloseY(mb1, mb2)) + step("checkbox") + val check = CheckBox("Enable notifications") + step("prefs-switch") + val prefSwitch = Switch() + prefSwitch.setOn() + step("textarea") + val note = TextArea(3, 20) + step("textarea-hint") + note.hint = "Add a short note" + step("prefs-container") val preferences = Container(BoxLayout.y()) - preferences.addAll( - CheckBox("Enable notifications"), - Switch().apply { setOn() }, - TextArea(3, 20).apply { hint = "Add a short note" } - ) + preferences.addAll(check, prefSwitch, note) + step("accordion-prefs") accordion.addContent("Preferences", preferences) + step("accordion-summary") accordion.addContent("Summary", BoxLayout.encloseY( Label("Accordion showcases grouped UI"), Button("Confirm Settings") )) + step("form-add") kotlinForm.add(accordion) + step("form-show") kotlinForm.show() + step("shown") return true } } \ No newline at end of file diff --git a/scripts/hellocodenameone/conformance/backfill_port_status.sh b/scripts/hellocodenameone/conformance/backfill_port_status.sh index 0843d977994..2b56658997f 100755 --- a/scripts/hellocodenameone/conformance/backfill_port_status.sh +++ b/scripts/hellocodenameone/conformance/backfill_port_status.sh @@ -127,6 +127,15 @@ while IFS= read -r workflow; do # normalized report, and a report that records real failures is the result # the table is supposed to show. # + # Every terminal conclusion except cancelled/skipped counts, not just success + # and failure. GitHub reports timed_out and startup_failure separately, and a + # producer that times out before normalization uploads no report at all -- + # exactly the case the "newest run uploaded no port-status artifact" check + # exists to catch. Filtering those runs out here meant they never reached it, + # so an older still-fresh report covered the port and the sweep stayed green + # over a producer that emitted nothing. Cancelled stays excluded: that is + # someone superseding a run, not the producer failing. + # # workflow_dispatch counts too. The producers declare dispatch and schedule # rather than push, so a maintainer rerunning one on master to repair a port # the scheduled run missed is exactly the recovery this sweep exists to pick @@ -145,7 +154,7 @@ while IFS= read -r workflow; do candidates="$(gh run list --workflow "${workflow}" --branch master --limit 100 \ --json databaseId,event,conclusion,updatedAt \ | jq -r --arg horizon "${horizon}" '[.[] | select((.event == "push" or .event == "schedule" or .event == "workflow_dispatch") and - (.conclusion == "success" or .conclusion == "failure") and + (.conclusion != null and .conclusion != "cancelled" and .conclusion != "skipped") and (.updatedAt >= $horizon))] | sort_by(.updatedAt) | reverse | .[].databaseId')" if [ -z "${candidates}" ]; then From 26234d62a0191dbf76fda92dff38215356732697 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 6 Aug 2026 07:52:21 +0700 Subject: [PATCH 69/91] Stop the EDT harness discarding its own dispatches, and retry stalled tests Two flakes, both pre-existing, both fixed at the mechanism rather than retried. core-unittests: FileTreeTest and SpanLabelWidthCapTest failed with "FormTest timed out after 5000ms" on CI and never locally, and the elapsed times said every wait burned its full timeout -- the dispatch never ran at all. UITestBase.tearDownDisplay clears runningSerialCallsQueue, and that teardown is itself dispatched onto the EDT by EDTTestInterceptor, so it runs while that queue is being drained and empties it: any sibling @AfterEach dispatch still sitting in it is discarded, and its waiter then waits out the full timeout for work that no longer exists. flushEdt() above it has already drained the queue, so the clear could only ever destroy someone else's in-flight work. Removed. The interceptor's wait was also `if (!completed) lock.wait(timeout)`, which treats any early return from wait() as expiry -- reporting a 5000ms timeout without having waited 5000ms. It is a deadline loop now, and the timeout message reports the pending serial-call count, which distinguishes "slow" from "never going to run". hellocodenameone: the Apple ports fail one random test per run with "timed out waiting for DONE stage=created" -- AccessibilityTest this round, MutableImageClipReadbackTest and VectorMapShapesScreenshotTest before. All three retry predicates required shouldTakeScreenshot(), so a transient stall in the show -> settle-timer -> DONE chain was recovered for a screenshot test and fatal for one that captures nothing. The stall is in the show/EDT chain, not the capture, so the silent-timeout retry no longer asks about screenshots. Still one-shot per index and still excludes a test that actually failed, so a broken test times out twice and fails. Verified: 4754 core tests with SpotBugs, suite builds, runner lambda count unchanged (7 code lines carrying `->`; the 12th match was a comment). Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/junit/EDTTestInterceptor.java | 37 +++++++++++++++++-- .../java/com/codename1/junit/UITestBase.java | 16 ++++---- .../tests/Cn1ssDeviceRunner.java | 13 ++++++- 3 files changed, 54 insertions(+), 12 deletions(-) diff --git a/maven/core-unittests/src/test/java/com/codename1/junit/EDTTestInterceptor.java b/maven/core-unittests/src/test/java/com/codename1/junit/EDTTestInterceptor.java index cf8a180c84e..2f1753425a4 100644 --- a/maven/core-unittests/src/test/java/com/codename1/junit/EDTTestInterceptor.java +++ b/maven/core-unittests/src/test/java/com/codename1/junit/EDTTestInterceptor.java @@ -52,10 +52,19 @@ private void runOnMyThread(Invocation invocation) throws Throwable { } }); + // A deadline loop, not a single wait. Object.wait may return before the + // timeout for reasons of its own, and the previous `if` treated any such + // return as expiry -- reporting "timed out after 5000ms" without having + // waited 5000ms, from a run that was about to succeed. try { + long deadline = System.currentTimeMillis() + DEFAULT_TIMEOUT_MILLIS; synchronized (lock) { - if (!completed[0]) { - lock.wait(DEFAULT_TIMEOUT_MILLIS); + while (!completed[0]) { + long remaining = deadline - System.currentTimeMillis(); + if (remaining <= 0) { + break; + } + lock.wait(remaining); } } } catch (InterruptedException ie) { @@ -64,7 +73,8 @@ private void runOnMyThread(Invocation invocation) throws Throwable { } if (!completed[0]) { - throw new AssertionError("FormTest timed out after " + DEFAULT_TIMEOUT_MILLIS + "ms"); + throw new AssertionError("FormTest timed out after " + DEFAULT_TIMEOUT_MILLIS + + "ms; edt=" + edtState()); } Throwable t = thrown.get(); @@ -75,4 +85,25 @@ protected void beforePretest() {} protected void pretest(String testName) { } + + /// Whether the dispatch thread was even running when the wait expired. A + /// timeout means one of two very different things -- the work was slow, or it + /// was never going to run -- and the message could not tell them apart. + private static String edtState() { + try { + if (!com.codename1.ui.Display.isInitialized()) { + return "display-not-initialized"; + } + // Serial calls queued but undrained is the signature of a dispatch that + // was discarded rather than one that ran slowly. + java.lang.reflect.Field f = + com.codename1.ui.Display.class.getDeclaredField("pendingSerialCalls"); + f.setAccessible(true); + Object pending = f.get(com.codename1.ui.Display.getInstance()); + int queued = pending instanceof java.util.List ? ((java.util.List) pending).size() : -1; + return "initialized pendingSerialCalls=" + queued; + } catch (Throwable unavailable) { + return "unavailable"; + } + } } \ No newline at end of file diff --git a/maven/core-unittests/src/test/java/com/codename1/junit/UITestBase.java b/maven/core-unittests/src/test/java/com/codename1/junit/UITestBase.java index 8f6deb4af77..b6476f97d00 100644 --- a/maven/core-unittests/src/test/java/com/codename1/junit/UITestBase.java +++ b/maven/core-unittests/src/test/java/com/codename1/junit/UITestBase.java @@ -98,13 +98,15 @@ protected void tearDownDisplay() throws Exception { pending.clear(); } - Field runningField = Display.class.getDeclaredField("runningSerialCallsQueue"); - runningField.setAccessible(true); - @SuppressWarnings("unchecked") - Deque running = (Deque) runningField.get(display); - if (running != null) { - running.clear(); - } + // runningSerialCallsQueue is NOT cleared. This teardown is itself + // dispatched onto the EDT by EDTTestInterceptor, so it runs while that + // queue is being drained -- and emptying it discards any sibling + // @AfterEach dispatch still waiting in it. The waiter for that dispatch + // then burns its full timeout and reports "FormTest timed out after + // 5000ms" from a test that was never slow, which is how FileTreeTest and + // SpanLabelWidthCapTest failed intermittently on CI and never locally. + // flushEdt() above has already drained the queue; there is nothing left + // to clear that is not someone else's work in flight. } catch (Exception ignored) { } diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java index ba59eae2ac2..e97b04af4e8 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java @@ -669,12 +669,21 @@ private void finalizeTest(int index, BaseTest testClass, String testName, boolea /// - the test actually takes a screenshot (non-screenshot tests may have /// side effects that aren't safe to repeat, and a missing tile is the /// only failure mode this retry exists to prevent). + /// One retry for a test that timed out having neither failed nor started a + /// capture -- the show -> settle-timer -> DONE chain was swallowed. + /// + /// Deliberately NOT conditioned on shouldTakeScreenshot(). It used to be, on + /// the reasoning that the retry existed to recover a missing screenshot, but + /// the stall is in the show/EDT chain and hits any test: AccessibilityTest and + /// MutableImageClipReadbackTest capture nothing, so a transient stall that a + /// screenshot test shrugs off failed them outright, on a different test each + /// run. A test that is genuinely broken still fails -- it times out the second + /// time too, and the retry is one-shot per index. private boolean shouldRetryAfterSilentTimeout(int index, BaseTest testClass) { return retriedTestIndex != index && !"HTML5".equals(Display.getInstance().getPlatformName()) && !testClass.isFailed() - && !testClass.isCaptureStarted() - && testClass.shouldTakeScreenshot(); + && !testClass.isCaptureStarted(); } private boolean shouldRetryAfterTransportFailure(int index, BaseTest testClass) { From d55ae23b6e70a99c13f4aa53a36d3e4e7cafebc6 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 6 Aug 2026 07:53:54 +0700 Subject: [PATCH 70/91] Add the missing copyright headers to the EDT test harness Editing them brought two pre-existing header-less files into the gate's scope. The gate reported it and I missed it again: piping its output into tail meant '&&' chained on tail's exit status, not the script's, so the guard I added last time never actually guarded anything. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/junit/EDTTestInterceptor.java | 22 +++++++++++++++++++ .../java/com/codename1/junit/UITestBase.java | 22 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/maven/core-unittests/src/test/java/com/codename1/junit/EDTTestInterceptor.java b/maven/core-unittests/src/test/java/com/codename1/junit/EDTTestInterceptor.java index 2f1753425a4..7e9e67de920 100644 --- a/maven/core-unittests/src/test/java/com/codename1/junit/EDTTestInterceptor.java +++ b/maven/core-unittests/src/test/java/com/codename1/junit/EDTTestInterceptor.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2026, 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.junit; import com.codename1.ui.CN; diff --git a/maven/core-unittests/src/test/java/com/codename1/junit/UITestBase.java b/maven/core-unittests/src/test/java/com/codename1/junit/UITestBase.java index b6476f97d00..3f188b7a5a0 100644 --- a/maven/core-unittests/src/test/java/com/codename1/junit/UITestBase.java +++ b/maven/core-unittests/src/test/java/com/codename1/junit/UITestBase.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2026, 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.junit; import com.codename1.impl.ImplementationFactory; From 15bfffb53ba9140c649009b6e98d1ebfa9442ff2 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 6 Aug 2026 09:37:58 +0700 Subject: [PATCH 71/91] Bisect the Windows Accordion.addContent failure The breadcrumbs localised it: addContent("Preferences", preferences) is the statement that throws. Every construction before it prints, including the container and all three children, and the first addContent -- whose body is two MultiButtons -- succeeds. So the failure is in what addContent does to one of CheckBox, Switch or TextArea: setHidden(true), which caches the body's margins and forces a zero preferred size. Three probe sections, one child each, to say which. Guessing at it from here would be the third theory in a row about this NPE; the first two were wrong and the breadcrumbs were what corrected them both. Co-Authored-By: Claude Opus 5 (1M context) --- .../examples/hellocodenameone/tests/KotlinUiTest.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt b/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt index 77193a3c9e5..11886884195 100644 --- a/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt +++ b/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt @@ -92,6 +92,17 @@ class KotlinUiTest : BaseTest() { step("prefs-container") val preferences = Container(BoxLayout.y()) preferences.addAll(check, prefSwitch, note) + // Bisect: addContent("Preferences", ...) is where the Windows port throws. + // The container and all three children construct fine (the steps above all + // print), so the failure is in what addContent does to one of them -- + // setHidden(true), which caches margins and forces a zero preferred size. + // One probe section per child says which. + step("probe-checkbox") + accordion.addContent("ProbeCheck", Container(BoxLayout.y()).apply { add(CheckBox("probe")) }) + step("probe-switch") + accordion.addContent("ProbeSwitch", Container(BoxLayout.y()).apply { add(Switch()) }) + step("probe-textarea") + accordion.addContent("ProbeText", Container(BoxLayout.y()).apply { add(TextArea(3, 20)) }) step("accordion-prefs") accordion.addContent("Preferences", preferences) From 6f02a353623de2a61699964626159c4a9cdc827d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:18:00 +0700 Subject: [PATCH 72/91] Probe on a throwaway Accordion, not the rendered one My bisect probes went into the Accordion the test actually shows, so they changed the rendered "kotlin" screenshot and failed the golden comparison on Android, JavaScript, Linux and the Mac/Windows comparators -- six jobs red for a diagnostic. That was careless: a screenshot test's form is its assertion. addContent does its work in the AccordionContent constructor, so an unparented Accordion exercises the identical path and renders nothing. Same measurement, no visible change. Co-Authored-By: Claude Opus 5 (1M context) --- .../examples/hellocodenameone/tests/KotlinUiTest.kt | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt b/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt index 11886884195..2e344d098b3 100644 --- a/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt +++ b/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt @@ -97,12 +97,19 @@ class KotlinUiTest : BaseTest() { // print), so the failure is in what addContent does to one of them -- // setHidden(true), which caches margins and forces a zero preferred size. // One probe section per child says which. + // Probed on a throwaway Accordion that is never added to the form. The + // first version of this put the probes into the real one, which changed + // the rendered "kotlin" screenshot and failed the golden comparison on + // every other port -- a diagnostic is not worth breaking six jobs for. + // addContent does its work in the AccordionContent constructor, so an + // unparented Accordion exercises exactly the same path and renders nothing. + val probe = Accordion() step("probe-checkbox") - accordion.addContent("ProbeCheck", Container(BoxLayout.y()).apply { add(CheckBox("probe")) }) + probe.addContent("ProbeCheck", Container(BoxLayout.y()).apply { add(CheckBox("probe")) }) step("probe-switch") - accordion.addContent("ProbeSwitch", Container(BoxLayout.y()).apply { add(Switch()) }) + probe.addContent("ProbeSwitch", Container(BoxLayout.y()).apply { add(Switch()) }) step("probe-textarea") - accordion.addContent("ProbeText", Container(BoxLayout.y()).apply { add(TextArea(3, 20)) }) + probe.addContent("ProbeText", Container(BoxLayout.y()).apply { add(TextArea(3, 20)) }) step("accordion-prefs") accordion.addContent("Preferences", preferences) From 6615b01464c5478c294d1ea6a673ecb855c27d84 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:42:01 +0700 Subject: [PATCH 73/91] Narrow the Windows NPE further, and back off properly on a 429 Round one of the bisect ruled out the obvious: a container holding a plain CheckBox, Switch or TextArea goes through addContent fine on Windows. So the trigger is something the real container does beyond constructing its children -- setOn() on the Switch, a hint on the TextArea, or holding three at once. One probe each; still on the unparented Accordion, so nothing renders. Separately, Build Android JDK 21 died on HTTP 429 fetching a JDK from GitHub releases. Every Android job provisions its own JDKs from the same release at the same moment, so the matrix trips the rate limit together -- and curl's default backoff of 1s, 2s, 4s meant the whole thing gave up inside seven seconds. --retry-delay 15 with six attempts and a 300s ceiling: curl waits for the larger of that and any Retry-After the server sends, and a genuinely dead mirror still fails the job rather than hanging. Co-Authored-By: Claude Opus 5 (1M context) --- .../hellocodenameone/tests/KotlinUiTest.kt | 15 +++++++++++++++ scripts/setup-workspace.sh | 10 ++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt b/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt index 2e344d098b3..654fde33f58 100644 --- a/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt +++ b/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt @@ -110,6 +110,21 @@ class KotlinUiTest : BaseTest() { probe.addContent("ProbeSwitch", Container(BoxLayout.y()).apply { add(Switch()) }) step("probe-textarea") probe.addContent("ProbeText", Container(BoxLayout.y()).apply { add(TextArea(3, 20)) }) + // Round one: all three plain children passed, so the difference is what the + // real container does beyond constructing them -- setOn() on the Switch, a + // hint on the TextArea, or simply holding three children at once. + step("probe-switch-on") + probe.addContent("ProbeSwitchOn", Container(BoxLayout.y()).apply { + add(Switch().apply { setOn() }) + }) + step("probe-textarea-hint") + probe.addContent("ProbeTextHint", Container(BoxLayout.y()).apply { + add(TextArea(3, 20).apply { hint = "probe hint" }) + }) + step("probe-three-plain") + probe.addContent("ProbeThree", Container(BoxLayout.y()).apply { + add(CheckBox("a")); add(Switch()); add(TextArea(3, 20)) + }) step("accordion-prefs") accordion.addContent("Preferences", preferences) diff --git a/scripts/setup-workspace.sh b/scripts/setup-workspace.sh index eedbdff6068..bef96488ae5 100755 --- a/scripts/setup-workspace.sh +++ b/scripts/setup-workspace.sh @@ -78,7 +78,13 @@ install_jdk() { log "Using cached JDK archive $(basename "$archive")" else log "Downloading JDK from $url" - curl -fL --retry 3 --retry-all-errors "$url" -o "$archive" + # --retry-delay 15: curl's default backoff is 1s, 2s, 4s, which is nothing + # against a rate limit. Every Android job provisions its own JDKs from the + # same GitHub release at the same moment, so the whole matrix can trip 429 + # together and all of it gave up inside seven seconds. curl waits for the + # larger of this and any Retry-After the server sends, and --retry-max-time + # bounds the whole thing so a genuinely dead mirror still fails the job. + curl -fL --retry 6 --retry-delay 15 --retry-max-time 300 --retry-all-errors "$url" -o "$archive" fi local top @@ -130,7 +136,7 @@ if [ -z "${MAVEN_HOME:-}" ] || ! [ -x "$MAVEN_HOME/bin/mvn" ]; then log "Using cached Maven archive $(basename "$mvn_archive")" else log "Downloading Maven from $MAVEN_URL" - curl -fL --retry 3 --retry-all-errors "$MAVEN_URL" -o "$mvn_archive" + curl -fL --retry 6 --retry-delay 15 --retry-max-time 300 --retry-all-errors "$MAVEN_URL" -o "$mvn_archive" fi mvn_top=$(tar -tzf "$mvn_archive" 2>/dev/null | head -1 | cut -d/ -f1 || true) if [ -z "$mvn_top" ]; then From c0e4c6ce1f4e05c03767991040521d9f8a13c115 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:07:25 +0700 Subject: [PATCH 74/91] Rename within the folder on Windows, as the contract says FileSystemStorage.rename documents newName as "relative to the current folder" -- a leaf name -- and the Linux port resolves it against the source's parent. Windows passed it straight to MoveFileExW as a full target, so a leaf name moved the file into the process working directory instead of renaming it in place. WAVWriter.close() does exactly that: it renames the recording to .pcm using new File(...).getName(), then reopens the .pcm to write the real WAV. On Windows the .pcm was not where it was left, so the reopen threw FileNotFoundException and AudioMixerApiTest failed -- reported as a missing file, for a file the port had quietly moved somewhere else. A leaf name now joins to the source's parent directory. An absolute target -- drive letter, UNC prefix or leading separator -- is still honoured as-is, so a caller depending on the old behaviour is unaffected. The old comment asserted the opposite contract and said a bare leaf name "would be a contract violation on the Java side". The Java side is CN1's own WAVWriter, and the javadoc agrees with it. Verified by cross-compiling the port into a real Windows PE with clang-cl + lld-link. Co-Authored-By: Claude Opus 5 (1M context) --- .../nativeSources/cn1_windows_io.c | 41 ++++++++++++++++--- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_io.c b/Ports/WindowsPort/nativeSources/cn1_windows_io.c index 214d2a45725..1c623128b0f 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_io.c +++ b/Ports/WindowsPort/nativeSources/cn1_windows_io.c @@ -280,14 +280,45 @@ JAVA_VOID com_codename1_impl_windows_WindowsNative_fileRename___java_lang_String UINT32 len1 = 0, len2 = 0; WCHAR* path = cn1WinJavaStringToWide(threadStateData, __cn1Arg1, &len1); /* - * Per the CN1 FileSystemStorage contract newName is the full target path, - * so it is passed directly to MoveFileExW. If a caller ever passes a bare - * leaf name the move would land in the process working directory; that - * would be a contract violation on the Java side. + * FileSystemStorage.rename documents newName as "relative to the current + * folder" -- a leaf name -- and the Linux port resolves it that way. This + * passed it straight to MoveFileExW as a full target, so a leaf name moved + * the file into the process working directory instead of renaming it in + * place. WAVWriter.close() does exactly that (it renames the recording to + * .pcm via new File(...).getName()) and then reopens the .pcm, which + * was not where it expected: AudioMixerApiTest failed on Windows with + * "No such file" for a file the port had quietly moved elsewhere. + * + * A leaf name is now joined to the source's parent directory. An absolute + * target -- one carrying a drive letter, a UNC prefix or a leading + * separator -- is still honoured as-is, so any caller relying on the old + * behaviour keeps working. */ WCHAR* newName = cn1WinJavaStringToWide(threadStateData, __cn1Arg2, &len2); + WCHAR* target = NULL; if (path != NULL && newName != NULL) { - MoveFileExW(path, newName, MOVEFILE_REPLACE_EXISTING | MOVEFILE_COPY_ALLOWED); + int absolute = (newName[0] == L'\\' || newName[0] == L'/' + || (newName[0] != 0 && newName[1] == L':')); + if (absolute) { + MoveFileExW(path, newName, MOVEFILE_REPLACE_EXISTING | MOVEFILE_COPY_ALLOWED); + } else { + WCHAR* lastBack = wcsrchr(path, L'\\'); + WCHAR* lastFwd = wcsrchr(path, L'/'); + WCHAR* sep = lastBack > lastFwd ? lastBack : lastFwd; + size_t dirLen = sep != NULL ? (size_t) (sep - path) + 1 : 0; + size_t nameLen = wcslen(newName); + target = (WCHAR*) malloc((dirLen + nameLen + 1) * sizeof(WCHAR)); + if (target != NULL) { + if (dirLen > 0) { + memcpy(target, path, dirLen * sizeof(WCHAR)); + } + memcpy(target + dirLen, newName, (nameLen + 1) * sizeof(WCHAR)); + MoveFileExW(path, target, MOVEFILE_REPLACE_EXISTING | MOVEFILE_COPY_ALLOWED); + } + } + } + if (target != NULL) { + free(target); } if (path != NULL) { free(path); From dedc1fc7084e3b9e7d1c825cb2c60db6ad4d0c6f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:43:10 +0700 Subject: [PATCH 75/91] Select the audio stream on Windows, and split the Switch probe VideoIORoundTripTest failed on Windows with "decoded clip reports audio but no PCM samples were returned" -- hasAudio true, readAudio empty. The reader never called SetStreamSelection. Which streams a source reader starts with depends on the presentation descriptor, and SetCurrentMediaType succeeds on a stream that is not selected, so configuring 16-bit PCM reported success and set hasAudio while ReadSample produced nothing. Everything is deselected now and the two streams this reader consumes are turned on explicitly. Compiled into a real Windows PE with clang-cl + lld-link. I cannot run a Windows binary here, so CI is the confirmation -- but the mechanism matches the symptom exactly, which is more than the last two theories about the NPE managed. On that NPE: the probes narrowed it to a Switch with setOn() reaching Accordion.addContent, while a plain Switch is fine and setOn() on its own is fine (the real test does both before the failure). The composite probe cannot tell constructing one from adding one, so it is split to the statement. Co-Authored-By: Claude Opus 5 (1M context) --- .../nativeSources/cn1_windows_video.cpp | 11 +++++++++++ .../hellocodenameone/tests/KotlinUiTest.kt | 17 +++++++++++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_video.cpp b/Ports/WindowsPort/nativeSources/cn1_windows_video.cpp index baeb61780d8..6a310bbb747 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_video.cpp +++ b/Ports/WindowsPort/nativeSources/cn1_windows_video.cpp @@ -108,6 +108,17 @@ static JAVA_LONG cn1ReaderOpen(const wchar_t* url) { return 0; } + // Select the streams explicitly. Which streams a source reader starts with + // depends on the presentation descriptor, and SetCurrentMediaType succeeds on + // a stream that is not selected -- so the audio configuration below reported + // success, hasAudio became true, and ReadSample then produced nothing at all. + // That is exactly what VideoIORoundTripTest saw on Windows: "decoded clip + // reports audio but no PCM samples were returned". Deselect everything, then + // turn on the two streams this reader actually consumes. + reader->SetStreamSelection((DWORD) MF_SOURCE_READER_ALL_STREAMS, FALSE); + reader->SetStreamSelection((DWORD) MF_SOURCE_READER_FIRST_VIDEO_STREAM, TRUE); + reader->SetStreamSelection((DWORD) MF_SOURCE_READER_FIRST_AUDIO_STREAM, TRUE); + CN1VideoReader* st = new CN1VideoReader(); st->reader = reader; st->width = 0; diff --git a/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt b/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt index 654fde33f58..4ff3529f4cb 100644 --- a/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt +++ b/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt @@ -113,10 +113,19 @@ class KotlinUiTest : BaseTest() { // Round one: all three plain children passed, so the difference is what the // real container does beyond constructing them -- setOn() on the Switch, a // hint on the TextArea, or simply holding three children at once. - step("probe-switch-on") - probe.addContent("ProbeSwitchOn", Container(BoxLayout.y()).apply { - add(Switch().apply { setOn() }) - }) + // Split to the statement, because the composite probe cannot distinguish + // constructing an on-switch from adding one to an Accordion -- and the + // real test already shows Switch()+setOn() on its own is fine. + step("probe-so-ctor") + val soSwitch = Switch() + step("probe-so-seton") + soSwitch.setOn() + step("probe-so-container") + val soContainer = Container(BoxLayout.y()) + soContainer.add(soSwitch) + step("probe-so-addcontent") + probe.addContent("ProbeSwitchOn", soContainer) + step("probe-so-done") step("probe-textarea-hint") probe.addContent("ProbeTextHint", Container(BoxLayout.y()).apply { add(TextArea(3, 20).apply { hint = "probe hint" }) From c8644d55f5c636c27783a1873db68ffb4fedb942 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:47:22 +0700 Subject: [PATCH 76/91] Report why Media Foundation yields no audio, and name the throwing accessor AudioMixerApiTest is fixed -- Windows went from pass=166 fail=4 to pass=167 fail=3 and the rename failure is gone. VideoIORoundTripTest still fails with SetStreamSelection in the build, so that was not the cause. Rather than guess a second time at a stack I cannot run here, readAudio now reports what MF actually did: read count, null-sample count, bytes collected, the last HRESULT and stream flags, and the negotiated rate/channels. It also bounds the null-sample path, which could otherwise spin forever on a stream that only ever ticks. For the NPE, the probes have it at addContent on a container holding an ON switch. The only state the ON path reaches that the OFF path does not is getSelectedStyle -- getThumbOnImage uses it where getThumbOffImage uses the unselected style -- and calcPreferredSize is what asks for the thumb. Both are public, so the test now touches unselectedStyle, selectedStyle and preferredSize in turn and the breadcrumbs will name which one throws. No probes in Switch itself. Cross-compiles into a real Windows PE. Co-Authored-By: Claude Opus 5 (1M context) --- .../nativeSources/cn1_windows_video.cpp | 21 ++++++++++++++++++- .../hellocodenameone/tests/KotlinUiTest.kt | 12 +++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_video.cpp b/Ports/WindowsPort/nativeSources/cn1_windows_video.cpp index 6a310bbb747..b58acd472c0 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_video.cpp +++ b/Ports/WindowsPort/nativeSources/cn1_windows_video.cpp @@ -275,17 +275,32 @@ static JAVA_OBJECT cn1ReaderReadAudio(CODENAME_ONE_THREAD_STATE, CN1VideoReader* } unsigned char* pcm = NULL; size_t pcmLen = 0, pcmCap = 0; + /* Diagnostics: readAudio returning empty is reported by the suite as + * "reports audio but no PCM samples were returned", which says nothing about + * WHY Media Foundation produced nothing -- and this only reproduces on a + * Windows runner. Report the reason once so the next run names it instead of + * inviting another guess. */ + int loops = 0, nullSamples = 0; + HRESULT lastHr = S_OK; + DWORD lastFlags = 0; for (;;) { DWORD streamFlags = 0; LONGLONG timestamp = 0; ComPtr sample; - if (FAILED(st->reader->ReadSample((DWORD) MF_SOURCE_READER_FIRST_AUDIO_STREAM, 0, NULL, &streamFlags, ×tamp, &sample))) { + loops++; + lastHr = st->reader->ReadSample((DWORD) MF_SOURCE_READER_FIRST_AUDIO_STREAM, 0, NULL, &streamFlags, ×tamp, &sample); + lastFlags = streamFlags; + if (FAILED(lastHr)) { break; } if (streamFlags & MF_SOURCE_READERF_ENDOFSTREAM) { break; } if (sample == NULL) { + nullSamples++; + if (nullSamples > 512) { + break; /* a stream that only ever ticks would spin here forever */ + } continue; } ComPtr buffer; @@ -310,6 +325,10 @@ static JAVA_OBJECT cn1ReaderReadAudio(CODENAME_ONE_THREAD_STATE, CN1VideoReader* buffer->Unlock(); } } + printf("CN1SS:INFO:winAudio reads=%d nullSamples=%d bytes=%u lastHr=0x%08lx lastFlags=0x%lx rate=%d ch=%d\n", + loops, nullSamples, (unsigned) pcmLen, (unsigned long) lastHr, + (unsigned long) lastFlags, st->audioRate, st->audioChannels); + fflush(stdout); JAVA_OBJECT result = JAVA_NULL; if (pcm != NULL && pcmLen > 0) { result = allocArray(threadStateData, (int) pcmLen, &class_array1__JAVA_BYTE, sizeof(JAVA_ARRAY_BYTE), 1); diff --git a/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt b/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt index 4ff3529f4cb..37ddc061d07 100644 --- a/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt +++ b/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt @@ -123,6 +123,18 @@ class KotlinUiTest : BaseTest() { step("probe-so-container") val soContainer = Container(BoxLayout.y()) soContainer.add(soSwitch) + // addContent on a container holding an ON switch is the failing statement. + // The only state the ON path reaches that the OFF path does not is + // getSelectedStyle() (getThumbOnImage uses it; getThumbOffImage uses the + // unselected style), and calcPreferredSize is what asks for the thumb. + // Both are public, so the accessor that throws can be named from here + // without probes in Switch itself. + step("probe-so-unselectedstyle") + soSwitch.unselectedStyle + step("probe-so-selectedstyle") + soSwitch.selectedStyle + step("probe-so-preferredsize") + soSwitch.preferredSize step("probe-so-addcontent") probe.addContent("ProbeSwitchOn", soContainer) step("probe-so-done") From 8175b20cc483c5a1a33a5e1c550ffb5e4634244f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:36:00 +0700 Subject: [PATCH 77/91] Read Windows audio from its own reader; stop handing back a 0 image peer Two Windows defects, both now diagnosed from CI rather than guessed at. Audio: the instrumentation answered plainly -- reads=1 nullSamples=0 bytes=0 lastFlags=0x2. That flag is MF_SOURCE_READERF_ENDOFSTREAM on the very first read after the rewind, so SetCurrentPosition was not bringing the audio stream back once the video pass had driven the file to end-of-stream, and its HRESULT was never checked. My earlier SetStreamSelection change did not fix this and was not the cause. readAudio now opens a source reader of its own from the stored URL: a fresh reader starts at the beginning by construction, so the audio track no longer depends on seek semantics or on what the video pass did to the shared position. The old rewind remains as a fallback. Images: createMutableImage returned 0 for any non-positive extent, and a 0 peer is worse than a tiny image -- Image.getGraphics() answers null for it and the caller dies with a NullPointerException far from the cause. That is the shape of the Switch failure: the probes put it in getPreferredSize(), whose track image is sized from the font height, and createRoundRectTrackImage calls img.getGraphics().setAntiAliased(true) with no null check. Clamped to 1x1 so the object is always usable, and the test reports the font height so the next run says whether a zero height is what got us there. Cross-compiles into a real Windows PE. Co-Authored-By: Claude Opus 5 (1M context) --- .../nativeSources/cn1_windows_image.cpp | 13 +++++- .../nativeSources/cn1_windows_video.cpp | 41 ++++++++++++++++--- .../hellocodenameone/tests/KotlinUiTest.kt | 4 ++ 3 files changed, 50 insertions(+), 8 deletions(-) diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_image.cpp b/Ports/WindowsPort/nativeSources/cn1_windows_image.cpp index 4bf4654de57..fd202311e6e 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_image.cpp +++ b/Ports/WindowsPort/nativeSources/cn1_windows_image.cpp @@ -441,8 +441,17 @@ JAVA_LONG com_codename1_impl_windows_WindowsNative_createMutableImage___int_int_ D2D1_COLOR_F clearColor; uint32_t a, r, g, b; - if (width <= 0 || height <= 0) { - return 0; + /* A zero or negative extent used to return 0, and a 0 peer is worse than a + * tiny image: Image.getGraphics() then answers null and the caller dies with + * a NullPointerException far from the cause -- which is how Switch's + * preferred-size calculation crashed on this port, since its track image is + * sized from the font height. Clamp to 1x1 so the object is always usable; + * a caller asking for an empty image gets an empty-looking one. */ + if (width <= 0) { + width = 1; + } + if (height <= 0) { + height = 1; } img = (CN1Image*)malloc(sizeof(CN1Image)); diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_video.cpp b/Ports/WindowsPort/nativeSources/cn1_windows_video.cpp index b58acd472c0..6205d2a0e26 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_video.cpp +++ b/Ports/WindowsPort/nativeSources/cn1_windows_video.cpp @@ -38,6 +38,7 @@ #include #include #include +#include #include "cn1_windows.h" using Microsoft::WRL::ComPtr; @@ -73,6 +74,8 @@ static void cn1StripFileWide(const char* utf8, wchar_t* out, int outLen) { struct CN1VideoReader { ComPtr reader; + /* The source URL, so readAudio can open a reader of its own. */ + std::wstring url; int width; int height; LONGLONG durationMs; @@ -121,6 +124,7 @@ static JAVA_LONG cn1ReaderOpen(const wchar_t* url) { CN1VideoReader* st = new CN1VideoReader(); st->reader = reader; + st->url = url; st->width = 0; st->height = 0; st->durationMs = -1; @@ -261,11 +265,35 @@ static JAVA_OBJECT cn1ReaderReadAudio(CODENAME_ONE_THREAD_STATE, CN1VideoReader* if (!st->hasAudio) { return JAVA_NULL; } - // readAudio() promises the entire audio track. A prior frameAt()/readFrames() - // may have repositioned the shared source reader via SetCurrentPosition, which - // moves every stream (not just video), so rewind to the start before draining - // the audio stream -- otherwise audio would begin at the last video seek. - { + // readAudio() promises the entire audio track, and the shared reader has + // usually been driven to end-of-file by frameAt()/readFrames() first. Rewinding + // it with SetCurrentPosition did not bring the audio stream back: the very + // first ReadSample after the seek returned MF_SOURCE_READERF_ENDOFSTREAM with + // zero bytes (reads=1 lastFlags=0x2), which is what left VideoIORoundTripTest + // reporting "reports audio but no PCM samples were returned" on Windows. + // + // Open a reader of our own instead. A fresh source reader starts at the + // beginning by construction, so the audio track no longer depends on seek + // semantics or on what the video pass did to the shared position. + ComPtr audioReader; + if (!st->url.empty()) { + ComPtr attrs; + if (SUCCEEDED(MFCreateAttributes(&attrs, 1))) { + MFCreateSourceReaderFromURL(st->url.c_str(), attrs.Get(), &audioReader); + } + } + if (audioReader != NULL) { + audioReader->SetStreamSelection((DWORD) MF_SOURCE_READER_ALL_STREAMS, FALSE); + audioReader->SetStreamSelection((DWORD) MF_SOURCE_READER_FIRST_AUDIO_STREAM, TRUE); + ComPtr pcmType; + if (SUCCEEDED(MFCreateMediaType(&pcmType))) { + pcmType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Audio); + pcmType->SetGUID(MF_MT_SUBTYPE, MFAudioFormat_PCM); + pcmType->SetUINT32(MF_MT_AUDIO_BITS_PER_SAMPLE, 16); + audioReader->SetCurrentMediaType((DWORD) MF_SOURCE_READER_FIRST_AUDIO_STREAM, NULL, pcmType.Get()); + } + } else { + /* No fresh reader: fall back to rewinding the shared one. */ PROPVARIANT pos; PropVariantInit(&pos); pos.vt = VT_I8; @@ -273,6 +301,7 @@ static JAVA_OBJECT cn1ReaderReadAudio(CODENAME_ONE_THREAD_STATE, CN1VideoReader* st->reader->SetCurrentPosition(GUID_NULL, pos); PropVariantClear(&pos); } + IMFSourceReader* src = audioReader != NULL ? audioReader.Get() : st->reader.Get(); unsigned char* pcm = NULL; size_t pcmLen = 0, pcmCap = 0; /* Diagnostics: readAudio returning empty is reported by the suite as @@ -288,7 +317,7 @@ static JAVA_OBJECT cn1ReaderReadAudio(CODENAME_ONE_THREAD_STATE, CN1VideoReader* LONGLONG timestamp = 0; ComPtr sample; loops++; - lastHr = st->reader->ReadSample((DWORD) MF_SOURCE_READER_FIRST_AUDIO_STREAM, 0, NULL, &streamFlags, ×tamp, &sample); + lastHr = src->ReadSample((DWORD) MF_SOURCE_READER_FIRST_AUDIO_STREAM, 0, NULL, &streamFlags, ×tamp, &sample); lastFlags = streamFlags; if (FAILED(lastHr)) { break; diff --git a/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt b/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt index 37ddc061d07..fb2046724dc 100644 --- a/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt +++ b/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt @@ -133,6 +133,10 @@ class KotlinUiTest : BaseTest() { soSwitch.unselectedStyle step("probe-so-selectedstyle") soSwitch.selectedStyle + // The track image is sized from the font height, and a zero height made + // createMutableImage hand back a broken peer. Report the number. + val f = soSwitch.style.font + step("probe-so-fontheight=" + (if (f == null) "null-font" else f.height.toString())) step("probe-so-preferredsize") soSwitch.preferredSize step("probe-so-addcontent") From 378c9483850c2c4680f76ec4ae1a316b61687b8f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:48:03 +0700 Subject: [PATCH 78/91] Drop the clamp, and report the metrics that explain these NPEs You are right that clamping to 1x1 was the wrong answer: something asking for a zero-sized image has a real bug -- a metric that came out zero -- and quietly handing back a 1x1 surface leaves that unfixed and renders wrongly instead of crashing. createMutableImage refuses a degenerate extent again, but says so in the log rather than returning a bare 0 with no explanation. I do not yet know why the metric is zero, and I am not going to guess a fourth time. DirectWrite is not failing -- cn1dwCreateFormat logs when CreateTextFormat fails and nothing appears in the Windows log -- so the format is being created and the cached font->height should be real. The font-height probe added last commit reports the actual number on the next run. Permanent diagnostics instead of one-off probes, as asked: every reported throwable now carries the platform state that turns NullPointerException with no stack into a lead -- display extent, density, EDT flag, default font height and system font height. A zero font height or a zero display extent is exactly how a component ends up asking for a zero-sized image whose graphics come back null. Printed only on failure, on every port, and it stays in. Runner lambda count unchanged at 7 code lines. Cross-compiles into a real Windows PE. Co-Authored-By: Claude Opus 5 (1M context) --- .../nativeSources/cn1_windows_image.cpp | 20 +++++----- .../tests/Cn1ssDeviceRunner.java | 37 +++++++++++++++++++ 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_image.cpp b/Ports/WindowsPort/nativeSources/cn1_windows_image.cpp index fd202311e6e..4b9054c0fcc 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_image.cpp +++ b/Ports/WindowsPort/nativeSources/cn1_windows_image.cpp @@ -441,17 +441,15 @@ JAVA_LONG com_codename1_impl_windows_WindowsNative_createMutableImage___int_int_ D2D1_COLOR_F clearColor; uint32_t a, r, g, b; - /* A zero or negative extent used to return 0, and a 0 peer is worse than a - * tiny image: Image.getGraphics() then answers null and the caller dies with - * a NullPointerException far from the cause -- which is how Switch's - * preferred-size calculation crashed on this port, since its track image is - * sized from the font height. Clamp to 1x1 so the object is always usable; - * a caller asking for an empty image gets an empty-looking one. */ - if (width <= 0) { - width = 1; - } - if (height <= 0) { - height = 1; + /* Refuse a degenerate extent, but say so. Returning a bare 0 makes + * Image.getGraphics() answer null and the caller die with a + * NullPointerException nowhere near the cause. Clamping to 1x1 would hide + * it just as badly: whatever asked for a zero-sized image has a real bug -- + * a metric that came out zero -- and silently handing back a 1x1 surface + * would leave that unfixed and render wrongly instead of crashing. */ + if (width <= 0 || height <= 0) { + cn1WindowsLog("createMutableImage refused a degenerate size"); + return 0; } img = (CN1Image*)malloc(sizeof(CN1Image)); diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java index e97b04af4e8..0d336aaa63f 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java @@ -26,6 +26,7 @@ import com.codename1.testing.TestReporting; import com.codename1.ui.CN; import com.codename1.ui.Display; +import com.codename1.ui.Font; import com.codename1.ui.Form; import com.codename1.util.StringUtil; import com.codenameone.examples.hellocodenameone.NativeInterfaceLanguageValidator; @@ -788,6 +789,7 @@ private static void logThrowable(String context, Throwable t) { stack = ""; } log("CN1SS:ERR:throwable context=" + context + " stackLength=" + stack.length()); + logPlatformState(context); if (stack.length() == 0) { // The implementation's own capture comes back empty on some ports -- // ParparVM Windows reports stackLength=0 for every throwable -- which @@ -805,6 +807,41 @@ private static void logThrowable(String context, Throwable t) { } } + /// Platform state alongside every reported throwable. + /// + /// Some ports report an exception with no stack at all -- ParparVM Windows + /// answers empty for both Display.getStackTrace and Throwable.getStackTrace -- + /// so a NullPointerException arrives as a bare type name and CI is the only + /// place it reproduces. The numbers below are the ones that turn such a + /// report into a lead: a zero font height or a zero display extent is how a + /// component ends up asking for a zero-sized image, whose graphics come back + /// null. Cheap, printed only on failure, and it costs nothing to leave in. + private static void logPlatformState(String context) { + StringBuilder sb = new StringBuilder("CN1SS:ERR:platform context="); + sb.append(context); + try { + Display d = Display.getInstance(); + sb.append(" display=").append(d.getDisplayWidth()).append('x').append(d.getDisplayHeight()); + sb.append(" density=").append(d.getDeviceDensity()); + sb.append(" edt=").append(d.isEdt()); + } catch (Throwable unavailable) { + sb.append(" display=unavailable"); + } + try { + Font def = Font.getDefaultFont(); + sb.append(" defaultFontHeight=").append(def == null ? "null-font" : String.valueOf(def.getHeight())); + } catch (Throwable unavailable) { + sb.append(" defaultFontHeight=unavailable"); + } + try { + Font sys = Font.createSystemFont(Font.FACE_SYSTEM, Font.STYLE_PLAIN, Font.SIZE_MEDIUM); + sb.append(" systemFontHeight=").append(sys == null ? "null-font" : String.valueOf(sys.getHeight())); + } catch (Throwable unavailable) { + sb.append(" systemFontHeight=unavailable"); + } + log(sb.toString()); + } + private static void logThrowableFrames(String context, Throwable t) { StackTraceElement[] frames; try { From 214286acccc3b089a113820f411ce6390a379ac2 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:34:15 +0700 Subject: [PATCH 79/91] Fix the cross-compile I broke, and probe the image primitives The cross-compile job went red because of me: I added #include to cn1_windows_video.cpp, which drags in the MSVC STL, and that hard-asserts STL1000 unless clang is exactly 19. That job does not pin LLVM 19 the way the build+run job does. The file already carried a comment saying no std::wstring precisely to avoid this, and I ignored it. The URL is a malloc'd wchar_t copy now, freed in the destructor, and the file has no STL again. My local check passed only because Homebrew clang here is 22.1.6 -- so a green xwin run locally is NOT evidence the CI toolchain accepts it. On the NPE: the font-height theory is dead. Windows reports fontHeight=20, systemFontHeight=20, display 784x561, density 20, and both styles resolve. So no metric is zero and no image is degenerate -- which also means the clamp I removed would have fixed nothing. What remains inside calcPreferredSize is the thumb image, and Switch builds it by creating an image, blurring it for the drop shadow, and drawing on the result. Rather than read further, the test now walks that same sequence through the public API and reports each step: create, graphics, antialias, blur support, the blur call, and graphics on the blurred image. Whichever comes back null or throws is the defect. Left in permanently -- a platform whose blur returns something undrawable cannot render a Switch at all. Co-Authored-By: Claude Opus 5 (1M context) --- .../nativeSources/cn1_windows_video.cpp | 24 ++++++++++++---- .../hellocodenameone/tests/KotlinUiTest.kt | 28 +++++++++++++++++++ 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_video.cpp b/Ports/WindowsPort/nativeSources/cn1_windows_video.cpp index 6205d2a0e26..9b8a62c51bb 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_video.cpp +++ b/Ports/WindowsPort/nativeSources/cn1_windows_video.cpp @@ -38,7 +38,7 @@ #include #include #include -#include +#include #include "cn1_windows.h" using Microsoft::WRL::ComPtr; @@ -74,8 +74,14 @@ static void cn1StripFileWide(const char* utf8, wchar_t* out, int outLen) { struct CN1VideoReader { ComPtr reader; - /* The source URL, so readAudio can open a reader of its own. */ - std::wstring url; + /* The source URL, so readAudio can open a reader of its own. A plain + * malloc'd copy rather than std::wstring: including drags in the + * MSVC STL, which hard-asserts on the compiler version (STL1000) and broke + * the cross-compile job, whose clang is not pinned to 19 the way the + * build+run job's is. Freed in the destructor below. */ + wchar_t* url; + CN1VideoReader() : url(NULL) {} + ~CN1VideoReader() { free(url); } int width; int height; LONGLONG durationMs; @@ -124,7 +130,13 @@ static JAVA_LONG cn1ReaderOpen(const wchar_t* url) { CN1VideoReader* st = new CN1VideoReader(); st->reader = reader; - st->url = url; + if (url != NULL) { + size_t urlChars = wcslen(url) + 1; + st->url = (wchar_t*) malloc(urlChars * sizeof(wchar_t)); + if (st->url != NULL) { + memcpy(st->url, url, urlChars * sizeof(wchar_t)); + } + } st->width = 0; st->height = 0; st->durationMs = -1; @@ -276,10 +288,10 @@ static JAVA_OBJECT cn1ReaderReadAudio(CODENAME_ONE_THREAD_STATE, CN1VideoReader* // beginning by construction, so the audio track no longer depends on seek // semantics or on what the video pass did to the shared position. ComPtr audioReader; - if (!st->url.empty()) { + if (st->url != NULL) { ComPtr attrs; if (SUCCEEDED(MFCreateAttributes(&attrs, 1))) { - MFCreateSourceReaderFromURL(st->url.c_str(), attrs.Get(), &audioReader); + MFCreateSourceReaderFromURL(st->url, attrs.Get(), &audioReader); } } if (audioReader != NULL) { diff --git a/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt b/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt index fb2046724dc..1f67600d705 100644 --- a/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt +++ b/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt @@ -137,6 +137,34 @@ class KotlinUiTest : BaseTest() { // createMutableImage hand back a broken peer. Report the number. val f = soSwitch.style.font step("probe-so-fontheight=" + (if (f == null) "null-font" else f.height.toString())) + // Switch sizes its thumb by building an image, blurring it for the drop + // shadow, and drawing on the result. Font height and styles are healthy on + // Windows (20 and non-null), so the failure is in one of those primitives. + // Walk the same sequence through the public API, which names the one that + // breaks; a platform whose blur returns something undrawable cannot render + // a Switch at all, so this earns its place as a standing check. + step("probe-img-create") + val probeImg = com.codename1.ui.Image.createImage(32, 24, 0) + step("probe-img-null=" + (probeImg == null)) + step("probe-img-graphics") + val probeG = probeImg.graphics + step("probe-img-g-null=" + (probeG == null)) + step("probe-img-antialias") + probeG.isAntiAliased = true + step("probe-blur-supported=" + com.codename1.ui.Display.getInstance().isGaussianBlurSupported) + if (com.codename1.ui.Display.getInstance().isGaussianBlurSupported) { + step("probe-blur-call") + val blurred = com.codename1.ui.Display.getInstance().gaussianBlurImage(probeImg, 5f) + step("probe-blur-null=" + (blurred == null)) + if (blurred != null) { + step("probe-blur-graphics") + val bg = blurred.graphics + step("probe-blur-g-null=" + (bg == null)) + step("probe-blur-antialias") + bg.isAntiAliased = true + step("probe-blur-ok") + } + } step("probe-so-preferredsize") soSwitch.preferredSize step("probe-so-addcontent") From 4aab910910ccc5fd318c1c8ef746d68960f18698 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:11:12 +0700 Subject: [PATCH 80/91] Probe the ImageFactory path and an OFF switch's preferred size The primitive probes came back entirely clean on Windows: create, graphics, antialias, blur supported, the blur call, graphics on the blurred image -- every one non-null. So the gaussian-blur theory is dead alongside the font-height one, and getPreferredSize still throws. The difference I had missed is that my probe called Image.createImage directly while Switch goes through ImageFactory.createImage(context, ...) with the component as context, which walks the parent chain looking for a per-component factory -- and at sizes derived from the font rather than my arbitrary 32x24. That exact call is now probed, at the size the switch computes. Also probing an OFF switch's preferred size. The ON/OFF split may be an artifact: the earlier OFF probe only ever called addContent, never getPreferredSize, so it may throw identically and the state is a red herring. Co-Authored-By: Claude Opus 5 (1M context) --- .../hellocodenameone/tests/KotlinUiTest.kt | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt b/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt index 1f67600d705..f432756764b 100644 --- a/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt +++ b/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt @@ -165,6 +165,25 @@ class KotlinUiTest : BaseTest() { step("probe-blur-ok") } } + // Switch does not call Image.createImage directly -- it goes through + // ImageFactory with the component as context, which walks the parent chain + // for a per-component factory. Probe that exact call, at the size the + // switch computes from its font, before asking for the preferred size. + step("probe-factory-image") + val fh = if (f == null) 20 else f.height + val factoryImg = com.codename1.ui.ImageFactory.createImage(soSwitch, fh * 3 + 4, (fh * 0.9).toInt(), 0) + step("probe-factory-null=" + (factoryImg == null)) + step("probe-factory-graphics") + val fg = factoryImg.graphics + step("probe-factory-g-null=" + (fg == null)) + step("probe-factory-antialias") + fg.isAntiAliased = true + step("probe-factory-ok") + // Is the ON/OFF split real, or did the OFF probe simply never compute a + // preferred size? Ask an OFF switch for one. + step("probe-off-preferredsize") + Switch().preferredSize + step("probe-off-preferredsize-ok") step("probe-so-preferredsize") soSwitch.preferredSize step("probe-so-addcontent") From 89590b3dcf9b6c91073e5a4733958a42d48a9695 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:03:39 +0700 Subject: [PATCH 81/91] Narrow the Switch NPE to the ON thumb's blur branch Two facts this round, both new. An OFF switch's getPreferredSize WORKS on Windows -- probe-off-preferredsize-ok -- and the ON switch's throws. So the ON/OFF split is real, not an artifact of which probe happened to compute a size. And the ImageFactory path is clean: create through the factory at the size the switch computes, take graphics, antialias, all fine. That leaves exactly one thing the ON path does that the OFF path does not. calcPreferredSize calls getCurrentThumbImage, then both track images; the tracks are shared, so the difference is getThumbOnImage vs getThumbOffImage. The ON thumb hard-codes shadowSpread=2 while the OFF thumb reads switchThumbShadowSpreadInt, which Material 3 sets to 0 for a flat thumb -- and a spread of 0 skips the drop-shadow branch entirely. The ON thumb is therefore the only one that ever reaches the blur. My earlier blur probe passed because it blurred a BLANK image. The real branch draws the shadow rings first, so the blur reads back a Direct2D target with an open draw batch. The probe now does that: create, draw, blur, take graphics -- and reports switchThumbShadowSpreadInt so the premise is checked rather than assumed. Co-Authored-By: Claude Opus 5 (1M context) --- .../hellocodenameone/tests/KotlinUiTest.kt | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt b/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt index f432756764b..37375d53f6f 100644 --- a/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt +++ b/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt @@ -184,6 +184,33 @@ class KotlinUiTest : BaseTest() { step("probe-off-preferredsize") Switch().preferredSize step("probe-off-preferredsize-ok") + // OFF preferred size works, ON throws, and the ONLY thing the ON path does + // that the OFF path does not is getThumbOnImage -- which hard-codes a + // shadowSpread of 2, while the OFF thumb takes it from + // switchThumbShadowSpreadInt (Material 3 sets that to 0 for a flat thumb). + // A spread of 0 skips the drop-shadow/blur branch entirely, so the ON thumb + // is the only one that ever blurs. Report the constant, then walk the branch + // as it really runs: create through the factory, DRAW into it, blur the + // drawn image, and take graphics on the result. The earlier blur probe + // blurred a blank image, which is not the same thing on a Direct2D target + // that has an open draw batch. + val um = com.codename1.ui.plaf.UIManager.getInstance() + step("probe-shadowspread=" + um.getThemeConstant("switchThumbShadowSpreadInt", 2)) + step("probe-drawn-create") + val drawn = com.codename1.ui.ImageFactory.createImage(soSwitch, 34, 34, 0) + val dg = drawn.graphics + dg.isAntiAliased = true + step("probe-drawn-fill") + dg.color = 0 + dg.fillRoundRect(2, 2, 30, 30, 30, 30) + step("probe-drawn-blur") + val drawnBlur = com.codename1.ui.Display.getInstance().gaussianBlurImage(drawn, 5f) + step("probe-drawn-blur-null=" + (drawnBlur == null)) + step("probe-drawn-blur-graphics") + val dbg = drawnBlur.graphics + step("probe-drawn-blur-g-null=" + (dbg == null)) + dbg.isAntiAliased = true + step("probe-drawn-ok") step("probe-so-preferredsize") soSwitch.preferredSize step("probe-so-addcontent") From 27121ff241712b866bf69ebb05fb063e890e2705 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 7 Aug 2026 01:03:27 +0700 Subject: [PATCH 82/91] Probe getWidth on the blurred image switchThumbShadowSpreadInt is 0 on Windows, confirming the premise: the OFF thumb skips the drop-shadow branch and the ON thumb always enters it, which is the whole of the ON/OFF split. But create -> draw -> blur -> graphics all pass, so the blur itself is not it. The one call calcPreferredSize makes on the blur result that no probe has made is getWidth(). It matters here: the Windows blur returns Image.createImage(argb, w, h) rather than the mutable surface it was handed, so the result's dimensions come from a different native than the one every passing probe exercised. Co-Authored-By: Claude Opus 5 (1M context) --- .../examples/hellocodenameone/tests/KotlinUiTest.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt b/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt index 37375d53f6f..c71c1389e98 100644 --- a/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt +++ b/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt @@ -210,6 +210,14 @@ class KotlinUiTest : BaseTest() { val dbg = drawnBlur.graphics step("probe-drawn-blur-g-null=" + (dbg == null)) dbg.isAntiAliased = true + // calcPreferredSize does not just draw on the blurred image -- it asks it + // for a width. That is the one call on the blur result never probed, and + // Windows builds its blur result from an ARGB array rather than the + // mutable surface, so its dimensions come from a different native path. + step("probe-drawn-blur-width") + val bw = drawnBlur.width + val bh = drawnBlur.height + step("probe-drawn-blur-size=" + bw + "x" + bh) step("probe-drawn-ok") step("probe-so-preferredsize") soSwitch.preferredSize From dfc918e39652fcc2ec7f23592363c04037b17253 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:48:49 +0700 Subject: [PATCH 83/91] Name the failing image in Switch, and count audio samples written External reproduction is exhausted: every step of the thumb branch passes when driven from the test at the exact size Switch uses -- create through ImageFactory, draw, blur, graphics, antialias, and getWidth on the blur result (34x34). Yet getPreferredSize still throws on an ON switch while an OFF switch answers fine, and switchThumbShadowSpreadInt=0 confirms the ON thumb is the only one that reaches the drop-shadow branch. So instrument the real path rather than a copy of it. createRoundThumbImage now fails with a named IllegalStateException when the factory, the blur, or either graphics comes back null, and calcPreferredSize names which of thumb/trackOn/ trackOff is null instead of letting the arithmetic throw. This does not paper over anything -- every one of these still fails the test -- it replaces a NullPointerException that carries no stack on ParparVM with a sentence saying what was null, which is the difference between one more round and an answer. Audio: the fresh reader did not change the symptom, and that is itself informative. A newly opened reader cannot be at EOF, so ENDOFSTREAM on its first read means the file has an audio stream header and no samples -- the writer, not the reader. videoWriterClose now reports how many audio samples and bytes were actually handed to the sink writer, plus the Finalize HRESULT. Note: the local xwin cross-link currently fails on bcrypt symbols in code this commit does not touch, with bcrypt.lib present in the sysroot. That looks like local toolchain state rather than these changes -- CI does its own cross-build and will say. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/com/codename1/components/Switch.java | 34 +++++++++++++++++++ .../nativeSources/cn1_windows_video.cpp | 15 ++++++++ 2 files changed, 49 insertions(+) diff --git a/CodenameOne/src/com/codename1/components/Switch.java b/CodenameOne/src/com/codename1/components/Switch.java index 4e6120ddbec..54af1ea2756 100644 --- a/CodenameOne/src/com/codename1/components/Switch.java +++ b/CodenameOne/src/com/codename1/components/Switch.java @@ -300,7 +300,15 @@ private static Image createRoundThumbImage(Component context, int pxDim, int col int imgW = baseW + 2 * (shadowSpread + thumbInset); int imgH = pxDim + 2 * shadowSpread; Image img = ImageFactory.createImage(context, imgW, imgH, 0x0); + if (img == null) { + throw new IllegalStateException("Switch thumb: ImageFactory returned null for " + + imgW + "x" + imgH); + } Graphics g = img.getGraphics(); + if (g == null) { + throw new IllegalStateException("Switch thumb: no graphics for a " + imgW + "x" + + imgH + " image"); + } g.setAntiAliased(true); int shadowOpacity = 200; @@ -322,9 +330,21 @@ private static Image createRoundThumbImage(Component context, int pxDim, int col g.translate(-iter, -iter); } if (Display.getInstance().isGaussianBlurSupported()) { + // A port whose blur answers null, or answers an image it cannot then + // draw on, used to surface as a NullPointerException two frames up in + // calcPreferredSize -- with no stack at all on ParparVM, which is how + // this cost days on Windows. Name it where it happens. Image blured = Display.getInstance().gaussianBlurImage(img, shadowBlur / 2); + if (blured == null) { + throw new IllegalStateException("Switch thumb: gaussianBlurImage returned null for " + + imgW + "x" + imgH); + } img = blured; g = img.getGraphics(); + if (g == null) { + throw new IllegalStateException("Switch thumb: no graphics on the blurred " + + img.getWidth() + "x" + img.getHeight() + " image"); + } g.setAntiAliased(true); } } @@ -675,12 +695,26 @@ public void styleChanged(String propertyName, Style source) { /// {@inheritDoc} @Override protected Dimension calcPreferredSize() { + // Each of these can be null if the platform failed to make an image, and + // the resulting NullPointerException names none of them. + requireImage(getCurrentThumbImage(), "thumb"); + requireImage(getCurrentTrackOnImage(), "trackOn"); + requireImage(getCurrentTrackOffImage(), "trackOff"); return new Dimension( getStyle().getHorizontalPadding() + Math.max(getCurrentThumbImage().getWidth(), Math.max(getCurrentTrackOnImage().getWidth(), getCurrentTrackOffImage().getWidth())), getStyle().getVerticalPadding() + Math.max(getCurrentThumbImage().getHeight(), Math.max(getCurrentTrackOnImage().getHeight(), getCurrentTrackOffImage().getHeight()))); } + /// Fails with the name of the image that could not be built, rather than a + /// bare NullPointerException from the arithmetic below it. + private void requireImage(Image img, String which) { + if (img == null) { + throw new IllegalStateException("Switch " + which + " image is null (uiid=" + getUIID() + + " on=" + value + " enabled=" + isEnabled() + ")"); + } + } + /// {@inheritDoc} @Override protected void resetFocusable() { diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_video.cpp b/Ports/WindowsPort/nativeSources/cn1_windows_video.cpp index 9b8a62c51bb..d7ddc10c840 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_video.cpp +++ b/Ports/WindowsPort/nativeSources/cn1_windows_video.cpp @@ -36,6 +36,7 @@ #include #include #include +#include #include #include #include @@ -95,6 +96,8 @@ struct CN1VideoReader { struct CN1VideoWriter { ComPtr writer; + unsigned long audioSamplesWritten; + unsigned long long audioBytesWritten; DWORD videoStream; DWORD audioStream; int width; @@ -397,6 +400,8 @@ static JAVA_LONG cn1WriterOpen(const wchar_t* url, bool hevc, int width, int hei CN1VideoWriter* st = new CN1VideoWriter(); st->writer = writer; + st->audioSamplesWritten = 0; + st->audioBytesWritten = 0; st->width = width; st->height = height; st->frameRate = fps; @@ -607,6 +612,12 @@ JAVA_VOID com_codename1_impl_windows_WindowsNative_videoWriterAudio___long_byte_ DWORD frames = len / (DWORD) (2 * (channels > 0 ? channels : 1)); LONGLONG dur = sampleRate > 0 ? (LONGLONG) ((LONGLONG) frames * CN1_HNS_PER_SEC / sampleRate) : 0; cn1WriterWriteSample(st, st->audioStream, pcm, len, (LONGLONG) ptsMs * CN1_HNS_PER_MS, dur); + /* A freshly opened reader cannot be positioned at EOF, yet it still reports + * ENDOFSTREAM on its first audio read -- so the file carries an audio stream + * header with no samples behind it, which points here rather than at the + * reader. Count what actually goes in. */ + st->audioSamplesWritten++; + st->audioBytesWritten += (unsigned long long) len; } JAVA_BOOLEAN com_codename1_impl_windows_WindowsNative_videoWriterClose___long_R_boolean(CODENAME_ONE_THREAD_STATE, JAVA_LONG peer) { @@ -615,6 +626,10 @@ JAVA_BOOLEAN com_codename1_impl_windows_WindowsNative_videoWriterClose___long_R_ return JAVA_FALSE; } HRESULT hr = st->writer->Finalize(); + printf("CN1SS:INFO:winWriter audioSamples=%lu audioBytes=%llu hasAudio=%d finalizeHr=0x%08lx\n", + st->audioSamplesWritten, st->audioBytesWritten, st->hasAudio ? 1 : 0, + (unsigned long) hr); + fflush(stdout); delete st; return SUCCEEDED(hr) ? JAVA_TRUE : JAVA_FALSE; } From eb0c848dec16d18356b2def2ff96fb2d44c9e6f5 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:28:00 +0700 Subject: [PATCH 84/91] Give the Windows blur a drawable result, encode audio at a rate MF accepts Three defects and one diagnostic, all found by reading the last run's evidence rather than by guessing. gaussianBlurImage on the Windows port returned Image.createImage(int[], w, h), which builds an ARGB-backed image with no Direct2D render target. Callers composite ON TOP of a blur result -- Switch blurs its thumb's drop shadow and then draws the knob onto it -- and getImageGraphics answers 0 for such an image, so Graphics wraps a null peer and every later draw either vanishes or dereferences it. The Linux port creates a target on demand for any image and JavaSE hands back a BufferedImage's graphics, so Windows was the outlier. Blit the blurred pixels into a mutable image before returning. The video writer reported hasAudio=0 at close for a clip the caller asked to have audio: Media Foundation's AAC encoder publishes input types at 44100 and 48000 Hz only, so AddStream failed for the suite's 8 kHz tone and the failure was swallowed by clearing hasAudio. The file then carried video alone while the writer reported success -- which is why a freshly opened reader, which cannot be at EOF, still hit ENDOFSTREAM on its first audio read. Configure the encoder at a rate it accepts, resample the incoming PCM to it, round the byte rate to a published value, and give the PCM input type the block alignment and byte rate it was missing. The AddStream/SetInputMediaType HRESULT is now reported at close instead of being discarded. ParparVM's Throwable.getStackTrace() is a stub that always answers an empty array, but the VM does record frames: the native fillInStack walks threadStateData->callStack when the throwable is constructed, and printStackTrace is the only accessor for the result. Asking getStackTrace and stopping there is why a NullPointerException on Windows arrived with no location at all. The runner now falls back to printStackTrace, which costs nothing on the JVM ports where the frames are non-empty. Review feedback, all six open threads: - The report sweep no longer treats a browser-evidence dispatch as the newest JavaScript producer. port-status-environment.json is excluded from the port report scan, and a successful run that uploaded it while naming none of the ports its workflow owns is skipped so newest_candidate advances. Gated on success so a producer that died before normalization still trips the strict checks. - The nightly site rebuild gets always(), so a browser-evidence failure no longer strands the reports the sweep just published. - ECB rejects any non-null IV on both desktop ports, not just a non-empty one: JavaSE branches on iv != null and ECB refuses the resulting IvParameterSpec. - An unfinished report that declares missing workloads must be labelled partial; port-status.html renders any benchmark whose status is complete. - Per-test "reasons" is validated as an array of strings, so a malformed one is a single unusable report rather than a failed Hugo build for the site. New tests: test_publishable_rejects_a_complete_label_on_partial_benchmark_data, test_publishable_rejects_a_malformed_reason_list and a positive counterpart. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/port-status-nightly.yml | 11 +- .../impl/linux/LinuxImplementation.java | 6 +- .../nativeSources/cn1_windows_video.cpp | 109 ++++++++++++++++-- .../impl/windows/WindowsImplementation.java | 19 ++- .../tests/Cn1ssDeviceRunner.java | 14 +++ .../conformance/backfill_port_status.sh | 66 +++++++++-- .../conformance/port_status.py | 25 ++++ .../conformance/test_port_status.py | 44 +++++++ 8 files changed, 268 insertions(+), 26 deletions(-) diff --git a/.github/workflows/port-status-nightly.yml b/.github/workflows/port-status-nightly.yml index 6a3fe20f2c4..b8893982343 100644 --- a/.github/workflows/port-status-nightly.yml +++ b/.github/workflows/port-status-nightly.yml @@ -132,9 +132,14 @@ jobs: GH_TOKEN: ${{ github.token }} run: scripts/hellocodenameone/conformance/publish_port_status_environment.sh artifacts/port-status-environment.json - name: Rebuild the static website snapshot - # Deliberately not gated on the JavaScript build: the reports published - # tonight reach the public table only through this dispatch. - if: github.ref == 'refs/heads/master' + # Deliberately not gated on the JavaScript build, nor on the evidence + # steps above it: the reports published tonight reach the public table + # only through this dispatch. Without always() the step inherits the + # implicit success() requirement, so a failure while downloading, + # assembling, uploading or publishing the browser evidence left the + # sweep's freshly published reports off the public snapshot until some + # later deployment happened to rebuild it. + if: always() && github.ref == 'refs/heads/master' env: GH_TOKEN: ${{ github.token }} run: gh workflow run website-docs.yml --ref master -f deploy_production=true diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java index a1fb5f84d0f..9247c0225e9 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java @@ -2811,7 +2811,11 @@ private static void checkIv(String transformation, byte[] iv) { // mattered -- and JavaSE and Android reject the same call, because // they hand a non-null iv to JCE as an IvParameterSpec and ECB // refuses it. Refusing here keeps the ports answering alike. - if (iv != null && iv.length > 0) { + // Any non-null array, including a zero-length one: JavaSE branches + // on iv != null rather than on its length, so new byte[0] builds an + // IvParameterSpec there and ECB throws. Accepting it here would have + // made the same call succeed on the desktop ports alone. + if (iv != null) { throw new RuntimeException("AES-ECB cannot use an initialization vector"); } } else if (iv == null || iv.length != 16) { diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_video.cpp b/Ports/WindowsPort/nativeSources/cn1_windows_video.cpp index d7ddc10c840..8f8d0e49122 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_video.cpp +++ b/Ports/WindowsPort/nativeSources/cn1_windows_video.cpp @@ -104,8 +104,25 @@ struct CN1VideoWriter { int height; float frameRate; bool hasAudio; + /* The rate the AAC encoder was configured at. Media Foundation's AAC encoder + * accepts 44100 or 48000 Hz only, so a caller asking for anything else (the + * conformance suite records an 8 kHz tone) gets its PCM resampled on the way + * in rather than losing the audio stream. */ + int audioEncRate; + int audioChannels; + HRESULT audioSetupHr; }; +/* The sample rates Media Foundation's AAC encoder will accept. */ +static int cn1AacEncoderRate(int requested) { + if (requested == 44100 || requested == 48000) { + return requested; + } + /* 44100 is the closer target for anything derived from CD-family rates + * (11025/22050); everything else lands on 48000. */ + return (requested % 11025) == 0 ? 44100 : 48000; +} + // -------------------------------------------------------------------------- // Reader // -------------------------------------------------------------------------- @@ -407,6 +424,9 @@ static JAVA_LONG cn1WriterOpen(const wchar_t* url, bool hevc, int width, int hei st->frameRate = fps; st->hasAudio = hasAudio; st->audioStream = 0; + st->audioEncRate = 0; + st->audioChannels = channels > 0 ? channels : 1; + st->audioSetupHr = S_OK; // ---- video output (H.264 / HEVC) ---- ComPtr videoOut; @@ -439,24 +459,53 @@ static JAVA_LONG cn1WriterOpen(const wchar_t* url, bool hevc, int width, int hei // ---- audio output (AAC) ---- if (hasAudio) { + /* Media Foundation's AAC encoder publishes input types at 44100 and + * 48000 Hz only. Asking it for the caller's rate made AddStream fail for + * an 8 kHz tone, and the failure was swallowed by clearing hasAudio: the + * file then carried a video stream alone while the writer reported + * success, so VideoIORoundTripTest saw a clip whose audio never arrived. + * Configure the encoder at a rate it accepts and convert on the way in. + * Byte rate is likewise constrained (the encoder publishes 12000, 16000, + * 20000 and 24000 bytes/sec), so an unlisted bit rate is rounded to the + * nearest supported one instead of failing the stream. */ + static const UINT32 aacByteRates[] = { 12000, 16000, 20000, 24000 }; + UINT32 wanted = (UINT32) (audioBitRate / 8); + UINT32 byteRate = aacByteRates[0]; + for (size_t i = 1; i < sizeof(aacByteRates) / sizeof(aacByteRates[0]); i++) { + UINT32 best = byteRate > wanted ? byteRate - wanted : wanted - byteRate; + UINT32 here = aacByteRates[i] > wanted ? aacByteRates[i] - wanted : wanted - aacByteRates[i]; + if (here < best) { + byteRate = aacByteRates[i]; + } + } + st->audioEncRate = cn1AacEncoderRate(sampleRate); + st->audioChannels = channels > 0 ? channels : 1; + ComPtr audioOut; MFCreateMediaType(&audioOut); audioOut->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Audio); audioOut->SetGUID(MF_MT_SUBTYPE, MFAudioFormat_AAC); audioOut->SetUINT32(MF_MT_AUDIO_BITS_PER_SAMPLE, 16); - audioOut->SetUINT32(MF_MT_AUDIO_SAMPLES_PER_SECOND, (UINT32) sampleRate); - audioOut->SetUINT32(MF_MT_AUDIO_NUM_CHANNELS, (UINT32) channels); - audioOut->SetUINT32(MF_MT_AUDIO_AVG_BYTES_PER_SECOND, (UINT32) (audioBitRate / 8)); - if (SUCCEEDED(writer->AddStream(audioOut.Get(), &st->audioStream))) { + audioOut->SetUINT32(MF_MT_AUDIO_SAMPLES_PER_SECOND, (UINT32) st->audioEncRate); + audioOut->SetUINT32(MF_MT_AUDIO_NUM_CHANNELS, (UINT32) st->audioChannels); + audioOut->SetUINT32(MF_MT_AUDIO_AVG_BYTES_PER_SECOND, byteRate); + st->audioSetupHr = writer->AddStream(audioOut.Get(), &st->audioStream); + if (SUCCEEDED(st->audioSetupHr)) { ComPtr audioIn; MFCreateMediaType(&audioIn); audioIn->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Audio); audioIn->SetGUID(MF_MT_SUBTYPE, MFAudioFormat_PCM); audioIn->SetUINT32(MF_MT_AUDIO_BITS_PER_SAMPLE, 16); - audioIn->SetUINT32(MF_MT_AUDIO_SAMPLES_PER_SECOND, (UINT32) sampleRate); - audioIn->SetUINT32(MF_MT_AUDIO_NUM_CHANNELS, (UINT32) channels); - writer->SetInputMediaType(st->audioStream, audioIn.Get(), NULL); - } else { + audioIn->SetUINT32(MF_MT_AUDIO_SAMPLES_PER_SECOND, (UINT32) st->audioEncRate); + audioIn->SetUINT32(MF_MT_AUDIO_NUM_CHANNELS, (UINT32) st->audioChannels); + /* The PCM input type is under-specified without these two: the + * encoder needs the frame size and byte rate to accept the type. */ + audioIn->SetUINT32(MF_MT_AUDIO_BLOCK_ALIGNMENT, (UINT32) (2 * st->audioChannels)); + audioIn->SetUINT32(MF_MT_AUDIO_AVG_BYTES_PER_SECOND, + (UINT32) (st->audioEncRate * 2 * st->audioChannels)); + st->audioSetupHr = writer->SetInputMediaType(st->audioStream, audioIn.Get(), NULL); + } + if (FAILED(st->audioSetupHr)) { st->hasAudio = false; } } @@ -609,7 +658,43 @@ JAVA_VOID com_codename1_impl_windows_WindowsNative_videoWriterAudio___long_byte_ } BYTE* pcm = (BYTE*) (*(JAVA_ARRAY) pcmObj).data; DWORD len = (DWORD) (*(JAVA_ARRAY) pcmObj).length; - DWORD frames = len / (DWORD) (2 * (channels > 0 ? channels : 1)); + int ch = channels > 0 ? channels : 1; + DWORD frames = len / (DWORD) (2 * ch); + BYTE* resampled = NULL; + + /* The encoder runs at a rate it will accept (see cn1WriterOpen), so PCM + * recorded at any other rate is converted here. Linear interpolation between + * neighbouring frames: enough for the tone the conformance suite records, + * and it keeps the signal level -- and therefore the RMS the test measures -- + * where the caller put it. */ + if (sampleRate > 0 && st->audioEncRate > 0 && sampleRate != st->audioEncRate && frames > 0) { + double ratio = (double) st->audioEncRate / (double) sampleRate; + DWORD outFrames = (DWORD) (frames * ratio); + if (outFrames > 0) { + DWORD outLen = outFrames * (DWORD) (2 * ch); + resampled = (BYTE*) malloc(outLen); + if (resampled != NULL) { + const short* in = (const short*) pcm; + short* out = (short*) resampled; + for (DWORD f = 0; f < outFrames; f++) { + double srcPos = (double) f / ratio; + DWORD i0 = (DWORD) srcPos; + DWORD i1 = i0 + 1 < frames ? i0 + 1 : frames - 1; + double frac = srcPos - (double) i0; + for (int c = 0; c < ch; c++) { + double a = (double) in[i0 * ch + c]; + double b = (double) in[i1 * ch + c]; + out[f * ch + c] = (short) (a + (b - a) * frac); + } + } + pcm = resampled; + len = outLen; + frames = outFrames; + sampleRate = st->audioEncRate; + } + } + } + LONGLONG dur = sampleRate > 0 ? (LONGLONG) ((LONGLONG) frames * CN1_HNS_PER_SEC / sampleRate) : 0; cn1WriterWriteSample(st, st->audioStream, pcm, len, (LONGLONG) ptsMs * CN1_HNS_PER_MS, dur); /* A freshly opened reader cannot be positioned at EOF, yet it still reports @@ -618,6 +703,7 @@ JAVA_VOID com_codename1_impl_windows_WindowsNative_videoWriterAudio___long_byte_ * reader. Count what actually goes in. */ st->audioSamplesWritten++; st->audioBytesWritten += (unsigned long long) len; + free(resampled); } JAVA_BOOLEAN com_codename1_impl_windows_WindowsNative_videoWriterClose___long_R_boolean(CODENAME_ONE_THREAD_STATE, JAVA_LONG peer) { @@ -626,9 +712,10 @@ JAVA_BOOLEAN com_codename1_impl_windows_WindowsNative_videoWriterClose___long_R_ return JAVA_FALSE; } HRESULT hr = st->writer->Finalize(); - printf("CN1SS:INFO:winWriter audioSamples=%lu audioBytes=%llu hasAudio=%d finalizeHr=0x%08lx\n", + printf("CN1SS:INFO:winWriter audioSamples=%lu audioBytes=%llu hasAudio=%d encRate=%d " + "audioSetupHr=0x%08lx finalizeHr=0x%08lx\n", st->audioSamplesWritten, st->audioBytesWritten, st->hasAudio ? 1 : 0, - (unsigned long) hr); + st->audioEncRate, (unsigned long) st->audioSetupHr, (unsigned long) hr); fflush(stdout); delete st; return SUCCEEDED(hr) ? JAVA_TRUE : JAVA_FALSE; diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java index 70f6bbbed6f..f54b3165461 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java @@ -1064,7 +1064,18 @@ public Image gaussianBlurImage(Image image, float radius) { int b = Math.min(255, (p & 0xff) * 255 / a); px[i] = (a << 24) | (r << 16) | (g << 8) | b; } - return Image.createImage(px, w, h); + // Callers composite ON TOP of the blur result -- Switch blurs its thumb's + // drop shadow and then draws the knob onto the blurred image -- so the + // result has to be drawable. Image.createImage(int[], w, h) builds an + // ARGB-backed CN1Image with no Direct2D render target, and + // getImageGraphics then answers 0: Graphics wraps a null peer, and every + // subsequent draw either vanishes or dereferences it. Blit the pixels + // into a mutable image so the port matches JavaSE, where the blur result + // is a BufferedImage and getGraphics has always worked. + Image blurred = Image.createImage(px, w, h); + Image drawable = Image.createImage(w, h, 0); + drawable.getGraphics().drawImage(blurred, 0, 0); + return drawable; } /** One separable box-blur pass over premultiplied ARGB; edges clamp. */ @@ -2819,7 +2830,11 @@ private static void checkIv(String transformation, byte[] iv) { // mattered -- and JavaSE and Android reject the same call, because // they hand a non-null iv to JCE as an IvParameterSpec and ECB // refuses it. Refusing here keeps the ports answering alike. - if (iv != null && iv.length > 0) { + // Any non-null array, including a zero-length one: JavaSE branches + // on iv != null rather than on its length, so new byte[0] builds an + // IvParameterSpec there and ECB throws. Accepting it here would have + // made the same call succeed on the desktop ports alone. + if (iv != null) { throw new RuntimeException("AES-ECB cannot use an initialization vector"); } } else if (iv == null || iv.length != 16) { diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java index 0d336aaa63f..f42258c7dfa 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java @@ -852,6 +852,20 @@ private static void logThrowableFrames(String context, Throwable t) { } if (frames == null || frames.length == 0) { log("CN1SS:ERR:throwable context=" + context + " frames=none"); + // ParparVM's Throwable.getStackTrace() is a stub that always answers + // an empty array, but the VM does record frames: the native + // fillInStack walks threadStateData->callStack when the throwable is + // constructed and stores the rendered text, which printStackTrace is + // the only accessor for. Asking getStackTrace and stopping there is + // why a NullPointerException on Windows arrived with no location at + // all and took several CI rounds to place. Costs nothing on the JVM + // ports, where the frames above are non-empty and this never runs. + log("CN1SS:ERR:throwable context=" + context + " printing the VM's own stack:"); + try { + t.printStackTrace(); + } catch (Throwable unsupported) { + log("CN1SS:ERR:throwable context=" + context + " printStackTrace=unsupported"); + } return; } for (int i = 0; i < frames.length && i < 24; i++) { diff --git a/scripts/hellocodenameone/conformance/backfill_port_status.sh b/scripts/hellocodenameone/conformance/backfill_port_status.sh index 2b56658997f..bf1f1df5c56 100755 --- a/scripts/hellocodenameone/conformance/backfill_port_status.sh +++ b/scripts/hellocodenameone/conformance/backfill_port_status.sh @@ -65,6 +65,31 @@ describe_accept_status() { esac } +# Every downloaded report in $1 that names one of the whitespace-separated port +# ids in $2. Used to tell a run that produced nothing for this workflow apart +# from the browser-evidence sidecar from one whose producer really failed. +owned_reports_in() { + local run_dir="$1" + local owned_ids="$2" + local report found + while IFS= read -r report; do + [ -n "${report}" ] || continue + found="$(jq -r '.port // empty' "${report}" 2>/dev/null || true)" + [ -n "${found}" ] || continue + case " $(printf '%s ' ${owned_ids}) " in + *" ${found} "*) printf '%s\n' "${found}" ;; + esac + done < <(port_reports_in "${run_dir}") +} + +# The per-port reports a run uploaded. port-status-environment.json is the +# browser-evidence sidecar rather than a port report -- it describes the +# browsers the evidence was captured in and names no port -- so scanning it as +# one recorded the newest run as having uploaded an unreadable report. +port_reports_in() { + find "$1" -type f -name 'port-status-*.json' ! -name 'port-status-environment.json' | sort +} + for tool in gh jq python3; do if ! command -v "${tool}" >/dev/null 2>&1; then echo "backfill-port-status: ${tool} is required." >&2 @@ -156,23 +181,28 @@ while IFS= read -r workflow; do | jq -r --arg horizon "${horizon}" '[.[] | select((.event == "push" or .event == "schedule" or .event == "workflow_dispatch") and (.conclusion != null and .conclusion != "cancelled" and .conclusion != "skipped") and (.updatedAt >= $horizon))] - | sort_by(.updatedAt) | reverse | .[].databaseId')" + | sort_by(.updatedAt) | reverse | .[] | "\(.databaseId):\(.conclusion)"')" if [ -z "${candidates}" ]; then echo "No completed master run for ${workflow}; nothing to publish." >&2 continue fi run_id="" - # The newest candidate gets stricter treatment than the ones behind it. - first_candidate=1 - newest_candidate="$(printf '%s\n' ${candidates} | head -1)" + # The newest producer candidate gets stricter treatment than the ones behind + # it. Which run that is cannot be read off the top of the list, because a run + # can be a dispatch that deliberately produced no port report at all -- see + # the evidence-only check below -- so it is assigned to the first candidate + # that actually attempted to produce one. + newest_candidate="" download_dir="${tmp_dir}/${workflow}" mkdir -p "${download_dir}" # Merge across candidate runs rather than stopping at the first with any # artifact: a failed matrix run can upload the report for one leg only, and # the other ports that workflow owns would then never be considered. owned="$(jq -r --arg workflow "${workflow}" '.ports[] | select(.workflow == $workflow) | .id' "${MANIFEST}")" - for candidate in ${candidates}; do + for candidate_entry in ${candidates}; do + candidate="${candidate_entry%%:*}" + candidate_conclusion="${candidate_entry##*:}" missing=0 for port in ${owned}; do if [ ! -f "${download_dir}/covered-${port}" ]; then @@ -189,13 +219,31 @@ while IFS= read -r workflow; do # freshness check passes, and the sweep goes green while the current run # produced no evidence. Only the newest is reported: older candidates # without artifacts are just how the merge walks back. - if [ "${first_candidate}" = "1" ]; then + if [ -z "${newest_candidate}" ]; then + newest_candidate="${candidate}" unusable+=("${workflow}: newest run ${candidate} uploaded no port-status artifact") fi - first_candidate=0 continue fi - first_candidate=0 + # A run that succeeded, uploaded the browser-evidence environment sidecar + # and named none of the ports this workflow owns never tried to produce a + # port report: scripts-javascript.yml dispatched with + # port_status_browser_evidence skips its screenshot job by design and only + # calls the reusable evidence workflow. Treating it as the newest producer + # made the sweep fail every time -- the environment artifact names no port, + # and the JavaScript port then looked omitted -- for a run in which no + # producer was asked to run. A producer that genuinely died before + # normalization is not caught by this: its job failure fails the run, so the + # conclusion is not success and the strict checks still apply. + if [ "${candidate_conclusion}" = "success" ] \ + && [ -f "${download_dir}/run-${candidate}/port-status-environment/port-status-environment.json" ] \ + && [ -z "$(owned_reports_in "${download_dir}/run-${candidate}" "${owned}")" ]; then + echo "Skipping run ${candidate}: a browser-evidence dispatch that produces no ${workflow} port report." >&2 + continue + fi + if [ -z "${newest_candidate}" ]; then + newest_candidate="${candidate}" + fi run_id="${candidate}" while IFS= read -r downloaded; do found="$(jq -r '.port // empty' "${downloaded}" 2>/dev/null || true)" @@ -275,7 +323,7 @@ while IFS= read -r workflow; do # every port to whichever candidate happened to be examined last -- # misleading exactly when someone is chasing down a bad report. printf '%s' "${candidate}" > "${download_dir}/source-run-${found}" - done < <(find "${download_dir}/run-${candidate}" -type f -name 'port-status-*.json' | sort) + done < <(port_reports_in "${download_dir}/run-${candidate}") done if [ -z "${run_id}" ]; then echo "No recent ${workflow} run has a port status artifact." >&2 diff --git a/scripts/hellocodenameone/conformance/port_status.py b/scripts/hellocodenameone/conformance/port_status.py index 0a8308ce09d..fe21a77d385 100755 --- a/scripts/hellocodenameone/conformance/port_status.py +++ b/scripts/hellocodenameone/conformance/port_status.py @@ -674,6 +674,18 @@ def publishable_report_problems( }: malformed.append(f"invalid result for {test}") continue + # The reason list is optional, but when present the feature template + # calls len, range and hasPrefix on it. A producer emitting "reasons": + # true or an object would pass the status check here, reach the data + # branch, and then fail the Hugo build -- taking the whole site down + # rather than being classified as one unusable report. + reasons = result.get("reasons") + if reasons is not None and ( + not isinstance(reasons, list) + or not all(isinstance(item, str) for item in reasons) + ): + malformed.append(f"{test} reasons is not an array of strings") + continue statuses[result["status"]] += 1 expected_summary = { key: statuses.get(key, 0) for key in ("pass", "fail", "skip", "not-run") @@ -765,6 +777,19 @@ def publishable_report_problems( "performance workloads unaccounted for: " + describe_workload_gap(covered, expected_benchmarks) ) + # A report that names workloads it never ran cannot also call the run + # complete. normalize never writes that pair, but nothing downstream + # re-derives the status: port-status.html renders the timings of any + # benchmark whose status is "complete", so the partial set that a + # crashed suite did manage to measure would be presented as a finished + # measurement run. accounted | missing can still cover the contract, so + # the check above does not catch it. + if declared_missing and performance.get("status") != "partial": + malformed.append( + "performance run is " + f"{performance.get('status')!r} but declares missing workloads: " + + ", ".join(declared_missing) + ) for name, measurement in benchmarks.items(): duration = measurement.get("duration_ns") if isinstance(measurement, dict) else None if isinstance(duration, bool) or not isinstance(duration, int) or duration < 0: diff --git a/scripts/hellocodenameone/conformance/test_port_status.py b/scripts/hellocodenameone/conformance/test_port_status.py index d6dd2fe31eb..12102e48f2d 100755 --- a/scripts/hellocodenameone/conformance/test_port_status.py +++ b/scripts/hellocodenameone/conformance/test_port_status.py @@ -495,6 +495,50 @@ def test_publishable_rejects_a_malformed_missing_field(self): self.assertTrue( any("missing list" in item for item in malformed), malformed) + def test_publishable_rejects_a_complete_label_on_partial_benchmark_data(self): + # normalize writes "partial" whenever a workload went unrun, but nothing + # downstream re-derives it: port-status.html renders the timings of any + # benchmark whose status is "complete". A crashed suite that mislabelled + # itself would present the handful of workloads it managed to measure as + # a finished measurement run, and measured | missing still covers the + # contract so the accounting check above stays quiet. + report = self.publishable_report("linux-x64") + report["suite_finished"] = False + report["performance"]["status"] = "complete" + report["performance"]["missing"] = ["quicksort"] + del report["performance"]["benchmarks"]["quicksort"] + + _, malformed = port_status.publishable_report_problems( + self.manifest, "linux-x64", report + ) + self.assertTrue( + any("declares missing workloads" in item for item in malformed), malformed + ) + + def test_publishable_rejects_a_malformed_reason_list(self): + # The reason list is optional, but the feature template calls len, range + # and hasPrefix on whatever is there. A wrongly typed one has to be + # caught as a single unusable report rather than reaching the data + # branch and failing the Hugo build for the whole site. + for bad in (True, 7, "flaky", {"why": "flaky"}, ["ok", 3]): + with self.subTest(bad=bad): + report = self.publishable_report("linux-x64") + report["tests"]["ClipboardRoundTripTest"]["reasons"] = bad + _, malformed = port_status.publishable_report_problems( + self.manifest, "linux-x64", report + ) + self.assertTrue( + any("reasons is not an array of strings" in item + for item in malformed), malformed) + + def test_publishable_accepts_a_well_formed_reason_list(self): + report = self.publishable_report("linux-x64") + report["tests"]["ClipboardRoundTripTest"]["reasons"] = ["documented skip"] + + self.assertEqual(([], []), port_status.publishable_report_problems( + self.manifest, "linux-x64", report + )) + def test_publishable_matches_every_report_the_site_serves(self): for port in self.manifest["ports"]: report_path = port_status.REPO_ROOT / self.manifest["report_directory"] / ( From 2eaad066246d1638edf00402403afbda9913f99f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:31:38 +0700 Subject: [PATCH 85/91] Replace the Switch bisect probes with the check that would have caught it The bisect instrumentation has served its purpose -- the Windows NPE is understood and fixed in the previous commit -- so it comes out. What replaces it is the one check none of it started as: draw on the result of a blur. That gap is why this took so many rounds. Every probe passed while the real code faulted, because the probes stopped at Graphics.setAntiAliased, which the Windows port does not override and CodenameOneImplementation implements as an empty method: it never touches the native peer. concatenateAlpha is the first call that reads through it, and that is exactly the next statement in Switch.createRoundThumbImage after the blur. The full chain, for the record: gaussianBlurImage returned an ARGB-backed image whose mutableGraphics is NULL, getImageGraphics answered 0, core's getGraphics wrapped that in a Graphics (it never returns null, which is why the null guard added earlier could not fire), and getAlpha dereferenced g->alpha at a null address. cn1WinFaultToException maps a fault below 0x10000 to a synthesized NullPointerException -- hence no message, no frames, and nothing to locate it by. Only the ON thumb reaches it: the OFF thumb takes its shadow spread from switchThumbShadowSpreadInt, which the Material 3 theme sets to 0, so it never blurs. The replacement covers both shapes -- the blur-then-draw sequence in the order Switch performs it, and an ON switch's preferred size -- and stays on a throwaway Accordion so the kotlin golden is untouched. Co-Authored-By: Claude Opus 5 (1M context) --- .../hellocodenameone/tests/KotlinUiTest.kt | 172 ++++-------------- 1 file changed, 36 insertions(+), 136 deletions(-) diff --git a/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt b/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt index c71c1389e98..951c2ab7b83 100644 --- a/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt +++ b/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt @@ -92,146 +92,46 @@ class KotlinUiTest : BaseTest() { step("prefs-container") val preferences = Container(BoxLayout.y()) preferences.addAll(check, prefSwitch, note) - // Bisect: addContent("Preferences", ...) is where the Windows port throws. - // The container and all three children construct fine (the steps above all - // print), so the failure is in what addContent does to one of them -- - // setHidden(true), which caches margins and forces a zero preferred size. - // One probe section per child says which. - // Probed on a throwaway Accordion that is never added to the form. The - // first version of this put the probes into the real one, which changed - // the rendered "kotlin" screenshot and failed the golden comparison on - // every other port -- a diagnostic is not worth breaking six jobs for. - // addContent does its work in the AccordionContent constructor, so an - // unparented Accordion exercises exactly the same path and renders nothing. + // A component that paints a drop shadow blurs an image and then draws + // ON TOP of the blur result -- Switch does exactly this for its ON thumb. + // A port whose blur hands back something undrawable therefore cannot + // render a Switch at all, and on Windows it did not: gaussianBlurImage + // returned an ARGB-backed image with no render target, getGraphics wrapped + // a null native peer, and the first call that read through it faulted. The + // fault surfaced as a bare NullPointerException with no message and no + // frames, because the port maps a null-address access violation to one. + // Nothing in the suite covered "draw on a blur result", so this is the + // check that would have caught it. Run on a throwaway Accordion that is + // never added to the form: an earlier version probed the rendered one and + // changed the kotlin golden on every other port. val probe = Accordion() - step("probe-checkbox") - probe.addContent("ProbeCheck", Container(BoxLayout.y()).apply { add(CheckBox("probe")) }) - step("probe-switch") - probe.addContent("ProbeSwitch", Container(BoxLayout.y()).apply { add(Switch()) }) - step("probe-textarea") - probe.addContent("ProbeText", Container(BoxLayout.y()).apply { add(TextArea(3, 20)) }) - // Round one: all three plain children passed, so the difference is what the - // real container does beyond constructing them -- setOn() on the Switch, a - // hint on the TextArea, or simply holding three children at once. - // Split to the statement, because the composite probe cannot distinguish - // constructing an on-switch from adding one to an Accordion -- and the - // real test already shows Switch()+setOn() on its own is fine. - step("probe-so-ctor") - val soSwitch = Switch() - step("probe-so-seton") - soSwitch.setOn() - step("probe-so-container") - val soContainer = Container(BoxLayout.y()) - soContainer.add(soSwitch) - // addContent on a container holding an ON switch is the failing statement. - // The only state the ON path reaches that the OFF path does not is - // getSelectedStyle() (getThumbOnImage uses it; getThumbOffImage uses the - // unselected style), and calcPreferredSize is what asks for the thumb. - // Both are public, so the accessor that throws can be named from here - // without probes in Switch itself. - step("probe-so-unselectedstyle") - soSwitch.unselectedStyle - step("probe-so-selectedstyle") - soSwitch.selectedStyle - // The track image is sized from the font height, and a zero height made - // createMutableImage hand back a broken peer. Report the number. - val f = soSwitch.style.font - step("probe-so-fontheight=" + (if (f == null) "null-font" else f.height.toString())) - // Switch sizes its thumb by building an image, blurring it for the drop - // shadow, and drawing on the result. Font height and styles are healthy on - // Windows (20 and non-null), so the failure is in one of those primitives. - // Walk the same sequence through the public API, which names the one that - // breaks; a platform whose blur returns something undrawable cannot render - // a Switch at all, so this earns its place as a standing check. - step("probe-img-create") - val probeImg = com.codename1.ui.Image.createImage(32, 24, 0) - step("probe-img-null=" + (probeImg == null)) - step("probe-img-graphics") - val probeG = probeImg.graphics - step("probe-img-g-null=" + (probeG == null)) - step("probe-img-antialias") - probeG.isAntiAliased = true - step("probe-blur-supported=" + com.codename1.ui.Display.getInstance().isGaussianBlurSupported) + step("probe-blur-drawable") if (com.codename1.ui.Display.getInstance().isGaussianBlurSupported) { - step("probe-blur-call") - val blurred = com.codename1.ui.Display.getInstance().gaussianBlurImage(probeImg, 5f) - step("probe-blur-null=" + (blurred == null)) - if (blurred != null) { - step("probe-blur-graphics") - val bg = blurred.graphics - step("probe-blur-g-null=" + (bg == null)) - step("probe-blur-antialias") - bg.isAntiAliased = true - step("probe-blur-ok") - } + val shadow = com.codename1.ui.ImageFactory.createImage(prefSwitch, 34, 34, 0) + val sg = shadow.graphics + sg.color = 0 + sg.fillRoundRect(2, 2, 30, 30, 30, 30) + val blurred = com.codename1.ui.Display.getInstance().gaussianBlurImage(shadow, 5f) + ?: throw IllegalStateException("gaussianBlurImage returned null") + // The calls Switch makes on the blur result, in its order. concatenateAlpha + // reads the graphics state and is the first one to dereference the peer. + val bg = blurred.graphics + bg.concatenateAlpha(255) + bg.color = 0xffffff + bg.fillRoundRect(2, 2, 30, 30, 30, 30) + step("probe-blur-drawable-ok=" + blurred.width + "x" + blurred.height) } - // Switch does not call Image.createImage directly -- it goes through - // ImageFactory with the component as context, which walks the parent chain - // for a per-component factory. Probe that exact call, at the size the - // switch computes from its font, before asking for the preferred size. - step("probe-factory-image") - val fh = if (f == null) 20 else f.height - val factoryImg = com.codename1.ui.ImageFactory.createImage(soSwitch, fh * 3 + 4, (fh * 0.9).toInt(), 0) - step("probe-factory-null=" + (factoryImg == null)) - step("probe-factory-graphics") - val fg = factoryImg.graphics - step("probe-factory-g-null=" + (fg == null)) - step("probe-factory-antialias") - fg.isAntiAliased = true - step("probe-factory-ok") - // Is the ON/OFF split real, or did the OFF probe simply never compute a - // preferred size? Ask an OFF switch for one. - step("probe-off-preferredsize") - Switch().preferredSize - step("probe-off-preferredsize-ok") - // OFF preferred size works, ON throws, and the ONLY thing the ON path does - // that the OFF path does not is getThumbOnImage -- which hard-codes a - // shadowSpread of 2, while the OFF thumb takes it from - // switchThumbShadowSpreadInt (Material 3 sets that to 0 for a flat thumb). - // A spread of 0 skips the drop-shadow/blur branch entirely, so the ON thumb - // is the only one that ever blurs. Report the constant, then walk the branch - // as it really runs: create through the factory, DRAW into it, blur the - // drawn image, and take graphics on the result. The earlier blur probe - // blurred a blank image, which is not the same thing on a Direct2D target - // that has an open draw batch. - val um = com.codename1.ui.plaf.UIManager.getInstance() - step("probe-shadowspread=" + um.getThemeConstant("switchThumbShadowSpreadInt", 2)) - step("probe-drawn-create") - val drawn = com.codename1.ui.ImageFactory.createImage(soSwitch, 34, 34, 0) - val dg = drawn.graphics - dg.isAntiAliased = true - step("probe-drawn-fill") - dg.color = 0 - dg.fillRoundRect(2, 2, 30, 30, 30, 30) - step("probe-drawn-blur") - val drawnBlur = com.codename1.ui.Display.getInstance().gaussianBlurImage(drawn, 5f) - step("probe-drawn-blur-null=" + (drawnBlur == null)) - step("probe-drawn-blur-graphics") - val dbg = drawnBlur.graphics - step("probe-drawn-blur-g-null=" + (dbg == null)) - dbg.isAntiAliased = true - // calcPreferredSize does not just draw on the blurred image -- it asks it - // for a width. That is the one call on the blur result never probed, and - // Windows builds its blur result from an ARGB array rather than the - // mutable surface, so its dimensions come from a different native path. - step("probe-drawn-blur-width") - val bw = drawnBlur.width - val bh = drawnBlur.height - step("probe-drawn-blur-size=" + bw + "x" + bh) - step("probe-drawn-ok") - step("probe-so-preferredsize") + // An ON switch inside a container is what first exposed the above: the OFF + // thumb takes its shadow spread from switchThumbShadowSpreadInt (0 for the + // flat Material 3 thumb) and so never blurs, while the ON thumb hard-codes + // a spread of 2. Keep both shapes covered. + step("probe-switch-on-preferred") + val soSwitch = Switch() + soSwitch.setOn() + probe.addContent("ProbeSwitchOn", Container(BoxLayout.y()).apply { add(soSwitch) }) soSwitch.preferredSize - step("probe-so-addcontent") - probe.addContent("ProbeSwitchOn", soContainer) - step("probe-so-done") - step("probe-textarea-hint") - probe.addContent("ProbeTextHint", Container(BoxLayout.y()).apply { - add(TextArea(3, 20).apply { hint = "probe hint" }) - }) - step("probe-three-plain") - probe.addContent("ProbeThree", Container(BoxLayout.y()).apply { - add(CheckBox("a")); add(Switch()); add(TextArea(3, 20)) - }) + step("probe-switch-on-ok") + step("accordion-prefs") accordion.addContent("Preferences", preferences) From b948cd053b22f03cadad1ec76f10d2d133f4a24e Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:55:34 +0700 Subject: [PATCH 86/91] Seed the three Windows goldens the Switch fix unblocked kotlin, SwitchTheme_light and SwitchTheme_dark have never had a Windows reference image, because the port could not render a Switch: the ON thumb faulted in the blur path and both tests died before capture. With that fixed all three render, so the reports moved from "failed=NullPointerException" to "screenshot-missing_expected" -- the last step being the absent baseline. Taken from the CI artifact of run 31158011821, never from a local build, and checked before being committed rather than trusted for merely existing: all three match the Linux references pane for pane -- off thumb grey on a light track, ON thumb white on the purple pill with its drop shadow, disabled thumb flat -- differing only in canvas size (784x561 against 800x600) and font rasterisation. The kotlin capture shows Details / Preferences / Summary and nothing else, confirming the diagnostic accordion stays off the captured form. Windows is now pass=168 fail=2 skip=1, with both remaining failures being these missing baselines. VideoIORoundTripTest passes: the writer reports audioSamples=6 audioBytes=95976 hasAudio=1 encRate=48000 and the reader gets 48 reads / 96256 bytes back at 48 kHz. Co-Authored-By: Claude Opus 5 (1M context) --- .../windows/screenshots/SwitchTheme_dark.png | Bin 0 -> 15785 bytes .../windows/screenshots/SwitchTheme_light.png | Bin 0 -> 15731 bytes scripts/windows/screenshots/kotlin.png | Bin 0 -> 13960 bytes 3 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 scripts/windows/screenshots/SwitchTheme_dark.png create mode 100644 scripts/windows/screenshots/SwitchTheme_light.png create mode 100644 scripts/windows/screenshots/kotlin.png diff --git a/scripts/windows/screenshots/SwitchTheme_dark.png b/scripts/windows/screenshots/SwitchTheme_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..bdd97c042d7463fb1be8f936a74778b883eec78d GIT binary patch literal 15785 zcmeIZXIN8N+cq4AQD!V4DosidMgeIeN+-b~%2Mzte7)*2*4EBrOZ(G4P)F{^x@FEg)&gxHCS+~Lu@ZneVaqHtSSVhuy z{?#oon7REa)baD-u8UMmI5i}5j%Vwued^UajjyTFy{^Nj^*)@A{Zs0)q=`woUCOrj zk6$9_{wFIM2CAmRho`^4ita0jw`o`~!h{{M8XkY_8C)-Q_rt|^_+2Fj&z=8s;eo6A zqj%0}u7RewKfE6E(~eFU{5TOojC4<7G6@$iUi`SsA15)H4HrMiP4B7y1qM?-el--# z@rv><;KN@Bm0_?u*ZzOM4X36bj*9dSzk!AR&R;z5X(&O->~KI$=0B_r;>ObRuiA(8 zjNBOO-?d!qRhe#d8ocBL*jx5fs|*@$4l5*)cHa4ZQ29>EAb&os(8XR}UgdR z&S$iyCB)Df&Lqu?+F?Vi&%3$oVPn#S>;EDtsp(K2JIbszH{GFkmA?kv;86s(I7%1_lQj_$;h*X#O?nRYBh)@}QPWM{ zKl43yp$U#0UArMz7)!gCF=q0wAAJQJ$~Y!F<3OA=$C2=}Dj}(LsA;L1?kj07R$J|L zYZ5IFPiW0LrbrkM^a>^Hk6>ohakGhFJI27$Zy>~s&q!-0@&>U;5{_!_#iNoqJ-FE= zDef$Sr$42`Yd4nxqwqF1P(9xE>mD^o6rq0SD5eIFv6nea=U(t<(ge32H9=sNr=9H$YLKBEFdT^Vk zFq}9Wm;687Q-?b!Ng(Fap=j8c#tu3(CGQsD8-jR`gXAo6?2s5zs^`w^M9<7+A zr6szx%5CX7-3AZGM&TQYDco#b_VU*=x{HSW?c}Fc7cN}T0*B6bmc+-th-U0DPx{eO z?Uo4b!$`de3zqTZ*I)h5tq-rl;~+b1Z*O=1uyecnC(8xi&KMkV!7aDC`dGeo<_Maf z6Xv?NOS_BJnX0W9u{4Dc`#9V6TvAeU0r&3Jkal7xX0|gSc6mbD+S*Tya1 zn~ApCj{Ruw>G=!2V_(g@w^zf9ar{SBOyNo%E*6hp((_pM*wdPn#A)Q2gEJyOPvU%s z%4(TnNPJ~wWu*5i8w4{OGZ2mi3z&t$)*7Et$iS4M*1jXQH)M}iYa-8_XVt{=*N(yK z4yvdiI1>UUf+Ho#>Jcty17D8SETiiqo{tHN;tG(&wLZIx7p0Mcwj>G1c)_Qg3zJ!X z6Mbl{SrBboh#}Ysd0L%I!_xOa-=6D!!~{*ajN2vd154j$1E!}-mqlzYf`EP#%MLOm zgGioDI_n7*i`En}!3;WRszQ5F>g4Xstwo@t3am5t^>|ZuCvDIx@SO*2`tofOK>Xi% z^YP+SbFpH1IORF@=*zp^@w1-Bi7W9KoKob^yY#;Wvz?{0czigA9%kj}=qUJxSQsKj z98ys^Xp`}r8vNHw6pB7d&{a{1YGjcq))js@PR%&y79{0g}A~JQn@+cq+&JEan! z49uGAkhELkeVdI147WRQf4~qm>;zb{NSbgB73IV@_d3iu{3-}9fh;;cOMkj&aYPCj zCJ6uaL&v97a!LBrRyn~_8kASaQ{a27G#-td&z{2#UNZtA627!?8>i4lxAA4ITtRa& zrelB3+>Hb=YME(QIi-JwmRU6+HQ+GR{83(}HT9ELJN&H9;e6{Cw?FTYf8TZzOzB*p z*Vfx^iG5#&ca7Hg#N9N1_PTL7CoYH8<~B|6`wnL|?XN~*+s!QD`$qidW z?0(i&r=_CO^mb``bV<6pdEUF8AuIQeL2a&4lLtj{L#KJGk>hW_ee3y{s_g_h&kUG2 z*h*gG$FHL1&#F6#-Zq>O{QeA$ZpwN?sU%TcUD)3*Y{Yo(=WV+{{uI;@I<5Ma>*#7MI_y_A!3e2BGp}mUwVl^CTzY`Y49m{g716SW%a&nNl ze*Jm{U&zHpPuGbUNdtrWS_EJl00j&VY#z#MAjqwE9!8a)Ne(P$^zo!pqiU~W0kCQ{ z)oDBoEXkDKK@k4~a!+%xqe)Su{Q31fso>f3sp-=NREEgDx-YLEpP799QGxjTF*QQ zkOSnrYaMev&}@B?}S91_oHw z<&oOL#D2(kIHR&}AfF~@;$b!?A(R@qFgg(n0!I%iZfWI5pU3*jj|wc04ecP5<1ByK zuR?Q3Z*WUqY0JW4I=W0&WMra)z*>coESEI$^qB1tc9+*jLKw)gw+JY&@$b&$J#*dn z%=PxocSi|R6UvNh?6EQz7Gb;4KxoqreJ?;qLO_L`Wr#Pu7#K%$x_p_I^TrVxOYcWX z6}Vn@C&`$4<;|NniQ%kZzYX(;c>MJ+t%a~x8^jPVk5w7c^K5JyfG5x@QChf4!4JCCfybNn_{jp@4Bfw|485xlRk%PqiPF z#?MZGjfq^&^7Awl@!#?LZ!p2sl(enz)^dtfWB9&1uL6GG1A{&4-0>^;f7Sw&L~XzR z@4ThI6@f~W-rA!5`wWkvfE|Yh!GT2}=Ma*TA)Jyf@dK2fU)G1gA07`%BmaJmC=8}R z6cOFTe7@BB!sm{1Qu>;?z!@6Oz#%xaGlBZbt|8x`A>yw?nujJ|Xgfjp!UJFr2*N_; zN2nnT_Dxp)uyT#hgfz(Hy=bklK^!G`&%e|Sfwc+T z{F5R(N4poK9$>ifzzQ}23xwIK3_;PHSRo>+qT&vKhMiv3kuvV*V|t|egIObu#xt%4 zAb9{BxP15UqqdZTmY4V7h}RcVjl$Mf5InHEYTZ{w zRnU{F@*YdC+bTdX<6pOS6V4byh$0@$ubYuY@Zr_eg3nc-L~ns43Cxr~WM6PLp&&hN zoeq{E!$@>%GpKH-hEzJ6u7ySozKrY~F2v;^_U)5*AL%cu5Ln5tkClO4BW+a)yJ;V~ zv-x|86G4Y0Y_&2FuMd}p!HlB*%XVR~xt=@vfJlHj{txB1!4-=PbKWP)ez3VA-~6=? z`&&3Ky`qDk{WS5fm6rXPFojsF#^loD7b)LGq-12Q0s;c|NH(`0XA#Ps5JNk}CojP^uNowken>TMhNitRKPCKd*8+V#E zi^Q2l2~gPJ;2k|25g1H-;uo-9lq#e`k1wA<_aA?Evu_gkC3&2PnKUGgE9es6sWo)y zg{=SZplSPMN)}{=UiM!5exKE;Z%uIl#6&K1i-(oPX8~4K@XO{VhV#)$ty$&`Rq08^ zdLFGO2THGQ=cX6jxEr_ zzdx9r6a&bH?g=Q6=L)^2XXC~r0vcKLAX))(qwUll2}wyYafNs*1BAk4cerKa0*ylH zg2CP&J|&L7*_TfqMK`I=!*RjG(pC}$Wo2b+5gWO7b^*uFrW=?y$iVUbm$H$^>?2zwA-v@s)gy z-X-^-&XSB5)T4#lb!wGu;Mm)tYqi4_SWM#&>mMJ$oObwrxntz@bxPgz`qWijr+t6f zUA1duWf*!!C&i`C$V`@;@>B-bH`=*xSMJG#*vd*f%J*xNtyzy1O?23cqcXWC6*ne< z5r`kVVUZC!a#PQfK1}zo447=MvLv@OVM_x>ZWdgR7Wwy_fa3*%J8^K0IKZZlXDZqZ zj*TUQkxrlKpgnbPZ4-x9>F^B;I<>*@vJ=NMI^`G>q35UWaQX5+3`gm|uS6fKFu%-! z;}g3R>G{V)ftm58rQCb*jboVqmKF*ErZXeqBGK$;b z)_O*l^h6P%1G!KXr$Kvjfqw(jS{$u85ny+UK3u0?!yJ1|HG7%avltKJRS_6D060ue zZQ&m^exs>Wkh69iw{!eaM7SvcB7BChsr*AO6GNTbN!5KW^;D8R*2La~S zE$An+&o;9kS zOjLMGjr{oJ$&+SN-7H02(#kor^}d9E9QU$l@2I}DExFJx*M0B=)k}rVoXDn5*Y4Bz z^mabNGiR;zkz!Vn{G>VhI|hhbiBCbcqf_9C&xC6WbbmnAVc1EmuUS-9S=@voIx%}! zdDGHq1tb4ROWH~wxp|8p&Z_Z8m%aOj1U&wsn}GH&;yi#Bd}Cwh?K^7m>;Lbu^&J82 z7kK{k>C-vy-f5jab0#-(OfF#X^(i~&^ShBk?j&njZ9X^GBfLoh2J>HoLgP%?TGH^u zl5=RdMZuG--FbP}1KKt6?9QLx3u3~jn&JB5VoeYVa&vQ4#px69QXacifF*~u0^@eo zwb97lwcr=!7HUNE7h&BMvUDpr59;ZML2^O+_RGJSGlGrTNlQX$LLE-X1-$VA;OpU3`LON?!1iU1-lDZk zAm#MOhlCjA9nq@h?@3ejd0evLBaI6V%^f{R`H8yHgW`?r=gt<@t2A`Hl>{krm!zt# zxw(+rWZvLDW<0z4;_35qAuap$_XY_C%g}XMuSN%3YlFJuB&an8h~Uu?KDz|Eyb+e4 zzQlQ)xe85oYV!Ix`j$s4uPjc#;9@IAdJJ)6Z@iX#rjVaEOW3)-CN_$AlvDZD-mgRa z7ufskv<+3O6R76lQ}&Z%z=#O0?8o5?*Woe0&o-&LMhTKCCWn$*;yErdRpq2(q(y6H zxSQGXoG=lqOWbwcgG{C`_$7#zGh$ldV^Y^W#&^wj5e=GxoUG%Gy-vqxBlM$wa+Anw zH)T+5pY7c_iP`}l+ypLW>j~2VFIT6-7c#tPn2z!|d$M(WMM_VCOpSpj+?~&e5t}R_ zbjJ!WZ4`k;I1#Tduecy2qur;9JZPOoPkVn7aWMp!2yDFRY-vR)>wEaR+C)qMT3E2g zB?*J*;qz!wgn(*V)4cY>FDb4}P~KZf)Tl9ACmx3j?-F(4p*)77lZp!M$$bouzau*H zE(C8O-X9|KD|3!C)FthdD1`@S|KgGj7brJ;~1={ z*_t$bR<_!1CE=A(p8gdJEys68gQZR~5CnJkyngg(=)`x68E1syzI_drxuIR(3(Nr= zLp^6TEi65gTWxYK2o8>bW+!R>On0?OVeZoH^~bm#^7}m@LUR?B@!;=L(B4GpI_{fe zC27ekYUB7JDBB%V?o*e55C31lZiqPyK(3GrFlO^V{Sb)XnTI>HLiOXQ*HHfqS}!or zW8*<<@`<=*-UJ}fqe@6TkkeH4zR2;_<^`Ax7gpBV7ay6d)HkNeZO>l=y_C1!=0iV)zM(-* zS*1CilJwZ)`&%Z82%J69uB;{a`amQTZv88=#j1Q`q43R{SmS3s34o!7NJFlV zq@*EsDgRRVQ zopC+LoqeDxcYQt$Dv*ckg3u+-#5X;QATD4{DdRD>A(zBrLRT>wU$c`{Z>!BiH9$*e zd+m(s5VU^bx0Yv~V<;JTJSYc}xC7LoOFORt^w0WsU*cNyvY1f23tKKxTv8anzP`>M za;ou-2`aEn!sDU#0Ui&q(hNZq_P*`bGA?q}7NN8rG<@eXH0|eRHv_?34&fa8hDh85 zsAiZf>PoL_nDrTL(k`o|wbOsz0YyR1n>UAqtNj}GlRxtF|EWUmhy+0Gl4D|3x-T-b zkEsJTVm`Yt>0#@`=LR9SN`$=+5d=WdeJ;OSODMxAHa{+LxkT`Fi4uZIDFm|&K;-8u zgzf|>+rm$@8w#=4Ky3?CesKr9P5vL=Qhvv()|Ji08cj8j489C-I()Za1wWP@86Awr zokk#IU;8AzYnF8V;(3?DYTdYw5?Rxk&Tva^v%2~9^C(nsNXTmQpAO*V1lj@3xr9as z2Zy3Swhs;sJqf-6FDNvc!^W3ERl|nZ<=lv#W!BdY-IINTZY{c5W3r^1>nF-D;VtjI zdim{(9zb$89`4 z%ND^_C~%o<-XZ)wbuF!UBE~I|Dg5abFd;!w5*w9ZBNzVuLPUc5FiSL1)+tJp$zDqz z_29oiqYV{R;9H2aSGlS(+o>YGy}jd#yA&0T4sfrzt|3{t z&d#(GcEDFVO;8h${7->Yz9T(%8$Ky2JV2{FEroY*{St%5&5UE?;&Kb$s>jC0 zPMUIX#`8@C&-YiL2C?}knCX$?=}tFK#*WL z?;%cRN8D?}WO=(IM-VJS0y;V+q&#A-n(+!f^D6JUZRCO`=fuMJ1|?nL@gR{mi4n5> z8`s=}-c>8cdm-0~)HRfPHxBOJAP#_${Ld%kb1j7YTd#?46%rs~ili{Ibc%=LQ_GHe)ghBflbJY!YD$H#}& z2Nxb`7=0T~S0jOy74ujr@^D{&vTO~U`2FF1N0r~`JWo+HaaUBV+tN>kN~M!{jb@={D}o%v21RFYN0Ap0hcL z@xKvzroqkiXl0XSeaTXc7_Q*Qs~bL4xT2z>-gYEc0$OVQ-e7P~JS ze#ei;n=Y@=XRaO#9V-IZkcY;lZ&pDcUW%}W^P?**tx8_LRL*&+4631A@GhS?O{eR8 zn(vymiU0WVWB5G57KK`Q4Y45S!A7N!BFtQ)>JFGjqPA_@hM~PWylwloNxGp8=vXm2 z;vYKQq~MLbmgL<*!@gow!uG`8&thPYb?$FPp_+*kyll!V)pM7I6~!Mscwl!40Ph1W zti&+l0kG4xR|ek!H%yF%yFA;RqTM-*eyq@q_FyI#cPyVFhtC0_EH>w3j{2cPiOagr zl|0Cqsnn3aY;@sLeG|7s8!I_^YrVEZC-zP`xLyl`c(FgvStxj$?h>v{3r$TkN(u|r z!EG+H{yu+R;lKe^nl2)k_12~R{E-}^na&1fP#MWh5SebdrIX*hC@`sTU}$5kUxVcRNuL-ZPCS-1uhAOkWTRWxs=D!RNTS9(gZFH%-P&ZO z{$CpKySG)6_kZs#dAZE6WGTpY3CFxDTGC zc&gfHXve&eu}undF;P94gwT#!>BA-A8p;h!Oid+Xq8YlIweAH-470RH)QXn;CV~}1 zsxxoRHeSP+&__vz;6&hQ<|Za1xkREii1az78&}>{!{hIZNuV%2vw$7KFZLkdB(D)U zOqyE)mM7lYL=?*n?#HWZD@qnTONtH3&yw4tmMM8a^|ZU+f+mXzf_xUgYx0F$Gy|90 zaKQN^h)SQn6+#3jK=ErGnq^5)h*5_Jd4>j!-xcNMjxM+~Eq zqdj6el9LN2XKIpDRa}MXLM46t$Z!ZY@xS8c9Z7FmV|h%(f$We)Nw^m^`HJTz0?Jv- zW3TVbErMVX`)2zL6qcks$Qg2e>e;e+1@WXQo>+r?5Bu>en9IN9~f+3jUYG0Jg@m;eUY+j3v9Yq>vtji-y!n^uH?sTYid= z4FD@>TRv6w?HPJOTf(vzO-hd0tMbMXv8;~GC zBm%xUhX8TgMfUk8SJEzA0{V=ohbp`frv5sd$S%+QN8(hDp$}s6Rm1K;G8$0^+`MWx zR5Jk>jP;bbHPruaq(3Fd1k+hA%a8$@lC+7eOh-8<*YR02SQPQ63cEIp{aWMsJ5E@7 zSVF*57y`ONQKX9LX^1@swahc*ot`=Xi>Lg8x~$4#@DRBTbs6H2#6R+Fv&YdwJU-RD z0V;PO9TR7;v@6=0`@AAxJYEj4^l|_jw=r>k)R9`@T^{H{c5E{9prMdTt-bWDgB77Q%61L0v4I6DX99S#E@E`SobW^vS=)*OSau?Aua zT>4VLG;}GTl980`1&vmykIPQNLnt1hFWtLLG(=SDwUgr$>YjDQtgF>CJ(M|vt1j}7 zd0C@1ehd2IFj(V3W94~9bSdqzc~(Cg0S@1 z)Y}3Qn?!|Pfm#ZnO@XbM9aK4g;R28yczJ+sE!J~Spr_jrQ_uox1{HurT@nEB^eUxS zuPR>kY+OSt-|h*V(y_lR%-spKMQ(tG!#ZGhKt};Vh`~eEPSnoLe)LzT)ESZ*Xj0{W zi(mTPg#tjy9f3k9Ez^*!8U0wY% zBlLWIeSJ=`CI~0`CL^Y~#9USIQq=&4B@aImb#KPgAhcIs55mw0V3G7eRLU(b=Do)m z?cIw40PrLr>Gn!Voji3aU@xlqLk-)=|D|?=DL`HE3LzFx_R6$q6vX4%-Q7n1->w9Q zDhyB+sz2u3lQj-R?Uj;ly}KcnTS`>jY)Nz`9s>DFD@O7_OK&~XA~-l>_)JV}?6%x@ z!KioDAvdi5Q{^sx7W{6k9zv;*DCTz1O?YLX;q#TX=u=De;|$5Fnv?UgWgadD~e6Mg{H zWNb1mXOoeX{iFP@(PIDC6!)%Ag?0t4j_6FqTw6s&kJcwvfc^%oeO5R(kIEV=Q>?(@ zyq-KwjCXLQJ|k)XWPv&*qUVkRtr(e>ra@7PHF?0;FJt#?7H<%w`ffw%C_G z$(51|wGY)wswAgAi(rk|| zDR8Aq1~};}04)>Q-RgM!$DBQcT{H>Y@IKw9wMamgc4_OjCT8NmVb$z9Fw3nM{ZDfL zb=>WB$kgX#b*)43*ix~|^u%;f7l!v4+qbF1RG0`Ndw&GHxs&X>HT1{W*9)OIbb;8CU~&nMkkX2wxGSgTQfJ0XW-S%B2;o-zU1kh zaA^JjD!R>nmhmR8B9H*ZP+0j`ZDnJBAn-Mh{z7|zF!b4rNLd#bwb|(lfDE4fR=DjO z7|iG`NxS{D)ybQb&wG}3mp?eM#qP4fk<(|afE)Wzt5l&3omez=qF+=_FUJUSWl$gh zMJ=dZf!TD=&BtCfeW?o~4=CU7-Ma@IAwD^o2DazEkLw#L@Y{m@VkjHn#d~F>PCzfx zdqH^Ne4P3IWne(c#YHmHB@-0ya*K+J<9~o?Z*%ABc2I78>zS}Cy$d$cHT(Z_2cKRj z3Q3IxlikyrmFbWS1hkp+doF>dJ>az#^#`4RI%Jvig6MM*$C=0m&;jaMD=JCf-!O}| zhw`7+<*``=$T{g$Z3^JzcpK(ZfYI8JL`qmjm^prma5FCAID)hW0S-tdv&$7hnAW&`Vv-d{x zOxrdH!L?Wbe{fb(5W`BVmoG~K=?=>Fn=SMP6i^li_)*`Bxj2IHH|0Y-1$qsUDfAZ$ zyI%(gwX3FUCcC%$Im-e)X1h52X+37J73$0@b}ulMLzT%#YHgmRBO>gz1S`o9PX{R= zQ9VI5K%c;$7zHV98Mnz{!QBA42N8kZl=|!MNh!;ookmk(bwB|DG_M0_tpbqJrbx?p zycZ%6h@`0-#B`@L9v>lg`z%qh4fl_{A^PNLKM0IKDoxM@`xZjOZ^|JcUB@csksZKg zkUSjH7AriT?OtIc=00bg!qyjQS0%+1L#a}~cpiy(S1xl+uHb4Tg7*t~zCT&tcs+i--flV1>cK*eP_+kIK$ z4KI#oWNz66Y40GRIV9`BJ)}=EHZWb-tn?E% z$NC>3<+FH=g!(MGe1YfbK%6QuJ9{k19I`KFHEC9xP)g$I0QwKG%0i{UyZx2uu^DwhY zUN%@P77`{17NDi>9~zn^G0cm1o0D?KaGPK^YiNv91XO~M`kvw3q$1?6jO+3;42~M0 z0M^5{f@(WSRU99)N39hCJ#!)c6dnv>HkZ*6xgZKOH6adpvdzD|c~0TLN`cOyX?4+W z)!3plb2Sq}Ray6o=#+5?^8!UZ1TUXTQPIgk=&+#2RbcwUjoa`HpyM@Q;JyhU`k0Sp z_}*;m@ttCzNBS9WAt4!%-lih;Ga($#mtzP#U4iPAY@cq(;mIaeLqZlYc%-s01mIj? zZU-Qz0kRXG*)vE~4CGEp3%<1oq5hOo7{gfhGa$`#02~D?L5Ad{5Kh&1ay9ir0)^$; zAb}($Wsc@yic992T$r<~Z;H#>kwO4{6CiamH2oo}KQ>!*ZjMWw@@!sjVA4e}EBq|X z0`_lbAZ;mGCqKpBTL!uqy_&|xA?a36l0O9AJGUu>QtK65mKwmSl z&L*sP{!s>@dp^f7C3&L>_I`mZDTZ26=tuQ@;8mKYu2NEoPdosm=8Bel5Pugawg?G< z6qU@;ST&mrS;%mrn=QIC5#|N}N}FZ~n>57CiEs~wvLDd7@-G>P1GTagnA?xAlLrg( z>hf$~PGyny)>W~AE`5HFg9aoY@{Zu{PvTZ(HyD@I&%Z%P9(^=6>3b8gqJ{B;#M_dR zreTauq4;h1n!&MS({L-uOjQnX#-_VtXYb_@#!6Zpn1a*UthpCg*XF5iP)()>_T#lh zN2>+1J`1Uvo6NvJ>e_-_&N%{e0<|+2dHQQ?V?F=0#pEf74hu0&TiCzE7hGz$w6Oqr`Cr= z6Se03589?F^e$JuP8-)u12XK}pO1(6J_L^gq@J|&__QBQ4652E_2NZw##qhR3wDM; zhtQU=D#)bX?)Yjc-im>rcVGtmCI9#|KtEC)#dGF{Td71nRC#wLXT3-BOJGccOz9vdCv^_!%)!k|EO zgS=sg8{%<_c@>41wqVQOP+K3H?ank+z1@9u2-(Z&47LGbls|1;$2(=bhZr>5&EJO~ z2ePcX9>>MG7Xgl7rZ+lE?rt`Gux)gxa}zaW0Sd3?H#ldxg!Fp28~a;C?FDv;oFm8^ z{Nq;`UtfcATQ8)&KN5cp$DnF!V+Sp+VWHjKVyI;_#W8#iq6wdQpuylJ=XS~%y`?^6 zVaWqzwVdlc4E~TKfwPY3ENj4vcxHhTyiMpb)8xmvVrDp4Tg0oZpQ}5s!6SsVNI4u7 zqb9qS!=5%Ui_7W{Z9W|UvObg=Avsv>{F~zS%*?hCQ1)5UrXjBexNKP*|1wGRR|t7k00 zr~fTo;{U41@jrw8XIuVvWwZaWsQ*~he=O=h(fEVF-{A()&=~y?y(x)wg@y{?k7`*V3HClfC!Z`yHP5 zd7nJGY;7UB^WaVx3?_QvH~1A8Z0jBvY>U=Ewt-LRF&;zUMI_{k#W`3>hujQ!^ONsc ztFtgzDQ?&LwVz=yWBCj4v-T*DIXXHtc{yWRXrnRwb2Ys6#Fe3dd`9lit;hahKkFpF z-#F~ptlq#CG!;Q3v_LNbw)x{&UD< zN~puFR%g$ZE%KEIY2ObY_B?c`ufHwXd_iEse_7SHI5IjlSX1;a=dCJO*4}5n+rf*0 z(jge^uJ@KLFxYQL|GSrWSKgCIr03dU7caduII^%7g^VP(rdckQc=8g;)U$u5>w@l#hS4vb1Ghvay92ihZL zz8Tw`fyKTCH$OKHw+2J!Rw(P-n0*G`b%+d7P3n>@TG9o1qW6JJRxs>o|-_hojc2}>KE*hf*J>Tl#jdyTxXfhM;X#{`a z8mYHVg?ImfS6Gs#QWcF!ODGlV=Q0#IV*@26-tt`k^(e-lkAj^>8_J|Hqpr)1;~Z8< zA%f@I(QbWYtwq!{DPIxuYsWhCRGHm&a}zv{;1<~;;$FICz;nwMz1KZGea+%io{G?T z^TOWf;Lfs|@xBMd#^PN;kDzbH{1Dt?ysM8cmo_{!FKiF;qLab~$F|P8j;>R>#_Phy zPH2U93}dl++yMkFVU+YEMTXDO8jL5i>7?mC@HsP~yv&ZhL)IfkKPGInZt&V&v$(r3 zG1Wo^&jS(?se=ACq^ktD57QjMX>ykKjXgX&JG;3VOLjK5R)V(QEjfJX?n)73O(HBT zZ1C-BYi?>ND)YmE?v8}<=BT2j5UzJWBjO)zj>m&ejYg(rn_Ef|^@GN$v=rmPvtB@! zV^idZ2ScVBgApcD%B~$VRX0A`l&8(5I@lO_M6$r4GQcra^5rB?xRmL2JV#!?!QXY= zV_=?JtSAIVa4G-&kWr^+_0kiasRc;_VzMY+Nr@4=NYD&m-ah*6vhtz3X03l6 zHd9t&SP1zR^7AOE46Y+S35&H*5V#`?LS=pnHqUueQlgO=9T;}WDEM3&r?2A$66qGU zw%^v>U0y=M(LKg|rVzRXT6xr*YzTsG$z;TrYa|tFkd>@W=PFR#l|ol{*YW_`NW$Cv z%)Ol^e1A8iTUXO#W^a@R(n(c3^k^d3HDBFP73fs(FGiwRLWfjO3CJp-nZKlm+199J zc4iu?)N>Q7OPAXNf_o<%8xv;miN2W;N3VB~-Zbz-msjd#Eiu99OTsVJTEGM?abtNu znBK=^6F$iSz@YFPqXF%yseNVTxM&=ALurf?RO8E3mot5 z?WMBc)k98W84{mzJc$sE2weZh?|)W8Ty5nIt-tI-QWtJ6E$nX#m&ILO(L&Rl7biR8 z6+6f#N=o&5OvY-TuCP?wu~rid#reARbg{;A5)@YSnc2_vu6Ydue`1Bg3;72mIAr0P zMF8@ZZ)e6n5I!ntg5x6u7Rue-{gUzWGb|SMt)8D#P@tCG8xs13nlnE6v#DmHG~1J0 zFYsbk7IWfriK!j!Zwd?Jg$skgNJwD;&GD%%9*z}}$9G8&%h?^Z(MSx>LuEh~n8go!lfqhd5Fkp?lBS)XvPxM@8R0>@UiVA=UYP?*o zMscfq12)7EiVE95)O_6e)9ufjQ3x`|%0SvdPk!?-6pw2NSo~VQFC1E4ld?GRfN<_U zIT5)2rj>vYMl13za_g;C;yfzlZxqZIs3_n(>ES__(oY8CIA2{rEJ#CPQx^3L3JYsk z@HucOHMByu=NcmwcvJ2&(-Hx0;dKAJE1N`$B=S8S9QHf9#YFhiD^JG=()hG5=55ug z#o?>@monubJKtPfdRTB;2D6}`pi7Oq*lFr{kUZ9`XaR3rSM(U2Bq=Ff7dBwxu~?sQ z&0D8NUNE@Z8Q*7wL{`!ODc}}xslGs!5}aW5Ib=1|>&KaCp2G8KA-{t07}+n{W7X;w zTHA^%e?kTA+4JG>OlunnI^ekPX+>JalQeHEwjePS=- z%B#*vNR;xbr=Zi%&EOZ|zMJf&e(VU)xvZol#vV0QQZmt>cslrYnznGYyqIunEp9@7 zLoCj4y7+$nsivp-zL$jlHT~6ODC&c##opku%j_g1wEU;vu`bKb6~EfAx%fE;cRe2_ z76DR;_r#s*s`;0FX^|$N%JD28}9j zI>6h|EP^Xh<0xxvJyx>Qc!iJsQ=Vexz^VN!O=`U&G&_(dM;J7>{C_iWu;;5KmGJ0Q+KR!M`;+X4*-=!U%ackL6NGG*TuG*4T!?4 z&2bhABjYq;p26&!9B@`0hU_oabt>!TmS@)a(JMarVpYHful$7M>eQ(Qu9>#8BbPk$ zGM;A4{z{1o4TZPWTz^0HF(t$cHlq2{Pq%-20Z`3xm$E8)MjJQ>?Y?5y{_778yU0JW zj|#{4vEONP#}V0{qcNNhTg4kmVa%aM^8(f*8Wbm!^dm#pXY0{4!MAcK@Uy-iK9btj zG?Skx)oDT`(L3EfPc3d=YG{iq{8_r4HylWw0toH!m8nN0lcu~8k&ncb>ETWrUqehanR}_ZU}g*L%NgARr@Fu2nK*`EdvnS$xW;VB{Oh{U9Qnm zRxgb7A&4|q!-sO`%>4=O{}H#BvH*#3K18piA{ByMesn{)Cnpjf20R75;mt5R3mjs&_S;mj zLtFl>(TZ>Rxs+8XJ@<}oSaX%!3s6_0ma!70-ZKH4*|nVPDW8u=x*T`A9fDj$o)9$6 zZD0C6(>GK@mXH7$q;^zg!dDRWvGZau5qu}u2|r2v^s}D*>Mu88BO!5kolL{r$)Oj+Himcn12$q8r(%PT8M*<@kquHJ zgi`2pf3p4bmu`Wx08yPpx0%_t8uES5QP{wJ#^H<9g)ua6Kt-mmKXyOPNDgwNm}q`e zr}X;q=9s8U+-w*o<)@!upA5e%5)1h@SmF{sDs> zdJ*#9tvUWv)q>uQy!ymW3Pk+z@pr> zNH9Qj|Ftu~0rl!5XM9R^$VdAo`#~Yl7TDGNcS#T*WugeIuEm<23a<89>uD)OzB+pI z=B@F9HUidFdgKXxHw>ot!C*J`nptd9tAjc%VZY}x;cfAWXw#sLS}2GBNDK${KqqEq z7j(wCYv6-tSGD5e5OWWjvya4E(mFw4i3);4F0&S-5yaRZX`2rNg5|GhTNvycjCt~ql2Qjg z*rTYO@JKdrJs2ups!GQhr6~@NX7Kt%$$!WX{#bbU964FRx}9!1UW>)PQLv?7H^O&P zmO9g2d24@i#t{u|n#4g+6QfpGT8H8wH0_UyFoSOIx@P5@5q$M{4ca+D)=;A+qUBvy zgV!Lvt*k-5b>dFxk~A?jCgwZU0x|G4t5{bA1{*O4I|0=QQ%b|7td6dNkCx1S%7E6m z5{P*qC@0x?ZvmId9runY3_Ua6?qvg=XzpAmm^EJ|Xe$i1-NyNvg`FJ|lH!o6JM{L@gHh<08GZz6MgfZnCWlx`53wRVRk!E35_-xeD(@-CTG!-COR9E zN$Sa|sgxF#3`rZ~8UyIzHi!NBiP*K9etroU!CJeP&!-Evr~NMb4^^nf2-g*}1IKT1 zS_=v1&CShsD;VjS+;05#c_JxJWc%8mvwgE37~`8)*Da!ELZlt&F*fuelJrffXoUVtWgMiEV z-8A&zo?9CgIwqIH;Kwnj2xNhsx`Ky9nodBgn4+Yj(H-xiweF$K!4<*aVNBgdE^%aN z{FT)e97_nQ?;1@#>hFP)JTNw?C2lo^D0ur7c zwfNfyqWG z&No&(PFE)&4)~Oo*T6fLBsdgk->_KndgB0UeJ0;)$hq6A>%vnmO3w~h(nvs)SUqb7R{!|O9LV)~&`35x3hOU6Yu>7k4r z|8z;kPd!6s>IzBuneOuVbUoFAZKAE#iuPdyvVyoqkVk~xOyjUusPCU;ens70nxU&iuFT5QNek+er3Z#<(0G-G=kY#mFG&K zi57h|6MOebHG}cJmj8TE8u{r{S5)82c+3IAtNqC($~Ci`R$*j?q$aew*}#~il^fh9 zr^Q@PZ`362RQfQdQ{;i29AwQ!nFej4`4{&N1*H78?BU_ zvG?H2O$iC$!J11%gPJNw>tz(H7lXVgcCtBdOI0H$w>7r)8_6jXOiz<=)3St(MFfY+ z*TgoTSmq#O?3OkLzOmdBM5g5S2bB!$sWzX29v?o|?yfbPkCC^k*;y)R>G%4Pi(vGw zsUv)2LY7R`rMC(vHNB>Gr6C4%RpvPxD_=yhb2fnq(!w!a>eN0n#0QLiK{wx%N}woA zp(nR#kIux!>EN#6)b~8ivwrlr+7^O%5Oq#U1VA>FWCd^h`IP1tF%j6A)Ps709IeQv zFEVvr*LMr^FXVCx;*(A!;ykoYncSIL0{9E;42oafkARMQar$g-$WdNKls6o1uRvAj z#gviO_yr@mOy+5lTBN+|e!9t56<+im?;AA98joo85hots$@to}T4cjG^<6OFT6d>& zsD5u~3bA+{HS}XKHiom21SZR#;J`l#*#7m#09pX-I$oISA$4o?&64uW>-W#53BU_< zQ`Sw?3WrQ_Sq*$oqA|2i>fWtQGWvV|7kKR`4sfvLCNdzfJb1kO)Rm{bTHh6$DLhQ%v1}jnS12*K#R>-c?&cuC z2RO}Lqp4os&kT&K*lI%c1jOFn{s<7r!gr|VjGBn|Osbi$R!`Yra8c9uUP2$bg}1-Y zt;S;U!=VW1+7?g}ZSx*dfb$HPG0=P-aV`DS919}swLs4U`qowAK-4k&U>4L%fvQKq z?f5`=xC&|yfO13TS8qQEVC$rA1u=K-GXIxZQX2kIpyG7*eAaL5Bk1J|JrH>S%A7_~ z-3>ix|3GcQacV1Q1i)PuV3NW3iqf$OQNWSi7#!>Bz${}Y$BW{CSwduUF5X7Q)I_It z{SK&$!P>A`XvP~Ld5AeUNXzpxak1S02e5znBS>zsabjD&M%E*8L&WBtkiIv1zX6gF z!FnG2xNQPrFexg(ih#?DQ)ey#+Cj;S(BZy1o12{eDeNa%VvO%a<>+OTP|WuSX4nR8!XF z7!eWiEHznINSiHOj)FgH21rTJ?IZG7*wBocZxMxF2S^bX{*eeYE2<{Tr&9H0P_F9B zY}M?yYon8wkJX;)vp*&nj^xi9t7T(^9>GrJOszXTuU4ly4Gc322Xioiy}kV}58zq= zfZmNJdWw|Zazym3^Kcw?cu`WZE7CjP@};B1%llcYK_S0X{A8wjaHp-GTE&h8ES>G) z>>RPAUu1E~WLY_Q!Bb!!%BG$Gup&FVR{v<6t!+Xqc#1bgMPEF?F1<_T>L1EP`MV#l zi}0zf=M=Mg0$(a;2~fwLZ3Ou5muz2bYCCUv{+T)aS+cpaq&Pwix3QJis6T~!G)}o{ z0jiS_qi643Uij_6&eLm%b#?%%dC>CjR!vEFHkKW+xgKk1e@weFE;f8i*4LhaJDFw( z)kCeV%1I_Ms7pp`Ia&)1_j80F8oa(rM2{~g(!3UBgPQRks1qrsKirH~dMs^^X%o(a z?WVl-RoIuG5#k1RMmrYXb`#Di^zF?g3zvJoI+0^he%Ousk(7S2P}kOY7Rxd>TxPsX zw;&-fWR{VDE!ZB~|QsUq*|A=6N%-21C!prtte8T-j_$_Vak7bW0#uNW%C z@x+ae*KY_8NcdQBnwhSMwfk#+etuZ@8l<_|{rwuG?Y*A;cDJblUW<7|hUp>!s>r9$Wo&B^fAeU z<2FL)DL|C{J#=3?KVlVt?6%m?r-hDK+iG!d;`EiAJ7(E$-|nGRvNjj}04&-n$iEY; zA@7*Nr=E58J%gyXejh&6Qzah^l=+~wrGTj&^M-f|?%8}1>V7C}tc{oKlCYAvpER9H ze{Ja?YEmnyZe?W!wb@C(<%?+j{yUrXwf@FUYiyohT)%E$dk69@slO5qw_BR)FF*3j z%OF``e5Rl@B5%}{nehXd;=*S5Ja2%6MM&DFe}>bYTvrev!<7( z^-nV9t_atn7F|2V2nu_AP7rqiHz9-4=y_>Fxf!~+sOb31@bNa~R;7(`-A-@anJe#C zO!xcPN-6TG;Kx7 zbm#RGo4Rd^9*smtHb2hUcHTVV!CmMKo>cfow>n$+#&WHlowby+i;9ePKERsqQurL$ zdqir3Sx2?U%9zfW+1gUZ^3d{^eODn>vRMC#_19m6e@Eu=VLC)sQ=|B(|PPq?m2 z*SsZMOs*1UQgTMe>c$&e6q8!xT0tmFfGWbge_C@OUCpbx$i_SNX>w|^`Lojm6A&-V zwNEEEt<-N|NUY0wy2Lqb;nav6)m{ppiS%D$4c1ADCnqNlwgV@P{!4O?goL6H9+tkW zOYw>AuK3d|BEo#p(HTU22ROZu#@IgUBAMn96hrHcUY@L!=Va*bV)Tl-GG0G5-J63` zl%T8CnET{zy2r&95ebRl$))k}KQH8zHDsvC*Gry=J-Od3yH;uA#~0r7=oTC9+U-2k zx*vDkA~@@-f^N;G>G6k@7Q6{bQk+hz+Xu*^4;$+0tO34_OAUI)F1TM9ZD2T@#d|1E z?Xkz^okK{z+(`woK|cA& z?yps+b{22R{@Nq+;GCJ{&P3|K-aYN6y6|VGn@SNTGEZ&vHB3T#e#q#(39=WRR}3Qr zB!Q`=G>yn6H)8~FBSGU!KQ*{q%<8q^S!YV4mSSPjJe`!v$U?77h8&6$(enHDibaQl zT$jmqIJ~jMJ<)cNY1&L4Ed?#Z!N(aIH~fl%Jns3afg`m;s4B#1*}5hAZB%RRv`RdY zdLs@SKJUdj`3Bfxqlz`}a{BGA*qm^7W;^w(+94N$3CJCAVFRC7Z3FgnghsgQcS0DO z$Qgbrs{~$4!xobUmYTT(Ye+LVTw4lhZf9p#i1kZ2m$m0XUW%XJj)#+2KYy`=lz=qg z+^#DNdPPCF>1ZdMj7}{kYFcv}<*}liy+LP|jCd2<70o6!*5&b3ZU_W(mx|TtDnFC?dp4)j`;x+r}-SnuNOI&6XKd5BDVskD_Uyl z0u)uQc-*bJy|(MspjcLGO*4H&%Fb4bT%Jv?a|K)BVQ>s8_|_u^3OnxsfHX4tUm&Fa z0HKN+?n2}?C|hy~5TQK@G3#ql_3d1F(ADY)q)jfa>GTs-pnj3zv!c98otXsyFCml) z(NKR;ubqLzA$i5W(eChP$TCGhuHUJ4yUsN01hhA#kAYVVuYjfm3!tG79s`Io6-fH& z{$^#I$wBH(hijm}kh?#Q)miLGB{MT?Akh#c66!szbE~e)cK9P+7vm92vjFH8v<0eu zpL`H-Rs1^W>jMdaMu6Qq5v(dkPsoE*09{c?A0NP^2PlAW`L5>sUB6Di=3A;uR=a6H zT=>jXF1f0%g3c3_g^Hc1<*v4ihCj@8hgZkDd;+UD6wm|)yfp=o?W=?24cFtIrM2x` z&}w)nj(fj$I+*#%)vNV;OYS?UtL9XF)93r*n%=eb)h;d&1Z+x3q%^AZp_#q_^#^nf zf%LF`H(3b@b%h8$=$&{y#%egM(>=Eh_z8P}a2M(W(`w`Jol}+s29cYr8KbYH^iWh@ zicq_7C#`xyJtbA6d_Fe}>K6c|QX&=$`l&z*3~P>~b7EdURYJPND|<=@*X zV|d*yt<4*B^s3uCn`m-6p-ySRfE$u;5efeIW!HH64PW0_1lWMxiaN(Z`2c!lYsed? zvp~D+MjGX6aRL_WfFmRvHhWk}3TEqpV*ghD1Wd+HDIEuWFsPh@Hch8+hk5w% znp2Rx4A=sR76#p_Go=hFPo4g!?)UuaT5XLk&Fll+52!l;o*F?(h)?Y!1cki7V!`TC zI_^Q62PuUv5&C%kinTIY&`qH*g&>oIlBHo)~u?tJi1@9tp_9iWYlC?q~;o~Gq{F3r((TU$w! zND#A)X;XIxN<%FCM@Xt92PA06iR0#z>E(dlUlv^k?d+5{L2qy(f^Fvs3Uy^^;OX4` z@msvw(p|qz+lzHMN-i*Ua*|oJsNirw( zz`g)I(}{nl^OG_RFUFn->usUB7aaHcI_ALXqXjjAPs_?-urt!X8T|S7?ORokx9GLM z^ro&c`vOXlbMd7&7AM~nqLtOuS`ptq02KA@uHKzS?DSxvMQwex#zTA2<#_;IzBWDC z;zi>IK#d36)8DYHp0msAii@1@cAaoDGc%-`k`kzj7^~yB!~$iW1x~>@Cx~4?Xa#ms z05E{8hpVE)R-E2+Q18%CTr5T!vm9Il{JH!;VLBfp(jfA6y!*5ON z-f4JX#+U!*<@av!@(pG8DH}$8Epq33#ZDQqE+DeBJY2|!|Bc-S#i-2w{p=B66VR;D zH+<&bmQoD0s5be3(FYIT94CAMQ-p;DdB;8ppz*FF0f>h9bx{AFvCS8;b@n(Q`k>Bq zdsd;ngXkBrBTO3m9Sa!yCc7QC5C<#`8el7LrBm#P;rfr5e;=J*6Ncltvqz>>698du zlF{gH>j8qBXqy;luJN?AaLhXTM?)Zh1i84`+S6U+6XPLmZBq2a`C0&Bf=GeM-v9WBuOON2~hvv@C&Rhm`#-K{VV&x6P8z%$O7+bf-!XqN` zElldv0exP}YgVU{*>|UVa~vU88L(+N^0m-{Fx_2l1KFdJs=ioR9=4y@V~)>^ZUj3e zcL+j-K+M(vty?EB<^&i9w8-p^NqUV>_Jdl~h_|e7wAoF2{N3!i3yn9Kf@01QArE2m z^d**SW-Zml1w1*0u?kQD=>5)BJ(P{IZtSz77MHm$G0~_{f>G6B+tJI4uEc;xyoWrQ zuR*UGQa}tpG%$4+n-cCD8A3?|U7BKB4-W-klmLfC!a)ziKE?68b435~?SDrc{|%!! zcS8i9M#}+K7tLRaudm}rLbSQwZ(Q|5E;JD9kbPu%LndI#v$PKG6s6ivKPgNqTPOo& zm;j7vdZZY=#GWC?NA=?>W37?-B3Ep z$*ZhvcLL3IfIun^+?oTtSW3w$^k5OUglr6T)1yrA^7DH$j5Cb^DA|H!T>Qkk z-Xe9vd3c_k<=54tiWV5LIt4AWtxUCxbseD54-vAEymc+w3cv|Ec?`TsZH5$q0K%Ya zi@{q8U_Z+nAUK1hfd6>99K+?Oe<<|q-jX=_b z72#~XQ<~>KK$|jvH0?{^UZO_r5ujg5m!H3E6%deqGA3%qz_7-j$sFzOaQB5&u0DpI z=yB4mlEg*d^i=tgO;oip@leqx)^dpUXzug9-6i2|Hi0CqO$h)*@s-p}4q;QKSb(6t z%0r_;Gu;xi9pub%?HC9_LGTJR=?&KnovUw71d`hz?L?R7@W}O|X~4HV6?P~qL8u18 zElNs|2&A1|p2P1M8d(=Y`bogRl{Eycvpj_-V(xS&4qgLoaxcPGNb$kB&-Uz|w{8aH zbpkk-Mpkvm5bL?L=S)Cd_v7dzVW6ka0eZH|1M2gulAg;6R|#Ax0tT?Qkp8A?8OX*7 zOS6H&7ON0K07?xc?QsFvR!D6F>0g&-m!J`Ahg_rflit*o`b8uFI2Rc1^Ln+mqTbDd z;1d1|8eL2Wiq6#yK(QgKyn#$iYwXW`$}X90jK#mAA4GvwLQ;(2)|vbi1v738aidO( ztXYzs@Fh{L?x4qd{rg=mSDz2Ls;X`zR>Z`_b%ud+NgKU|?sN?li zz*Rtw5rE_oHn`!Cg5jd6ukuYmR+i3kk^`EveQL%U9{R+NXKla904I?m4eUsw6P>n9n#M?fHqcb)LR{y}6r{HJn`m_<6u{fqZ&rmCCitMmTB)EZi1dZ_6eysQ zGx2>iAAC2!HJZhqqt|!CP{IM`UDQN!>NvnW<`qNL{&n}Y0$EjEg|*bO5N*M!sb+vo zbqrmA)ma zr=kYlb&2jq4xQ_jTS=|)%%${0eZpf{iaBug{NGuc2W<`ns$rY<=( zOtUy1Xi(mx6~!tb=@t-8CdYJ4+zI})%7NHm;%r*M!9Kf*)wp}c4iF^ ztq{m+p+|s}_CU&;GqG~TP2v`tRBp(kE}#R8m#sOF?jDll1pE;KV0mdw!^5koen=oE zYjXBYPnI9a0m`P~>ncKMWl*^Rsg6VPGU!J;bZgfa0#}xS_bB5#iTnXb5d@)gNXQM{ z1*r~~gccg9>cu6)!b1MVvt$6lq4nMRT3vOJ_A_k6lxuJ(+b9313vxtS zqpG^yje9xC(IcX|;(qU7($(PnXkfaVO^m{N2-0s>9fzrpMK#HCQ;?LgERv z>#eJ)vN1hJ|1h{xJKC#UP;M9S<@v_o->m*j_}55EIIx9WzLwYO4;=}IsQEZTob>(d z_l$yO)qMQWbt9LnGS)z*2SqjTvjCf&75=-u!<(ThPD8M8q^8)#r+D<;H5{4Gl?-3N zfL}OVZ42n2z6#_bz+>9X$g=lS1Grgq0HiH5&bd-=Of}-t$ z;d67->Y3&%C`oavI3Ru^ctj2=h{q2-71h)gT9*;H>=qz7w;Uy)zK_w7q4d3y1Ai-4(aiNGh`Kx&oIR~sNcTsIr(^m}%nK1mB0v9?l^b%%MOGK-mge|A$EV2$6h~g(lX6&QNwp3XpT2<5 zOb7v$KE@0)7pp94hND z7%zg9r1M-p^9xiIl2cjLBE_A$swEzqHjRL&0bpfaoz9LJzF}@PD?*z3v9%j`sj9$T zDu`Hh%0o^Tm|1Gf+7oPwS#lW$4O(R>a-s1qbzAK@Bq2zKP06XA2O!x$r2PenQd)SY z0XO8H(P98PLp4opP|gG9g#!ti^FHXyi9~69jo+F;D##QaXm+buXzH@kWvG5T_%HE~ zTVZ0x|NNl>sSgC*;qk&cIVT`0G!f=%LO&Ps&VK?_ag`A&ul=e!pax#owcq)Lbu^4F zKnzEqEZlc(7zV?P{M2~%&>?_(LJos}?|P%7A#nO*J1o>(ze+Qc#)yf3baB;a1ZJb%|;DUkEVp=YqL4yyV>PfBX-kQ+1gD literal 0 HcmV?d00001 diff --git a/scripts/windows/screenshots/kotlin.png b/scripts/windows/screenshots/kotlin.png new file mode 100644 index 0000000000000000000000000000000000000000..9d4d5fb86048c7d77123db35879fe50b41ef3a2d GIT binary patch literal 13960 zcmeHucUY5Iw{OrH`NjewO+kpFBPdOjqO_m{7%L)D14KbUX_*KFq$D_w5^BZJ|~M0yDjN(d=;zn=5mbD!s)cF*_R=l*dIe?WNiX1{B%wSK?#+iUMc zUAtlGss`2@j7iP30#s*Z-2g@h!z+ z4A=UjvormpG>+ggreprO5|hH+;{|_7eOfu9OTpvQ+RN^F|M={=R!2%xO4GZS=cJZ* zNHKM9c^Fd>9-Y|E$Xf5mBGTzyitL-6f#IDmAM(2IcLX|5Ne$`Ub^vxA&HW$_{`ka2 zfZtDl`Wp;(=;eR;<)5f*m*8`;h4Kn)dHG9j;1@Hults#ub-9L9htj%o$Iamix~T5@f-*oR!Zb zr>Lb`#$k(R-3xZp?|tMfFMs(>nb8LyeM;VTQ}4(j7;Zb6de7W&>Fajv=r_xz{sw(j z#E36RofB@zZ-37_3H#gMB7$n={pt?;)$(atGlg$Me^!LuM1cp^+Bfgl$8sOe5o$XE>d8iIFosQz znfG-|c;KO^cqe*acXfT9V3%FXt~M7U`xrsVIB3w5`C2?}f6$1LvIg&)S@*AW(#3wo z)9Viv*vjcr5>_<=|HN?J$KvvR6IQiTr9Vmy?^?s z!!T}(#AMrbez3F%NH-u7_n&#AkFdGEI|jiU7YDZwC_$O49lZ3F^Vf#(^!kA+OXwqQ zV&}KN>pTjK$l4*Ucu=^>Pq`%eiY7+Oea{_b-MQhmjX%*8;1Ux4cq59`&imB#AWI}NRqNJy{7zWqFD9_Mwlv`V z_BugcnF2de{AZj>sj4G@T zUV1fVwx&DU@l%A6#j9}Bgb7g*7_72s=MOO0AD2x4Kehhwf9YkV++K*R9?N4?!V!R= z1W(nysunCD$hiV2U#?&JjkhW@QKCAkVQg!6WX?P>d}6A04!YmSR&XDfHUWlk4j(5M z$7fx+w9@#qvs>o$^JOLz@Y8kOKKTS)^{CLr8UqMw_Cw`Ip-60A(tVpXC7bqOg|RYG zT7LI}ny+J-bvmo_P1c)jun6Z{wIW)JtJVy`Si9!PB3FKk5i@MV{#3ZE|r2GQ2hG2_b?$8t5D4(JUB zn+|sU0E#!)t)`}?$e8+2Tt9an*G=&t)CLG6 z74N@Zb;S>YF?zCpE}d_dJ)&=CS|bRDnD^2~3V(!!3~;DFZcKHxH{^U`xq@dT-RIje zsrWUtMB2Hx<^GQ*&_aGKz|6ac;!PdGbvU-mIf&N9;+cKv?QL$KqspBw5qd!s=B}y& zgS~m{d-~9K-kk~(WUOWcZC=uBrgie~+!ZuQ8s7-P)*Ce!nMWQB`vvb6SyvTy--**2 z1Nr@Xj;|!TM3;mI+74oPlnDFFk;2O=nVo?M!uA0OJCnTG_zB zz%1I#EXz3jLhO9m^=&Yi!Drp=N=i^I+$W1)$p8dq>|@UZVShfU7&aY*3cp2nZvN#i z$Y_-Ffq`U@h_$OIj&%!(H0`8t<_>46hw0Y}tfV;Hh8(jH#48Wj0uhZGZ+skBf4e|2 zZY+4hBInR`e$r;y4ow*Mz!G2mA z&+?{JWSk5eSn4@IWNZ@MH5&$srhbCKjI8dA{o%fOt_A}DZCLqrBTGdD7BOPg1JR3# z{R4pEn#O;JY6?-T_vMTUon=mDn`<}RCEgD0AXzXruook!)*&2#!Is2&F(vuAiUXRD zcPs1o* zJ43@n9d6uVK9Y2J^Ac=eyW!eEj60=m8>82*FQERXcl1KP_CL-{mBdFAk_9nkrNsr? zn`PI9D-T<>Zr-GFIu&G_fF-OI6^XUeT^e!Hc1NN&EPKnBdUNGZ-+uez$#L>o-PxAi zQEcOIpIBp`U&E&2cez57%_JetT-^fMH%D2oQwEk^rp|uhB*HIWby0#2@6qRB-@tZAOZTdF<+dKQ>1qCf%0I#l3-M&m-?2Ho2BHSN}iHxEkvI#S#V zeuCoT5`OoJwtAS3lF}sqo>d{LwiKmV)n7DXKh(K8jUkgBY%RQ?9#x_|nkn}e8}qTr zG^pIDBZmb$++CcI6-XU1XQ6Qf*y!1h+cH9KQlVMP*K>Q4IkXr*65DvB)NFcTb!8VJ zdPC7j8?pPH;!WXpZsLf>F5+QRQ5=Ys!8^z^#Z`-i#LltdAfr!)Cg z`dH2o>ak)Ormo?hGi2uD-Jx?r)s|QV7p`FQ=LrmZgZnWsi$&R}6A7N0rPz_n=iy6R@rFGk&KyPMiHdB^1h z3*E%d1@q?RK<`mCXLM`~}r{egdq9p zTa0lMe8}*SdEf462=|EHFxdRL__Mc~WP+4^W%f!)5CWI6S~Y&Z;`hTqdLKM>PgphG zBgqC3-bIZ5c-|?b`M44ofBj@+>@Y$wp8q*>%*AFikI1V%IpI6>MTUZBjR!9^B=Dp_ zxl1`a`MU(RJ4@M5N6|G@#Hu`aTy?pPY$y%EpA3c>oe(=vO}N}J{Saly{v%Opuh}J( zOH2+q__(7?5%J$Az=}wP{Xpl|p)6!;q`}(wUAk{fWG-NsN~8(!&A%b#>f+`!{skNU6`} ziv7xPPw;s2M5!OY#{MnN*JrruIKhFmx}^8%vp1Ha60k=CQG_ zOdmG^6EcLO{7g%7KF>MiHnSxT*vhr1-U_cPb2T&E;PnhtVXY4m=C!cH1Et`TFR5)B zrFX4S?ZUOUS{qZl+}6mt-*PVL+e+yF_KM?aaWr6u>2bNCOe1g2U9rRt;r*SDqW51L z(QF|H+Na_s$Ga(La&BQ^?8oT%Bo%WrEybOW&fl!gAfL};a=XcJ>{vTAB{vy^4L z)iRJ7AHRKl3_)nUy2j17DgBzK?A9w^z$pAFr}3GhH3R?4_WJTFDjvT4F^i4#dz4#-=;{eQV(V-wh9`l{_C z($fS)gu=T3#De&$$|3yLr^eE+Q8|oNEYof2&vTnI&R!GVCC%aD!*C1u*<&qfoL9Wn ztIGujg1teb>qvZX!4REZ)6|H1@F~3_Fx~%->W|1*N(^GAc=WTYp_tf0g?(EJ*AyO# z9o%11WG@K_LO0zTucOH6IKABdtSzIy-c1Ljkc4NN|Ls(MX=X-QS=o`J`he=up$ipu zg1f*lN}$1^ML|dX^7|Og=HHidnnqcF$Yx<0Hm8#M2lhV{U4X-12H@~DcKqo{3+CwA zn3YEQm%fI)@@-a5*bhVWjCv^vU#nc=89w3QfoGbAXV(pGng|Z-vRmf7Gsm~s%!X=9 zi#``(^m+G#{_k^>xD;iijg5^1@`kOgxlq7oivysH^c6*L7EHxy8J;O_jJuK{3t^_1 zyBG|qmc_sFC04HVv#nB1?aH+;LzP(h#o zw6nal^b2fXBq=ru9zKSW=)SM(`t{v|&Dw2Xsy|b8x_SE)}BZ|%#6R<_GAET6MU(Y>s?ajAYrZva7 zPDI-=C+u9JK7RbT|J3bY>ZaZuwz3PlGf#~XzY$ciQqk`!cu0-w;&qtSuPQxf54zvxc;bgOr&?SHh?Z)QW94n5KIP#H`koG`N?>h zD!A)9Utvd`{+z>9NZuhXmrC6Qg9V^^b~gQ%lO7wsF{lSFN?96iBDHx{hZXOC8wZr) zUwW(Yv3)Jhx4gW(>7h7ZLx5=%#?i?3e-Mby9w1@AV+Rb-Zn-wVLnG)+;J={jUS0Z# zlf|c%GX7|`>9<^b5lkcwup(xwX1Q#ooep(^q1oWC4cWj&aaU4w&AbbwLFX;R;<^cM zaooaf-pYcOT07Me&Su42p>ratDl03KK-&l*Z0^deuV!!Yp`aj+;M(J>Xp@4l zTliemMS3Emh~i_!C)AbD=n0h`P1A?5vrR`bi|Tb2sh(EH6`|#Gy?gCBcHxsI=|Z}*o~AAE3PaMuITmR*{QoETxYnd*w^NP=0v zWvU-Z(lQhycWr))jzvLCZMQRc1_~-d`^aTeFmS()=!beRg-$ z0^SzbdlXE1_cY?r4zc-7O#7JsS77d(ULi?@FZvCbOXjb4iZ8seDPeK?@_ilFxXEA< zd2Phb5C;2HP$|DxbdS6oUvxJSAQZ&Z2C$U#$SV+1#tUgxsH(CXM{KS8*}aLU9-XJb zt%yH&HKdXkY}8{6M#E?SG+ig1pX&M%7BTu}BqF!yfI>;3lY}xk;5y!`_A2jFRH;y~ zH5C~YHIlwj-+e)JmuEiq$ zgLt6jt848<6{!;$^+p-vThvx`uPUB{@mKODo$+(Ja>Ewa)JR`r0g1GzJo91#>qcwI z*RyS&E3j?WawA2tb5h|ng`lulMV%AtqoJ(a;^O$^>^% zhrCN?<7^=1u$iZ5z$DZKMUKYh?UlB&%AIz4G|dsDqm31TfNMq9m?l)#9o*n~otuA) z5pq6phH~=@qx19geRseCBSOu$A^NJCpIx&PGgx0gmCBjn(MC!LLOmTc=p*kqgpv>wF4WTlN!mW49ojT;vDY!g8whOLC z!F4>Y;#mtZg~4|Nc@t>3ENkl>ahjfkS)KW;%w3(4%(PhG+y`Xxi_5Vyg_XMT#`i9_ zd;5f0;7>Y)hK3Gr(YKhnS{ADzhM?2^b-+uBd{Z+scbeTRr4u_}l&-wc z(PGE!DV?o|r#LUBXEw_lUE8}wEy(%CRyOWGf>zt;i-SO0Gq3Wl z?N%%&42QEYj1E0vDj81)8d3`%K31>wCd4F__eyf*bNys$s|D>RW{og~*R!5AJ^cR0 z?x^UG_N$SyOm?Ecn?yNn)X%%_B*>Fzk}TC@v@8^)a;_L=NQsAY>!c+Bp5&iWP6h@F zA5vqK$u9eI(zP`}*?CSL&9ZG+cO2d@p?z#@h3nr+Os(fkELgS>_}}t)OSZl~BYPeC z6dgiQ<(>=OeWQ*;AHOV5WfRYbrrz%yI3vV##qve>v?H24TnN5_yrtTp<`Tl1J<|4o zG8uO#)fjtuzGB0ro5Dh&5~Ef3vJR}I^GreKgR-Uq8a9`kj5az+Pzh{N3=BBj%9~j|o7R{W~@zKeq;?di8?1}x$bUo%pNrOZQwenc9EOkGdzz;TD<2?N^bYy*m zqTk8=@C5s9OFXI}oSVRSg&AvbMqy0}7<+Y1VZyYK&5Q&e9eCE46djHml{1CcqZ97CiCqN3@4a>b`*Y3WXq z%BfEB{4G|m#2)8u8FRCy?(XjM6c*mS3v`-7TqCz-6u7q~(x6hfOH+dsIM?5tT;UC| z(`H-9Ys{Fj)|(pr9jox~yb2gEbi$MoV*RfyM}pa1MG_L-eGSLU)aLd{_<~ZVxZvQa z4RpT+vOMqy^Qj7l1T%7jw3X2t^lHhXY@_&)CPa=$%=er~^H*sxUEV78gr86~5c6fd z?K0lX0c<(a=PmMApYL#0>5=_I^vt3nbw|2mW<`aLuI~_3b%DcL2FmYR(-tb@mx1)S z<30DcxOf|)7+C3K**tPRx|$cFdT?d=nnO2sAwp#P$8bM77Q5mytbosW8yFWK4JCK#&}&s{_=<3I>+*an2{3xs@EzcIR*0~RGC zU`q!Uaoa#>1iD`lBab6s|H`hU!>#7B{<2V33Y=ii3GfN?cf*4$8E9Nv=+|qh+aR7< z_+p*0#6&HAcnqW@AWF%M{QMIHbs$3{y@BEao#mqmRL=Dqyj$a+89-?d0P4#>GhW4X z9fy;nqT?`rQeac`oh~r3xkXP;AGrqxdo2k#$>bCe5U`{F;Y(Ggz-EMdfA~b(=5i6Y z3(h+!gP`Bg)%BVF#EGPmZFRvm@w`j1fRPwdxeYt;DiCZrHaKx-uNoSzE*-(H$x@%g z!7ZadhDd>@zDv)uKqY~;&}7Cgwsm*e?F(TngkL-93MG5DGL(#&ydLMZmKAcjPn z?#^VyllgV4pbhZG;N?b2XlUi5yUTB{KrXq#<+fT9Y;_UitDi59Q8(&Y>il)Qw<7Az zA;D~2YZl!z?Y{X9J3HD4wIN3v2T~%XNZ;o||BEM$Vn*67Av0M$3&ox~Ugo6uTc(jM zv%LkE+$1F6f~D7Go8UqM-p|+f3Ucb=C1lePlA&BqTFAw_S+6}-r_Vr9W5R{@AwbIw zq=6OpN@Q*fJtA7n@Yf}eFk5NW+n^>-FdOm^#~P#t?*SO zyw!3oe{wQmKe%ns>jFM7qZVr6l(}n8MuH@xRJ3h(y@W~@Gbk^fPm9fHt2J87F80*I zuY4x^R$j@k6+`ge1pEB;TN!5)c)hZB!4dT5{rWQ_KEN{?YUJU&?F>_S^}?#Ng0A)w zvjD{~W8kGS+NHDXPYO^VcFXIR&u=bH%VfXpG(2+EVC^{sw6(tK=tP$j1{Qz=YM=QL z#K3aujb{xv20c3n?Hs)E-1i@GM8Pz@1XEsFIY|R!Ty#XCG66H4fJiHN`iTV+8pI4@ z;IGTdj5nrp8A~78s^6JzmymNAVmbks$xZQo3VLimd-RCwaMiPnPYs9LR%8KD`b3$2 z`A>4xI}aHh^=JcD&1o5mVG1}vA+CG~HqidwY!|Z&BRHFmpM>_k*MHr%EusUMo524G zX0{D<1b_J_etxvR#~}eepAQ;mNn0^FZmn-s-4>Gd*q>qZ$3VLh88*4H3?R=W3#+sR zQKWDsKwhid>|l1sde0eHEh#$s{OSyfSTB0Re5`@);x;fjZzSZ?I4yRI@i~g2R)(E_7noReBMK` zDhONX%$}+ujArx?sz$MP(u0zih2ZuGju4_#2ue|yFdgxkhIZsmT*7d zd-FDKPwwct=Q*G7V@k%n<&Ea4hG36_f$is@LXMY<82O;39Q#51#Y{gYMVKf8PN_SIsjB!W8rp{!lz z7&s@C_V;J~BS(h*Ufvxj+g*YBw^EVP-SWD7w-r4!H#j0f()ogPrK4v;LD;9L?}Lw+C$lv=UchRtaPDf)LLJz|?%~(? z1%efYQFH8p(>O2Ws-p=Kc{%Ci;mM8^unQ_)QBl!7FknA4djpMz@7c3Q%l+f?4`pgf z8QN%YNGJLcW9_tOXKa+DasofL=%x1E>rVGtf6m&v3PvD+Sj=*qX&?%`o4Tt^?+!*3=^9JxsGY)`V% z14pC7kgNnEur+0j&YpY8f3*D8xn84d9stkrRMsO$)` z0!LtMzx4H)`}_Ofc1Wn>?oAV|eybJ(=4bxu+Q6g(lwQt(ago6NpvgE_5dn-Mf;c>{ z6zl!+rHQx6W+(c724ge&P#Ehcg9Cf7eyq2 z=RwB$G=Jr>yGVm?JrfrwUU%-Ogr@Wl4Gq~>`Z&zskk8F%&7jQdQq)n7Se3_6={b(` zFhp8zcU_+!boZ18!Lkcc&dLBf*vt(a{{SNfjOg?`S>tlKPcLa4@1THQ;1#`*8|wpf z{a@4cf)wlbC54&QnCDAehRIO>oP_`~mJU|Kg(px=MnVe&iHul~c2 z{9n%C6w*bYq*syJ>5c|zyxCS$l$DjW8vNFywTiufbFXtX2NWpMjA_Ah>J! zPqu^f_h?UgLI!%b(v8s+1(LST`bdU_NOx})mgzd#{u0aX(3{Gs$;!(+ZXUb;38ako zL8mL)U!D%~LeBqc@=XUI)7ja%Jy}N;%=j|dxL&(IkZz5ws|w7ku0ET6HBAnXh@+#U zlF~(z74Jm61@dyT_Hihg0ha*MVFl!qiuk zuiaf7uGaGIE6fGZRBOwCUbErn=ZqMtgZ%;2>^p`w#S;Ytt_WVVI(@MBAhvekspghMmOWQqS>-jm&tOGDe#>K^D z(E5eFw>nq~P zs7;-L!Ge|V`1y^Fvp+h6CWJ>O6!O>-5?Ywix+)GMrfo6?#5)5}{Cw%$+}tJh zzYrNB1l=Hkm3-|Xht#pw`err#m%;l-%2bu+w}DG zFgWt4kdKBWWD6aYFT zSAfu1`D|pXA_WFmEHaVGytTP90nV|Wf3+7xo-k++@*0vI$R_^A7`o;d^+zvA5|uL6 zBqRXuX3X+8zdx(@MFuca-B<$#tF!J%(mDdvLN&Bw zC=FVjkjQ{UjLW-?FBSbRpp=_|5)G2b0MMtCe}@jh#Bj)5(f2HyOG!y_6Yw^{HIS|5 zKqi1vmShm*2htxJC`PlPDj*!B+>9r<0{Ao~tv5+O&v;zZyBDgsA*$spx5T|^>HlN+Nd0@!tAUT^DoP5LWLZRswGXtr; zPf!<#oiBcKZ%cR)7dYZrUI0p*@^=#E13i=2fw_P+umPTKPtnutLgT1r{X2Ae% zXa$|RcLqcMOa(!RU;He;Hm(5i;s2;p{Hf&Lv!8P;Ngx%g=89`qz|BulE~Q!y z04ekje5>K_OO*fm_agp#g#QizNb-O5>oxy9!vDu<#24!P7@VtMWAO-N66}K6Rb=t` Ho4@@JPW_Yy literal 0 HcmV?d00001 From 6559b6c9e11511efe0f8e5bb35526b674755673c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:39:44 +0700 Subject: [PATCH 87/91] Close the thirteen open review threads I had been reading review threads one page at a time and never paginating, so "6 unresolved" was wrong: there are 113 threads and 13 were open. All of them are addressed here. Sweep and publication: - Strictness is now scoped to scheduled and push runs. A workflow_dispatch carries arbitrary inputs and routinely runs a subset on purpose -- scripts-ios.yml with watch_only skips the GL, Metal and tv legs, and scripts-javascript.yml with port_status_browser_evidence skips its screenshot job entirely -- so treating whichever run happened to be newest as the authoritative producer failed the sweep for ports nobody asked to run. Their reports are still merged and still win on recency; only the "must cover every port" demand moves to the runs that are supposed to. This replaces the narrower browser-evidence heuristic from the previous commit, which the watch_only report showed was the same bug seen through one keyhole. - port-status-publish.yml no longer requires conclusion == 'success'. The strict gate fails a producer precisely when a test failed or never ran, so demanding success published only the good news; the Linux producer can also finish after the nightly snapshot, leaving the previous green report public for a further day. A failed run with no artifacts is no longer a second red X. - publish_port_status.sh propagates the acceptance status instead of always exiting zero, so an unusable report fails its publishing workflow. Contract drift stays quiet, which is what it is for. Report validation -- each of these reached Hugo and could take the site build down, which is worse than rejecting one report: - suite_finished must be an actual boolean. bool() accepted "false" and 1. - A test status is checked with isinstance before set membership; an unhashable one raised TypeError past main()'s ContractError handler. - Summary counts must be non-boolean non-negative integers. Python's True == 1 meant a count of exactly one serialized as a boolean compared equal. - performance.status is validated for unfinished reports too; `eq .status "complete"` against a map aborts the whole build. Presentation: - The errata list applies the same reason-code and port predicate the feature cell uses. Listing every skip by test name alone filed a Linux encoder failure under an erratum explaining an Apple simulator limitation, contradicting the cell beside it. - A port card accounts for not-run. Reading only summary.fail rendered a green "Suite completed" card for a report from a red run. Native and port: - fileRename resolves the absent separator before comparing. One of wcsrchr's results is NULL for any ordinary path, and relational comparison against a null pointer is undefined behaviour -- an optimizing build picking NULL drops the parent directory and puts the rename back where this join was meant to stop it going. - readAudio configures the fresh reader completely or discards it. Every call was unchecked, so a reader that could not select its stream or negotiate PCM was used anyway, returning compressed bytes labelled as 16-bit PCM. - timegm no longer delegates to _mkgmtime, which is documented to start at the epoch and returns -1 below it -- so every pre-1970 zone lookup collapsed to -1 and was evaluated at 1969-12-31T23:59:59Z, answering a 1900 Europe/Paris query with 1969's rules. Verified against Python for 1583, 1600, 1900, 1930, 1969, 1970, 2020 and 2100: exact on all eight, including the four the host's own timegm cannot represent. - checkKeyFamily requires the key label to be exactly RSA or EC. startsWith("EC") called "ECfoo" an EC key and every other string an RSA one, while JavaSE hands the label to KeyFactory.getInstance and throws. Verification: 34 port-status tests pass (4 new), both desktop ports build, both Windows natives cross-compile, the site builds under Hugo and validate_port_status.mjs passes against the generated page. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/port-status-publish.yml | 25 ++++++++- .../impl/linux/LinuxImplementation.java | 12 +++- .../nativeSources/cn1_windows_io.c | 18 +++++- .../nativeSources/cn1_windows_video.cpp | 38 +++++++++---- .../impl/windows/WindowsImplementation.java | 12 +++- .../website/layouts/_default/port-status.html | 30 +++++++++- .../partials/port-status-port-state.html | 9 +++ .../conformance/backfill_port_status.sh | 54 +++++++++--------- .../conformance/port_status.py | 50 ++++++++++++++--- .../conformance/publish_port_status.sh | 11 +++- .../conformance/test_port_status.py | 56 +++++++++++++++++++ vm/ByteCodeTranslator/src/cn1_win_compat.h | 27 ++++++++- 12 files changed, 289 insertions(+), 53 deletions(-) diff --git a/.github/workflows/port-status-publish.yml b/.github/workflows/port-status-publish.yml index ef3f8e5b10c..2d9536958e7 100644 --- a/.github/workflows/port-status-publish.yml +++ b/.github/workflows/port-status-publish.yml @@ -18,8 +18,18 @@ permissions: jobs: publish: + # A FAILED producer's report is the one that matters most: the strict gate + # fails the workflow precisely when a test failed or never ran, so demanding + # conclusion == 'success' here published only the good news. The Linux + # producer can run for 90 minutes from 01:45 while the nightly sweep + # snapshots completed runs at 02:35, so a failure finishing after that + # snapshot left the previous GREEN report public for a further day. Publish + # whatever normalized artifacts a completed run uploaded and let the + # acceptance gate judge them; cancelled runs are excluded because a + # superseded run's evidence is not current. if: >- - github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.conclusion != 'cancelled' && + github.event.workflow_run.conclusion != 'skipped' && (github.event.workflow_run.event == 'push' || github.event.workflow_run.event == 'schedule') && github.event.workflow_run.head_branch == 'master' runs-on: ubuntu-latest @@ -41,6 +51,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} PORT_STATUS_PUBLISH: '1' + WORKFLOW_CONCLUSION: ${{ github.event.workflow_run.conclusion }} run: | set -euo pipefail found=0 @@ -49,8 +60,16 @@ jobs: ./scripts/hellocodenameone/conformance/publish_port_status.sh "$report" done < <(find reports -type f -name 'port-status-*.json' | sort) if [ "$found" -eq 0 ]; then - echo "No normalized port reports were found in workflow artifacts." >&2 - exit 1 + # A producer that died before normalization uploads nothing. That is + # a real defect for a successful run, but an expected outcome for a + # failed one -- the job may have crashed before the normalize step -- + # and failing here would turn every such run into a second red X + # that says nothing the producer's own failure did not already say. + if [ "${WORKFLOW_CONCLUSION}" = "success" ]; then + echo "No normalized port reports were found in workflow artifacts." >&2 + exit 1 + fi + echo "No normalized reports from a ${WORKFLOW_CONCLUSION} run; nothing to publish." fi - name: Rebuild the static Port Status snapshot diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java index 9247c0225e9..258b4dd45cc 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java @@ -2890,8 +2890,18 @@ private static void checkKeyFamily(String algorithm, String keyAlgorithm) { if (algorithm == null || keyAlgorithm == null) { return; } + // The label has to be one this runtime actually knows, not merely one + // whose prefix looks familiar. startsWith("EC") called "ECfoo" an EC key + // and every other string an RSA one, so PrivateKey.fromPkcs8("garbage", + // der) signed happily here while JavaSE and Android hand the same label + // to KeyFactory.getInstance and throw NoSuchAlgorithmException. Identical + // public API calls must not depend on the port. + String key = keyAlgorithm.toUpperCase(); + if (!"RSA".equals(key) && !"EC".equals(key)) { + throw new RuntimeException("Unknown key algorithm: " + keyAlgorithm); + } boolean wantsEc = algorithm.toUpperCase().indexOf("ECDSA") >= 0; - boolean keyIsEc = keyAlgorithm.toUpperCase().startsWith("EC"); + boolean keyIsEc = "EC".equals(key); if (wantsEc != keyIsEc) { throw new RuntimeException(algorithm + " cannot be used with a " + keyAlgorithm + " key"); diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_io.c b/Ports/WindowsPort/nativeSources/cn1_windows_io.c index 1c623128b0f..5ff42361033 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_io.c +++ b/Ports/WindowsPort/nativeSources/cn1_windows_io.c @@ -304,7 +304,23 @@ JAVA_VOID com_codename1_impl_windows_WindowsNative_fileRename___java_lang_String } else { WCHAR* lastBack = wcsrchr(path, L'\\'); WCHAR* lastFwd = wcsrchr(path, L'/'); - WCHAR* sep = lastBack > lastFwd ? lastBack : lastFwd; + WCHAR* sep; + /* Resolve the absent cases before comparing. An ordinary Windows + * path has no forward slash and a normalized one has no backslash, + * so one of these is NULL almost every time -- and relational + * comparison of a null pointer against a pointer into `path` is + * undefined behaviour, not merely unusual. An optimizing build is + * free to decide it either way, and picking NULL would drop the + * parent directory and put the rename back in the working + * directory, which is the bug this join exists to fix. Both + * operands below point into `path`, where `>` is well defined. */ + if (lastBack == NULL) { + sep = lastFwd; + } else if (lastFwd == NULL) { + sep = lastBack; + } else { + sep = lastBack > lastFwd ? lastBack : lastFwd; + } size_t dirLen = sep != NULL ? (size_t) (sep - path) + 1 : 0; size_t nameLen = wcslen(newName); target = (WCHAR*) malloc((dirLen + nameLen + 1) * sizeof(WCHAR)); diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_video.cpp b/Ports/WindowsPort/nativeSources/cn1_windows_video.cpp index 8f8d0e49122..0fff03661e7 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_video.cpp +++ b/Ports/WindowsPort/nativeSources/cn1_windows_video.cpp @@ -315,17 +315,35 @@ static JAVA_OBJECT cn1ReaderReadAudio(CODENAME_ONE_THREAD_STATE, CN1VideoReader* } } if (audioReader != NULL) { - audioReader->SetStreamSelection((DWORD) MF_SOURCE_READER_ALL_STREAMS, FALSE); - audioReader->SetStreamSelection((DWORD) MF_SOURCE_READER_FIRST_AUDIO_STREAM, TRUE); - ComPtr pcmType; - if (SUCCEEDED(MFCreateMediaType(&pcmType))) { - pcmType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Audio); - pcmType->SetGUID(MF_MT_SUBTYPE, MFAudioFormat_PCM); - pcmType->SetUINT32(MF_MT_AUDIO_BITS_PER_SAMPLE, 16); - audioReader->SetCurrentMediaType((DWORD) MF_SOURCE_READER_FIRST_AUDIO_STREAM, NULL, pcmType.Get()); + /* Configure it completely or not at all. Every call here was previously + * unchecked, so a reader that opened but could not select its stream or + * negotiate PCM stayed non-null and was used anyway -- reading an + * unselected stream, or the source's COMPRESSED native format, while the + * bytes were handed back with the cached 16-bit PCM rate and channel + * count beside them. Silent garbage is worse than the seek path this + * replaced, so a partially configured reader is discarded and the shared + * reader is rewound instead. */ + bool configured = false; + if (SUCCEEDED(audioReader->SetStreamSelection((DWORD) MF_SOURCE_READER_ALL_STREAMS, FALSE)) + && SUCCEEDED(audioReader->SetStreamSelection( + (DWORD) MF_SOURCE_READER_FIRST_AUDIO_STREAM, TRUE))) { + ComPtr pcmType; + if (SUCCEEDED(MFCreateMediaType(&pcmType))) { + pcmType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Audio); + pcmType->SetGUID(MF_MT_SUBTYPE, MFAudioFormat_PCM); + pcmType->SetUINT32(MF_MT_AUDIO_BITS_PER_SAMPLE, 16); + configured = SUCCEEDED(audioReader->SetCurrentMediaType( + (DWORD) MF_SOURCE_READER_FIRST_AUDIO_STREAM, NULL, pcmType.Get())); + } + } + if (!configured) { + printf("CN1SS:INFO:winAudio fresh reader configuration failed; rewinding the shared reader\n"); + fflush(stdout); + audioReader.Reset(); } - } else { - /* No fresh reader: fall back to rewinding the shared one. */ + } + if (audioReader == NULL) { + /* No usable fresh reader: fall back to rewinding the shared one. */ PROPVARIANT pos; PropVariantInit(&pos); pos.vt = VT_I8; diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java index f54b3165461..a560599b6fd 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java @@ -2909,8 +2909,18 @@ private static void checkKeyFamily(String algorithm, String keyAlgorithm) { if (algorithm == null || keyAlgorithm == null) { return; } + // The label has to be one this runtime actually knows, not merely one + // whose prefix looks familiar. startsWith("EC") called "ECfoo" an EC key + // and every other string an RSA one, so PrivateKey.fromPkcs8("garbage", + // der) signed happily here while JavaSE and Android hand the same label + // to KeyFactory.getInstance and throw NoSuchAlgorithmException. Identical + // public API calls must not depend on the port. + String key = keyAlgorithm.toUpperCase(); + if (!"RSA".equals(key) && !"EC".equals(key)) { + throw new RuntimeException("Unknown key algorithm: " + keyAlgorithm); + } boolean wantsEc = algorithm.toUpperCase().indexOf("ECDSA") >= 0; - boolean keyIsEc = keyAlgorithm.toUpperCase().startsWith("EC"); + boolean keyIsEc = "EC".equals(key); if (wantsEc != keyIsEc) { throw new RuntimeException(algorithm + " cannot be used with a " + keyAlgorithm + " key"); diff --git a/docs/website/layouts/_default/port-status.html b/docs/website/layouts/_default/port-status.html index cf2ca5b3a08..e98ae000d65 100644 --- a/docs/website/layouts/_default/port-status.html +++ b/docs/website/layouts/_default/port-status.html @@ -168,7 +168,35 @@

Skipped-test errata

{{- with $report -}} {{- $result := index .tests $erratum.test -}} {{- with $result -}} - {{- if eq .status "skip" -}}{{- $skippedPortNames = $skippedPortNames | append $port.name -}}{{- end -}} + {{- if eq .status "skip" -}} + {{- /* The same predicate the feature cell uses, not the test name + alone. Listing every skip of this test made the erratum claim + ports it does not cover: a VideoIORoundTripTest encoder + failure on Linux was filed under an erratum that explains an + Apple simulator limitation, which both misdiagnoses the + failure and contradicts the cell beside it, where that skip + is correctly left partial as unexplained. */ -}} + {{- $codes := $erratum.reason_codes -}} + {{- $reasons := slice -}} + {{- with .reasons -}}{{- $reasons = . -}}{{- end -}} + {{- $documented := true -}} + {{- if $codes -}} + {{- $documented = gt (len $reasons) 0 -}} + {{- range $reasons -}} + {{- $reason := . -}} + {{- $ok := false -}} + {{- range $codes -}} + {{- $portAllowed := true -}} + {{- with .ports -}} + {{- $portAllowed = in . $port.id -}} + {{- end -}} + {{- if and $portAllowed (hasPrefix $reason .prefix) -}}{{- $ok = true -}}{{- end -}} + {{- end -}} + {{- if not $ok -}}{{- $documented = false -}}{{- end -}} + {{- end -}} + {{- end -}} + {{- if $documented -}}{{- $skippedPortNames = $skippedPortNames | append $port.name -}}{{- end -}} + {{- end -}} {{- end -}} {{- end -}} {{- end -}} diff --git a/docs/website/layouts/partials/port-status-port-state.html b/docs/website/layouts/partials/port-status-port-state.html index efce1323cdb..7250185b0d0 100644 --- a/docs/website/layouts/partials/port-status-port-state.html +++ b/docs/website/layouts/partials/port-status-port-state.html @@ -6,6 +6,7 @@ {{- $label := "No stored report" -}} {{- if $report -}} {{- $failed := int (default 0 $report.summary.fail) -}} + {{- $notRun := int (default 0 (index $report.summary "not-run")) -}} {{- $bootstrapComplete := and (eq $report.bootstrap_source "successful-master-workflow") (eq $report.workflow_conclusion "success") -}} {{- $complete := or $report.suite_finished $bootstrapComplete -}} {{- $generated := time.AsTime $report.generated_at -}} @@ -16,6 +17,14 @@ {{- if gt $failed 0 -}} {{- $state = "fail" -}} {{- $label = printf "%d failing test%s" $failed (cond (eq $failed 1) "" "s") -}} + {{- else if gt $notRun 0 -}} + {{- /* A test that never reported is not a pass. The strict producer gate + fails the workflow on not-run, so a report reaching here with zero + failures and a pile of unreported tests came from a RED run -- and + looking only at summary.fail rendered it as a green "Suite completed" + card while its feature cells sat incomplete. */ -}} + {{- $state = "partial" -}} + {{- $label = printf "%d test%s did not run" $notRun (cond (eq $notRun 1) "" "s") -}} {{- else if not $complete -}} {{- $state = "partial" -}} {{- $label = "Run incomplete" -}} diff --git a/scripts/hellocodenameone/conformance/backfill_port_status.sh b/scripts/hellocodenameone/conformance/backfill_port_status.sh index bf1f1df5c56..9a8e53ff343 100755 --- a/scripts/hellocodenameone/conformance/backfill_port_status.sh +++ b/scripts/hellocodenameone/conformance/backfill_port_status.sh @@ -181,19 +181,29 @@ while IFS= read -r workflow; do | jq -r --arg horizon "${horizon}" '[.[] | select((.event == "push" or .event == "schedule" or .event == "workflow_dispatch") and (.conclusion != null and .conclusion != "cancelled" and .conclusion != "skipped") and (.updatedAt >= $horizon))] - | sort_by(.updatedAt) | reverse | .[] | "\(.databaseId):\(.conclusion)"')" + | sort_by(.updatedAt) | reverse | .[] | "\(.databaseId):\(.event)"')" if [ -z "${candidates}" ]; then echo "No completed master run for ${workflow}; nothing to publish." >&2 continue fi run_id="" - # The newest producer candidate gets stricter treatment than the ones behind - # it. Which run that is cannot be read off the top of the list, because a run - # can be a dispatch that deliberately produced no port report at all -- see - # the evidence-only check below -- so it is assigned to the first candidate - # that actually attempted to produce one. + # The newest SCHEDULED (or push) run gets stricter treatment than the ones + # behind it: it must upload an artifact, and it must report every port its + # workflow owns. A workflow_dispatch is deliberately excluded from that role. + # Dispatches carry arbitrary inputs and routinely run a subset on purpose -- + # scripts-ios.yml with watch_only skips the GL, Metal and tv legs, and + # scripts-javascript.yml with port_status_browser_evidence skips its screenshot + # job entirely -- so treating whichever run happens to be newest as the + # authoritative producer failed the sweep for ports nobody asked to run. Their + # reports are still merged (and, being newest, still win), which is the manual + # recovery this sweep exists to pick up; only the strictness is scoped to the + # runs that are supposed to cover everything. newest_candidate="" + # Set once the strict candidate has been processed: coverage found at or + # before it counts as current, so a dispatch that repaired a port newer than + # the last scheduled run is not then reported as an omission of that run. + past_strict_candidate=0 download_dir="${tmp_dir}/${workflow}" mkdir -p "${download_dir}" # Merge across candidate runs rather than stopping at the first with any @@ -202,7 +212,10 @@ while IFS= read -r workflow; do owned="$(jq -r --arg workflow "${workflow}" '.ports[] | select(.workflow == $workflow) | .id' "${MANIFEST}")" for candidate_entry in ${candidates}; do candidate="${candidate_entry%%:*}" - candidate_conclusion="${candidate_entry##*:}" + candidate_event="${candidate_entry##*:}" + if [ -n "${newest_candidate}" ]; then + past_strict_candidate=1 + fi missing=0 for port in ${owned}; do if [ ! -f "${download_dir}/covered-${port}" ]; then @@ -219,29 +232,13 @@ while IFS= read -r workflow; do # freshness check passes, and the sweep goes green while the current run # produced no evidence. Only the newest is reported: older candidates # without artifacts are just how the merge walks back. - if [ -z "${newest_candidate}" ]; then + if [ -z "${newest_candidate}" ] && [ "${candidate_event}" != "workflow_dispatch" ]; then newest_candidate="${candidate}" unusable+=("${workflow}: newest run ${candidate} uploaded no port-status artifact") fi continue fi - # A run that succeeded, uploaded the browser-evidence environment sidecar - # and named none of the ports this workflow owns never tried to produce a - # port report: scripts-javascript.yml dispatched with - # port_status_browser_evidence skips its screenshot job by design and only - # calls the reusable evidence workflow. Treating it as the newest producer - # made the sweep fail every time -- the environment artifact names no port, - # and the JavaScript port then looked omitted -- for a run in which no - # producer was asked to run. A producer that genuinely died before - # normalization is not caught by this: its job failure fails the run, so the - # conclusion is not success and the strict checks still apply. - if [ "${candidate_conclusion}" = "success" ] \ - && [ -f "${download_dir}/run-${candidate}/port-status-environment/port-status-environment.json" ] \ - && [ -z "$(owned_reports_in "${download_dir}/run-${candidate}" "${owned}")" ]; then - echo "Skipping run ${candidate}: a browser-evidence dispatch that produces no ${workflow} port report." >&2 - continue - fi - if [ -z "${newest_candidate}" ]; then + if [ -z "${newest_candidate}" ] && [ "${candidate_event}" != "workflow_dispatch" ]; then newest_candidate="${candidate}" fi run_id="${candidate}" @@ -290,7 +287,7 @@ while IFS= read -r workflow; do --port "${found}" --report "${downloaded}" >/dev/null 2>&1 || accept_status=$? if [ "${accept_status}" -ne 0 ]; then echo "Ignoring the ${found} report from run ${candidate}: $(describe_accept_status "${accept_status}")." >&2 - if [ "${candidate}" = "${newest_candidate}" ] \ + if [ "${past_strict_candidate}" -eq 0 ] \ && [ "${accept_status}" -eq "${ACCEPT_CONTRACT_DRIFT}" ]; then # The newest run DID report this port; its report is simply built # against another revision of the contract, which is the one case that @@ -315,7 +312,10 @@ while IFS= read -r workflow; do fi cp "${downloaded}" "${download_dir}/port-status-${found}.json" : > "${download_dir}/covered-${found}" - if [ "${candidate}" = "${newest_candidate}" ]; then + # At or before the strict candidate: a dispatch newer than the last + # scheduled run counts as current coverage, so a manual repair is not + # reported as that run having omitted the port. + if [ "${past_strict_candidate}" -eq 0 ]; then : > "${download_dir}/newest-covered-${found}" fi # Remember which run this port's report actually came from. Reports are diff --git a/scripts/hellocodenameone/conformance/port_status.py b/scripts/hellocodenameone/conformance/port_status.py index fe21a77d385..02d8c4166ec 100755 --- a/scripts/hellocodenameone/conformance/port_status.py +++ b/scripts/hellocodenameone/conformance/port_status.py @@ -669,9 +669,13 @@ def publishable_report_problems( statuses = Counter() for test, result in tests.items(): - if not isinstance(result, dict) or result.get("status") not in { - "pass", "fail", "skip", "not-run" - }: + # isinstance before membership, not just membership. An unhashable + # status -- a producer writing a list or an object -- raises TypeError + # out of the `in` test, and main() catches only ContractError, so the + # gate died with a status the sweep does not read as unusable and fell + # back to an older report while finishing green. + if not isinstance(result, dict) or not isinstance(result.get("status"), str) \ + or result["status"] not in {"pass", "fail", "skip", "not-run"}: malformed.append(f"invalid result for {test}") continue # The reason list is optional, but when present the feature template @@ -695,7 +699,21 @@ def publishable_report_problems( # predates a test -- suppressing this whenever any drift was seen let a # genuinely malformed report be filed as mere drift and fall back quietly, # which is the outcome the loud/quiet split exists to avoid. - if report.get("summary") != expected_summary: + # Types before values. Python treats True as equal to 1, so a summary count + # serialized as a JSON boolean compared equal to a genuine count of one and + # sailed through the equality below -- Android's "skip": 1 becoming "skip": + # true is accepted, published, and rendered by Hugo as "true skipped". + summary = report.get("summary") + if not isinstance(summary, dict): + malformed.append("report has no summary") + elif any( + isinstance(summary.get(key), bool) + or not isinstance(summary.get(key), int) + or summary.get(key) < 0 + for key in ("pass", "fail", "skip", "not-run") + ): + malformed.append("summary counts are not non-negative integers") + elif summary != expected_summary: malformed.append("summary does not match the test results") expected_benchmarks = manifest.get("performance_benchmarks", []) @@ -712,7 +730,17 @@ def publishable_report_problems( # suite that actually finished; a partial section simply is not presentable # as performance. Structural defects below stay loud either way, because # those are producer bugs whatever the suite did. - suite_finished = bool(report.get("suite_finished")) + # An actual boolean, not anything truthy. bool() accepted the string "false" + # and the integer 1 as a completed suite, and Hugo reads the same value as + # truthy in port-status-port-state.html -- so a report with no failures but a + # pile of not-run tests rendered a green "Suite completed" card. + suite_finished_raw = report.get("suite_finished") + if not isinstance(suite_finished_raw, bool): + malformed.append( + f"suite_finished is {type(suite_finished_raw).__name__}, not a boolean" + ) + suite_finished_raw = False + suite_finished = suite_finished_raw # Validated before anything joins or iterates it. A producer emitting # "missing": true or a number raised TypeError out of the join below, and @@ -728,9 +756,17 @@ def publishable_report_problems( malformed.append("performance missing list is not an array of workload names") declared_missing = [] + # Validated for every report, not only finished ones. port-status.html does + # `eq .status "complete"`, and Hugo aborts the whole site build with an + # incompatible-types error when that compares a map against a string -- so a + # malformed status on an UNFINISHED report used to skip validation entirely + # (the workload keys still accounted for) and take the build down. + perf_status = performance.get("status") + if perf_status not in ("complete", "partial"): + malformed.append(f"performance status is {perf_status!r}") if suite_finished: - if performance.get("status") != "complete": - malformed.append(f"performance run is {performance.get('status')!r}") + if perf_status != "complete": + malformed.append(f"performance run is {perf_status!r}") if declared_missing: malformed.append( "performance workloads never reported: " + ", ".join(declared_missing) diff --git a/scripts/hellocodenameone/conformance/publish_port_status.sh b/scripts/hellocodenameone/conformance/publish_port_status.sh index 777a846335e..30e45ce6368 100755 --- a/scripts/hellocodenameone/conformance/publish_port_status.sh +++ b/scripts/hellocodenameone/conformance/publish_port_status.sh @@ -36,7 +36,16 @@ if [ "${accept_status}" -ne 0 ]; then *) reason="rejected by the publication gate (status ${accept_status})" ;; esac echo "Not publishing the ${port} report: ${reason}; preserving the last published one." - exit 0 + # Contract drift is the one case that waits quietly: a report built before a + # newly registered test is expected, and the next run resolves it. Anything + # else -- an unusable report, or a gate failure nobody anticipated -- is a + # producer defect, and exiting zero here made port-status-publish.yml go green + # and rebuild the site off the PREVIOUS report, so the defect stayed invisible + # until some later nightly sweep happened to notice it. + if [ "${accept_status}" -eq 11 ]; then + exit 0 + fi + exit "${accept_status}" fi for tool in gh jq python3; do diff --git a/scripts/hellocodenameone/conformance/test_port_status.py b/scripts/hellocodenameone/conformance/test_port_status.py index 12102e48f2d..92f4d75da4a 100755 --- a/scripts/hellocodenameone/conformance/test_port_status.py +++ b/scripts/hellocodenameone/conformance/test_port_status.py @@ -539,6 +539,62 @@ def test_publishable_accepts_a_well_formed_reason_list(self): self.manifest, "linux-x64", report )) + def test_publishable_requires_a_boolean_suite_completion_marker(self): + # bool() accepted "false" and 1 as a finished suite, and Hugo reads the + # same value as truthy -- so a report with no failures and a pile of + # not-run tests rendered a green "Suite completed" card. + for bad in ("false", "true", 1, 0, [], {}): + with self.subTest(bad=bad): + report = self.publishable_report("linux-x64") + report["suite_finished"] = bad + _, malformed = port_status.publishable_report_problems( + self.manifest, "linux-x64", report + ) + self.assertTrue( + any("suite_finished" in item for item in malformed), malformed) + + def test_publishable_rejects_an_unhashable_test_status(self): + # A list status raises TypeError out of the set-membership test, and + # main() catches only ContractError -- so the gate died with a status the + # sweep does not read as unusable and fell back quietly. + for bad in (["skip"], {"status": "skip"}, 3, None): + with self.subTest(bad=bad): + report = self.publishable_report("linux-x64") + report["tests"]["ClipboardRoundTripTest"]["status"] = bad + _, malformed = port_status.publishable_report_problems( + self.manifest, "linux-x64", report + ) + self.assertTrue( + any("invalid result" in item for item in malformed), malformed) + + def test_publishable_rejects_boolean_summary_counts(self): + # Python considers True == 1, so a count of exactly one serialized as a + # JSON boolean compared equal to the calculated summary and published; + # Hugo then rendered "true skipped". + report = self.publishable_report("linux-x64") + report["tests"]["ClipboardRoundTripTest"]["status"] = "skip" + report["summary"] = { + "pass": len(report["tests"]) - 1, "fail": 0, "skip": True, "not-run": 0 + } + _, malformed = port_status.publishable_report_problems( + self.manifest, "linux-x64", report + ) + self.assertTrue( + any("non-negative integers" in item for item in malformed), malformed) + + def test_publishable_rejects_a_malformed_performance_status_when_unfinished(self): + # port-status.html does `eq .status "complete"`, and Hugo aborts the whole + # site build when that compares a map with a string. The unfinished branch + # used to skip status validation entirely. + report = self.publishable_report("linux-x64") + report["suite_finished"] = False + report["performance"]["status"] = {"state": "partial"} + _, malformed = port_status.publishable_report_problems( + self.manifest, "linux-x64", report + ) + self.assertTrue( + any("performance status is" in item for item in malformed), malformed) + def test_publishable_matches_every_report_the_site_serves(self): for port in self.manifest["ports"]: report_path = port_status.REPO_ROOT / self.manifest["report_directory"] / ( diff --git a/vm/ByteCodeTranslator/src/cn1_win_compat.h b/vm/ByteCodeTranslator/src/cn1_win_compat.h index 8f77f92da75..b44ad66f600 100644 --- a/vm/ByteCodeTranslator/src/cn1_win_compat.h +++ b/vm/ByteCodeTranslator/src/cn1_win_compat.h @@ -169,8 +169,33 @@ static __inline int unsetenv(const char* name) { return _putenv_s(name, ""); } +/* Civil fields to a UTC epoch second, by arithmetic rather than through the + * CRT. _mkgmtime is documented to support only dates from 1970-01-01 and + * returns -1 for anything earlier, and the timezone runtime multiplies this + * result by 1000 without checking it -- so EVERY pre-epoch lookup collapsed to + * -1 and was then evaluated at 1969-12-31T23:59:59Z. A 1900 Europe/Paris + * request came back with 1969's rules. Howard Hinnant's days_from_civil is + * exact for the whole proleptic Gregorian range and has no such limit. + * Deliberately arithmetic-only: this header is included by translation units + * that may not pull in . */ static __inline time_t timegm(struct tm* tm) { - return _mkgmtime(tm); + long long year = (long long) tm->tm_year + 1900; + long long month = (long long) tm->tm_mon + 1; + long long day = (long long) tm->tm_mday; + long long era, yoe, doy, doe, days; + + /* March-based years, so a leap day lands at the end of the cycle. */ + year -= (month <= 2) ? 1 : 0; + era = (year >= 0 ? year : year - 399) / 400; + yoe = year - era * 400; /* [0, 399] */ + doy = (153 * (month + (month > 2 ? -3 : 9)) + 2) / 5 + day - 1; + doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; /* [0, 146096] */ + days = era * 146097 + doe - 719468; /* since epoch */ + + return (time_t) (days * 86400LL + + (long long) tm->tm_hour * 3600LL + + (long long) tm->tm_min * 60LL + + (long long) tm->tm_sec); } static __inline struct tm* localtime_r(const time_t* timep, struct tm* result) { From 86cfcb5d141c55db42faced55c8eaf26e536fc4a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:46:00 +0700 Subject: [PATCH 88/91] Chunk long Windows waits, and pin the lowercase custom-zone contract Two findings that were filed in review BODIES rather than as threads, which is why the thread query never surfaced them. pthread_cond_timedwait narrowed a 64-bit millisecond deadline straight to a 32-bit DWORD. Object.wait(Long.MAX_VALUE) -- the ordinary park-until-notified idiom -- yields a wait of roughly 292 million years, so the truncation produced an arbitrary short timeout, and a value whose low word happened to be 0xFFFFFFFF became Win32's INFINITE and would never expire. It now waits in bounded ~24.8 day chunks and reports ETIMEDOUT only once the absolute deadline has actually passed. A spurious wake still returns success, which is correct: every caller re-tests its predicate. The other finding -- that customTimeZone() is inconsistent for mixing a case-INSENSITIVE regionMatches prefix test with a case-SENSITIVE startsWith("GMT") acceptance test -- is not a defect, and I checked before concluding that rather than after. Run against a real JDK 25: GMT+05:00 -> id=GMT+05:00 raw=18000000 gmt+05:00 -> id=GMT raw=0 Gmt+05:00 -> id=GMT raw=0 gmt+5 -> id=GMT raw=0 utc+05:00 -> id=GMT raw=0 gmt-08:00 -> id=GMT raw=0 The JDK's parseCustomTimeZone tests `id.indexOf("GMT") != 0`, which is case-sensitive, and the zone-name lookup ahead of it is case-sensitive too, so every lowercase spelling answers plain GMT with a zero offset. The port already matches that exactly. The two prefix tests answer different questions on purpose: the insensitive one decides whether an id is an offset ATTEMPT (so it falls back to GMT instead of to the platform database, where POSIX would invert the sign), the sensitive one decides whether to HONOUR it. Rather than leave that resting on an argument in a review thread, the seven spellings are added to CustomTimeZoneApp, which compares ids and offsets against the host JDK through the ParparVM clean target. CustomTimeZoneIdTest passes with tests=1 failures=0 skipped=0. Co-Authored-By: Claude Opus 5 (1M context) --- vm/ByteCodeTranslator/src/cn1_win_compat.c | 40 ++++++++++++++----- .../tools/translator/CustomTimeZoneApp.java | 13 +++++- 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/vm/ByteCodeTranslator/src/cn1_win_compat.c b/vm/ByteCodeTranslator/src/cn1_win_compat.c index eefef097452..1881d46826a 100644 --- a/vm/ByteCodeTranslator/src/cn1_win_compat.c +++ b/vm/ByteCodeTranslator/src/cn1_win_compat.c @@ -39,6 +39,11 @@ #include "cn1_win_compat.h" +/* Longest single SleepConditionVariableSRW timeout, ~24.8 days. Comfortably + below INFINITE (0xFFFFFFFF), so a long wait is never mistaken for one that + must not expire. */ +#define CN1_WIN_MAX_WAIT_CHUNK_MS 0x7FFFFFFFu + /* The mirror structs in the header must match the real Win32 types exactly. */ _Static_assert(sizeof(cn1_srwlock_t) == sizeof(SRWLOCK), "SRWLOCK layout mismatch"); _Static_assert(sizeof(cn1_condvar_t) == sizeof(CONDITION_VARIABLE), "CONDITION_VARIABLE layout mismatch"); @@ -98,21 +103,38 @@ int pthread_cond_wait(pthread_cond_t* cond, pthread_mutex_t* mutex) { int pthread_cond_timedwait(pthread_cond_t* cond, pthread_mutex_t* mutex, const struct timespec* abstime) { /* pthread passes an absolute CLOCK_REALTIME deadline; Win32 wants a relative millisecond timeout, so convert against the current time. */ + /* The deadline is 64-bit milliseconds and the Win32 timeout is a 32-bit + DWORD, so it cannot simply be narrowed. Object.wait(Long.MAX_VALUE) -- the + usual "park until notified" idiom -- produces a wait of ~292 million + years; truncating that to 32 bits yields an arbitrary short timeout, and a + value whose low word happens to be 0xFFFFFFFF becomes Win32's INFINITE + sentinel, so the wait either returns early or never expires at all. Wait + in bounded chunks instead and only report a timeout once the ABSOLUTE + deadline has genuinely passed. */ struct timeval now; long long now_ms, abs_ms, wait_ms; - gettimeofday(&now, NULL); - now_ms = (long long)now.tv_sec * 1000 + now.tv_usec / 1000; abs_ms = (long long)abstime->tv_sec * 1000 + abstime->tv_nsec / 1000000; - wait_ms = abs_ms - now_ms; - if (wait_ms < 0) { - wait_ms = 0; - } - if (!SleepConditionVariableSRW((PCONDITION_VARIABLE)&cond->cond, (PSRWLOCK)&mutex->lock, (DWORD)wait_ms, 0)) { - if (GetLastError() == ERROR_TIMEOUT) { + for (;;) { + DWORD chunk; + gettimeofday(&now, NULL); + now_ms = (long long)now.tv_sec * 1000 + now.tv_usec / 1000; + wait_ms = abs_ms - now_ms; + if (wait_ms <= 0) { return ETIMEDOUT; } + chunk = wait_ms > (long long)CN1_WIN_MAX_WAIT_CHUNK_MS + ? CN1_WIN_MAX_WAIT_CHUNK_MS : (DWORD)wait_ms; + if (SleepConditionVariableSRW((PCONDITION_VARIABLE)&cond->cond, + (PSRWLOCK)&mutex->lock, chunk, 0)) { + /* Signalled. A spurious wake reports success too, which is correct: + every caller re-tests its predicate. */ + return 0; + } + if (GetLastError() != ERROR_TIMEOUT) { + return 0; + } + /* A chunk expired. Loop to find out whether the real deadline did. */ } - return 0; } int pthread_cond_signal(pthread_cond_t* cond) { diff --git a/vm/tests/src/test/resources/com/codename1/tools/translator/CustomTimeZoneApp.java b/vm/tests/src/test/resources/com/codename1/tools/translator/CustomTimeZoneApp.java index cf82f99a167..4607196419c 100644 --- a/vm/tests/src/test/resources/com/codename1/tools/translator/CustomTimeZoneApp.java +++ b/vm/tests/src/test/resources/com/codename1/tools/translator/CustomTimeZoneApp.java @@ -50,7 +50,18 @@ public class CustomTimeZoneApp { "GMTZ", "UTCZ", "UTZ", // Bare prefixes: only exact uppercase UTC names a zone of its own; the // rest take the unknown-id fallback and answer GMT. - "UTC", "UT", "utc", "ut", "gmt", "Utc" + "UTC", "UT", "utc", "ut", "gmt", "Utc", + // An OFFSET suffix is accepted only after exact uppercase "GMT". The + // JDK's parseCustomTimeZone tests `id.indexOf("GMT") != 0`, which is + // case-sensitive, and the zone-name lookup ahead of it is case-sensitive + // too -- so every spelling below answers plain GMT with a zero offset, + // NOT the offset it appears to name. Reviewed as an inconsistency + // (the port tests the prefix case-insensitively when deciding whether an + // id is an offset attempt at all, and case-sensitively when deciding + // whether to honour it), so it is pinned here against the real JDK + // rather than left to argument. + "gmt+05:00", "Gmt+05:00", "gMt+05:00", "gmt+5", "gmt-08:00", + "utc+05:00", "ut+5" }; public static void main(String[] args) { From 7ee0e19642635abbfd80e9ab807f350cfe7d6c74 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:56:03 +0700 Subject: [PATCH 89/91] Stop PR jobs building a deploy action they never run build-test (17) failed with "failed to resolve source metadata for docker.io/library/alpine:latest" after three retries. That is a Docker Hub outage, but the job had no business talking to Docker Hub at all: the only thing that needed it was the "Copying Files to Server" step, which is gated on `github.event_name == 'push' && github.ref == 'refs/heads/master' && matrix.java-version == 8` and therefore could never run on a pull request, let alone on the Java 17 leg. A Docker-based action is BUILT during job setup, before any step's `if` is evaluated. So every leg of every PR run was pulling alpine:latest to build an action none of them would execute, and any Docker Hub wobble failed the build for a deploy that was not happening. Retrying the job would have papered over an entirely avoidable dependency. The deploy moves to its own job with the condition at JOB level, which skips setup outright, so a pull request never fetches the image. build-test hands the bundle over as an artifact, uploaded only on the master pushes that actually deploy, so PR runs do not pay for that either. Verified actions/download-artifact@v7 exists and pairs with the actions/upload-artifact@v7 already used here, and that this was the only non-actions/ action in the workflow. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/pr.yml | 40 ++++++++++++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 955a23b06ae..8c1e016d691 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -598,14 +598,14 @@ jobs: if: matrix.java-version == 8 run: zip -j result.zip CodenameOne/javadocs.zip CodenameOne/dist/CodenameOne.jar CodenameOne/updatedLibs.zip Ports/JavaSE/dist/JavaSE.jar build/CodenameOneDist/CodenameOne/demos/CodenameOne_SRC.zip - - name: Copying Files to Server + # Handed to a separate job rather than deployed here. See deploy-dist. + - name: Upload the distribution bundle if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && matrix.java-version == 8 }} - uses: marcodallasanta/ssh-scp-deploy@v1.0.5 + uses: actions/upload-artifact@v7 with: - host: ${{ secrets.WP_HOST }} - user: ${{ secrets.WP_USER }} - password: ${{ secrets.WP_PASSWORD }} - local: result.zip + name: result.zip + path: result.zip + retention-days: 1 - name: Upload a Build Artifact if: matrix.java-version == 8 @@ -614,3 +614,31 @@ jobs: name: JavaSE.jar path: Ports/JavaSE/dist/JavaSE.jar + + deploy-dist: + # Gated at the JOB level, deliberately, and this is not a style preference. + # A Docker-based action is BUILT during job setup, before any step's `if` is + # evaluated -- so while this deploy step was inside build-test it made every + # matrix leg, including the Java 17 and 21 legs that could never satisfy its + # condition, pull alpine:latest from Docker Hub to build an action they were + # never going to run. A Docker Hub timeout then failed a pull-request job for + # a deploy that was not happening: exactly what killed build-test (17) with + # "failed to resolve source metadata for docker.io/library/alpine:latest" + # after three retries. A job-level condition skips setup entirely, so on a + # pull request the image is never fetched. + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }} + needs: build-test + runs-on: ubuntu-latest + steps: + - name: Download the distribution bundle + uses: actions/download-artifact@v7 + with: + name: result.zip + + - name: Copying Files to Server + uses: marcodallasanta/ssh-scp-deploy@v1.0.5 + with: + host: ${{ secrets.WP_HOST }} + user: ${{ secrets.WP_USER }} + password: ${{ secrets.WP_PASSWORD }} + local: result.zip From c724f5a7b56ba7fa2d830aaadcfbed29dcaa95cd Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:14:01 +0700 Subject: [PATCH 90/91] Stop a wrong-key RSA verify being reported as a broken verifier CryptoApiTest failed on Windows with "verify failed: signature verification failed to run (status 0xc000000d)" -- STATUS_INVALID_PARAMETER. It passed on the nine Windows runs before it, so this is intermittent rather than new, and checking that mattered: my only crypto change in the range was checkKeyFamily, which throws a Java RuntimeException naming the label and cannot produce a native NTSTATUS. The port history is what ruled it out, not the plausibility of the diff. Of the four verify calls in that test, three are deterministic: two use the right key, and the tampered-data case decrypts with the CORRECT key and recovers the original DigestInfo, so it always mismatches cleanly. The odd one out is "JWT RS256 rejects signature from wrong key", where CNG decrypts the signature under a different modulus and gets essentially random bytes. Whether the PKCS#1 v1.5 parse of that garbage comes back INVALID_SIGNATURE or INVALID_PARAMETER depends on the garbage -- which is exactly the shape of a failure that survives nine runs and appears on the tenth. The rule "anything except STATUS_INVALID_SIGNATURE means verification never ran" was too strong. By the time BCryptVerifySignature is called the key has imported, the digest algorithm is supported, the key family matches the algorithm and the digest has computed -- so the only input left for CNG to object to is the signature's CONTENT, and that is a rejected signature, not a broken runtime. INVALID_PARAMETER now joins INVALID_SIGNATURE on the "false" side; invalid handles and unsupported algorithms still raise. To keep that from swallowing a genuine configuration fault, the one remaining way to reach INVALID_PARAMETER with valid configuration is closed first: an RSA signature whose length is not exactly the modulus width is now rejected up front. It cannot be a signature for that key, every other port answers false for it, and it is bad input to verify() rather than a broken verifier. This also brings Windows into line with Linux, which gets the same semantics for free: EVP_DigestVerify returns 0 -- rejected, not an error -- when the padding of a wrong-key verify fails to parse, and only negative values raise there. Co-Authored-By: Claude Opus 5 (1M context) --- .../nativeSources/cn1_windows_crypto.c | 45 ++++++++++++++++--- 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c index 5e8767d2fb5..9b39aa83524 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c +++ b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c @@ -1107,6 +1107,22 @@ JAVA_BOOLEAN com_codename1_impl_windows_WindowsNative_verifyData___java_lang_Str toVerifyLength = (ULONG) (half * 2); } } + /* An RSA signature that is not exactly the modulus width cannot be a + * signature for this key at all. CNG answers STATUS_INVALID_PARAMETER + * for it, which the classification below would report as a + * configuration fault -- but a caller-supplied signature of the wrong + * size is bad input to verify(), not a broken runtime, and every other + * port answers false for it. */ + if (usable && !isEc) { + DWORD expected = 0; + ULONG copied = 0; + if (BCryptGetProperty(key, BCRYPT_SIGNATURE_LENGTH, (PUCHAR) &expected, + sizeof(expected), &copied, 0) == STATUS_SUCCESS + && expected != 0 && toVerifyLength != expected) { + BCryptDestroyKey(key); + return JAVA_FALSE; + } + } if (!usable) { cn1CryptoFail("could not read the signature for verification", 0); } else { @@ -1116,13 +1132,28 @@ JAVA_BOOLEAN com_codename1_impl_windows_WindowsNative_verifyData___java_lang_Str isEc ? 0 : BCRYPT_PAD_PKCS1); if (verifyStatus == STATUS_SUCCESS) { result = JAVA_TRUE; - } else if (verifyStatus != STATUS_INVALID_SIGNATURE) { - /* Only STATUS_INVALID_SIGNATURE means "this signature is bad". - * An invalid handle, parameter or unsupported algorithm means - * verification never ran, and answering a bare false for those - * reported tampering where the real fault was configuration -- - * the same conflation the Linux port had. Recording the status - * is what makes cryptoVerify raise a CryptoException instead. */ + } else if (verifyStatus != STATUS_INVALID_SIGNATURE + && verifyStatus != STATUS_INVALID_PARAMETER) { + /* STATUS_INVALID_SIGNATURE means "this signature is bad". An + * invalid handle or unsupported algorithm means verification + * never ran, and answering a bare false for those reported + * tampering where the real fault was configuration -- the same + * conflation the Linux port had. Recording the status is what + * makes cryptoVerify raise a CryptoException instead. + * + * STATUS_INVALID_PARAMETER is deliberately on the "bad + * signature" side. By this point the key imported, the digest + * algorithm is supported, the key family matches the algorithm, + * the digest computed and (for RSA) the signature is exactly + * the modulus width -- every configuration input has been + * checked, so the only thing left that CNG can object to is the + * signature's CONTENT. Verifying with the wrong key decrypts to + * essentially random bytes, and whether the PKCS#1 v1.5 parse of + * that garbage comes back INVALID_SIGNATURE or INVALID_PARAMETER + * depends on the garbage: CryptoApiTest's "rejects signature + * from wrong key" case passed nine runs and then raised + * "verification failed to run (status 0xc000000d)" on the tenth. + * Rejecting the signature is the correct answer for both. */ cn1CryptoFail("signature verification failed to run", verifyStatus); } } From 5ed6c4af3506ecd01791148bdaaec499428b47cf Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:30:25 +0700 Subject: [PATCH 91/91] Fix the four new review findings, including a wait bug my last fix missed Object.wait(Long.MAX_VALUE) still returned immediately on Windows. Chunking the timeout inside pthread_cond_timedwait was the wrong end of the problem: the deadline handed to it was ALREADY wrong, because java_lang_Object_wait___long_int built it with ts.tv_sec = tv.tv_sec + (long)(timeout / 1000); and `long` is 32 bits on Windows (LLP64). Long.MAX_VALUE / 1000 is 9223372036854775, which truncates to -1511828489 -- a deadline about 48 years in the PAST -- so the park-until-notified idiom never blocked. LP64 platforms were unaffected, which is why it only ever showed on Windows. The arithmetic is now 64-bit end to end, the seconds addend is capped so time_t cannot overflow, and the nanosecond normalization is done in 64-bit too: tv_usec * 1000 plus the sub-second timeout plus nanos can exceed 2e9, which neither fits a 32-bit tv_nsec nor normalizes with a single subtraction. The silent-timeout retry is gated on a new BaseTest.isRetrySafe(). Dropping the shouldTakeScreenshot() condition without putting anything in its place made the retry reachable for tests whose runTest() starts a worker and returns: resetForRetry() clears the shared completion state, so a late done() from the first attempt's thread would complete the SECOND attempt and advance the suite early. Screenshot-taking was never the property that made a retry safe -- having no work in flight is -- and VideoIODecodedFramesScreenshotTest both captures a screenshot and starts a thread, so the old gate did not establish it either. Six tests start work that outlives runTest(), found with a grep for `new Thread(` with `.start()` plus `startThread(`, and all six now declare themselves unsafe. CN.callSerially work does not count: finalizeTest runs on the EDT, so anything an earlier attempt queued has already drained. getAppHomePath() gives each application its own directory again. storageDir() names the Codename One directory shared by every CN1 app under the account, so two applications had the same app home and could read and overwrite each other's files. The base implementation appends getProperty("AppName", packageName) for exactly this reason -- the reason these overrides exist is that the base builds it on an unwritable filesystem root, not that the per-app component was wrong. The name is sanitized for the filesystem and never degrades to the literal "null", which is what produced "/null/" originally. The Linux private-key import is PKCS#8 only. d2i_AutoPrivateKey also accepts bare PKCS#1 and SEC1 keys, but the entry point is PrivateKey.fromPkcs8(), JavaSE and Android feed it to PKCS8EncodedKeySpec and Windows imports NCRYPT_PKCS8_PRIVATE_KEY_BLOB -- so those encodings loaded on Linux and nowhere else. That is a portability trap, not a kindness. Also adds the missing copyright header to the five test files that needed one. Verified with real exit codes rather than a status from the tail of a pipe: both desktop ports build, the conformance suite compiles under JDK 17, cn1_win_compat.c cross-compiles, and the copyright gate passes. Co-Authored-By: Claude Opus 5 (1M context) --- .../nativeSources/cn1_linux_crypto.c | 23 +++++----- .../impl/linux/LinuxImplementation.java | 42 ++++++++++++++++++- .../impl/windows/WindowsImplementation.java | 42 ++++++++++++++++++- .../tests/BackgroundThreadUiAccessTest.java | 31 ++++++++++++++ .../hellocodenameone/tests/BaseTest.java | 21 ++++++++++ .../tests/BridgeBulkTransferGuardTest.java | 31 ++++++++++++++ .../BytecodeTranslatorRegressionTest.java | 31 ++++++++++++++ .../tests/Cn1ssDeviceRunner.java | 13 +++++- .../tests/MotionSensorDeviceTest.java | 31 ++++++++++++++ .../VideoIODecodedFramesScreenshotTest.java | 31 ++++++++++++++ .../tests/VideoIORoundTripTest.java | 9 ++++ vm/ByteCodeTranslator/src/nativeMethods.m | 32 +++++++++++--- 12 files changed, 318 insertions(+), 19 deletions(-) diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c b/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c index e7bc50c183d..91dfee77aab 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c @@ -309,17 +309,20 @@ static EVP_PKEY* cn1PrivateKey(const unsigned char* der, int length) { PKCS8_PRIV_KEY_INFO_free(info); } if (key == 0) { - /* Tolerate a bare PKCS#1/SEC1 key as well; some callers keep those. - * The PKCS#8 attempt above queued its failure on this thread's OpenSSL - * error queue. For a valid PKCS#1/SEC1 key that failure is expected and - * the fallback succeeds, but the stale entry stays queued and the next - * unrelated operation to read the queue reports it -- so drop it before - * trying again. */ + /* PKCS#8 only, deliberately. This used to fall back to + * d2i_AutoPrivateKey, which also accepts bare PKCS#1 RSA and SEC1 EC + * keys -- but the public entry point is PrivateKey.fromPkcs8(), JavaSE + * and Android feed it to PKCS8EncodedKeySpec and Windows imports it as + * NCRYPT_PKCS8_PRIVATE_KEY_BLOB, so all of those reject those + * encodings. Accepting them here meant the identical key loaded on + * Linux and nowhere else, which is a portability trap rather than a + * kindness: code written against this port would fail on every device + * it shipped to. + * + * The failed attempt above queued an entry on this thread's OpenSSL + * error queue; clear it so the next unrelated operation to read the + * queue does not report it. */ ERR_clear_error(); - cursor = der; - key = d2i_AutoPrivateKey(0, &cursor, (long) length); - } - if (key == 0) { cn1CryptoFail("private key is not PKCS#8 DER"); return 0; } diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java index 258b4dd45cc..63bbf601b5f 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java @@ -2675,6 +2675,41 @@ public String[] listFilesystemRoots() { * directory joined to itself -- every write through that class landed on a * path that could not exist. */ + /// A stable, filesystem-safe directory name for THIS application. + /// + /// storageDir() names the Codename One directory shared by every CN1 app + /// under this user account, so returning it as the app home gave two + /// applications the same getAppHomePath(): each could read and overwrite + /// the other's files. The base implementation appends + /// getProperty("AppName", packageName) for exactly this reason; the reason + /// this override exists at all is that the base builds it on an unwritable + /// filesystem root, not that the per-app component was wrong. + /// + /// Never returns the literal "null": an unset AppName and packageName is + /// what made the base implementation produce "/null/" in the first place. + private String appHomeDirName() { + String name = getProperty("AppName", null); + if (name == null || name.length() == 0) { + name = getPackageName(); + } + if (name == null || name.length() == 0 || "null".equals(name)) { + return "CN1App"; + } + StringBuilder safe = new StringBuilder(name.length()); + for (int i = 0; i < name.length(); i++) { + char c = name.charAt(i); + // Reserved on Windows and awkward everywhere else; NTFS and ext4 + // both accept the rest of the printable range. + if (c < ' ' || c == '\\' || c == '/' || c == ':' || c == '*' || c == '?' + || c == '"' || c == '<' || c == '>' || c == '|') { + safe.append('_'); + } else { + safe.append(c); + } + } + return safe.toString(); + } + @Override public String getAppHomePath() { String dir = LinuxNative.storageDir(); @@ -2684,7 +2719,12 @@ public String getAppHomePath() { if (!dir.endsWith("/")) { dir += "/"; } - return "file://" + dir; + dir += appHomeDirName() + "/"; + String home = "file://" + dir; + if (!exists(home)) { + mkdir(home); + } + return home; } @Override diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java index a560599b6fd..3f221ea8861 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java @@ -2691,6 +2691,41 @@ public String[] listFilesystemRoots() { * {@code new File(fs.getAppHomePath() + "x")} resolve to the home * directory joined to itself. */ + /// A stable, filesystem-safe directory name for THIS application. + /// + /// storageDir() names the Codename One directory shared by every CN1 app + /// under this user account, so returning it as the app home gave two + /// applications the same getAppHomePath(): each could read and overwrite + /// the other's files. The base implementation appends + /// getProperty("AppName", packageName) for exactly this reason; the reason + /// this override exists at all is that the base builds it on an unwritable + /// filesystem root, not that the per-app component was wrong. + /// + /// Never returns the literal "null": an unset AppName and packageName is + /// what made the base implementation produce "/null/" in the first place. + private String appHomeDirName() { + String name = getProperty("AppName", null); + if (name == null || name.length() == 0) { + name = getPackageName(); + } + if (name == null || name.length() == 0 || "null".equals(name)) { + return "CN1App"; + } + StringBuilder safe = new StringBuilder(name.length()); + for (int i = 0; i < name.length(); i++) { + char c = name.charAt(i); + // Reserved on Windows and awkward everywhere else; NTFS and ext4 + // both accept the rest of the printable range. + if (c < ' ' || c == '\\' || c == '/' || c == ':' || c == '*' || c == '?' + || c == '"' || c == '<' || c == '>' || c == '|') { + safe.append('_'); + } else { + safe.append(c); + } + } + return safe.toString(); + } + @Override public String getAppHomePath() { String dir = WindowsNative.storageDir(); @@ -2700,10 +2735,15 @@ public String getAppHomePath() { if (!dir.endsWith("\\") && !dir.endsWith("/")) { dir += getFileSystemSeparator(); } + dir += appHomeDirName() + getFileSystemSeparator(); // com.codename1.io.File splits paths on '/' only, so a URL carrying // backslashes would report the whole native path as a file's name and // "file:/" as its parent. Native I/O accepts either separator. - return "file://" + dir.replace('\\', '/'); + String home = "file://" + dir.replace('\\', '/'); + if (!exists(home)) { + mkdir(home); + } + return home; } @Override diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BackgroundThreadUiAccessTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BackgroundThreadUiAccessTest.java index 0e699f22118..cf1bce49cef 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BackgroundThreadUiAccessTest.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BackgroundThreadUiAccessTest.java @@ -1,9 +1,40 @@ +/* + * Copyright (c) 2026, 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.codenameone.examples.hellocodenameone.tests; import com.codename1.ui.Display; import com.codename1.ui.plaf.UIManager; public class BackgroundThreadUiAccessTest extends BaseTest { + + /// Not safe for the runner's silent-timeout retry: it drives UI access from a background thread it starts itself, + /// and that worker outlives runTest(). A retry resets the shared + /// completion state, so a late done() from the first attempt's worker + /// would complete the second attempt and advance the suite early. + @Override + public boolean isRetrySafe() { + return false; + } @Override public boolean runTest() { Thread worker = new Thread(() -> { diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BaseTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BaseTest.java index c7d5e935f7d..960ec0ea5b5 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BaseTest.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BaseTest.java @@ -58,6 +58,27 @@ public boolean shouldTakeScreenshot() { return true; } + /// Whether the runner's one-shot silent-timeout retry may re-run this test. + /// + /// Override to false in any test that starts work which OUTLIVES runTest() + /// -- a `new Thread(...).start()` or a `Display.startThread(...)`. The + /// retry calls resetForRetry() and runs the test again on the same + /// instance, so a late done() from the first attempt's worker would + /// complete the SECOND attempt, advance the suite before it had really + /// finished, and let that worker's side effects bleed into later tests -- + /// masking exactly the timeout the retry was meant to survive. + /// + /// CN.callSerially work does not count: finalizeTest runs on the EDT, so + /// anything queued by an earlier attempt has already been drained by the + /// time a retry is decided. + /// + /// Find the tests that must override this with: + /// grep -lE 'new Thread[[:space:]]*\(' *Test.java (that also call .start()) + /// grep -l 'startThread[[:space:]]*(' *Test.java + public boolean isRetrySafe() { + return true; + } + public synchronized void fail(String message) { this.failed = true; this.failMessage = message; diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BridgeBulkTransferGuardTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BridgeBulkTransferGuardTest.java index c553fa11d4e..2feaaa9b7ef 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BridgeBulkTransferGuardTest.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BridgeBulkTransferGuardTest.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2026, 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.codenameone.examples.hellocodenameone.tests; import com.codename1.io.Storage; @@ -26,6 +48,15 @@ /// the test passes trivially. public class BridgeBulkTransferGuardTest extends BaseTest { + /// Not safe for the runner's silent-timeout retry: it starts a worker thread to exercise the bridge, + /// and that worker outlives runTest(). A retry resets the shared + /// completion state, so a late done() from the first attempt's worker + /// would complete the second attempt and advance the suite early. + @Override + public boolean isRetrySafe() { + return false; + } + @Override public boolean runTest() { new Thread(() -> { diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BytecodeTranslatorRegressionTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BytecodeTranslatorRegressionTest.java index d4b75f64604..f291af07915 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BytecodeTranslatorRegressionTest.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BytecodeTranslatorRegressionTest.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2026, 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.codenameone.examples.hellocodenameone.tests; import com.codename1.ui.CN; @@ -10,6 +32,15 @@ import java.util.ArrayList; public class BytecodeTranslatorRegressionTest extends BaseTest { + + /// Not safe for the runner's silent-timeout retry: it runs its regression cases on a worker thread, + /// and that worker outlives runTest(). A retry resets the shared + /// completion state, so a late done() from the first attempt's worker + /// would complete the second attempt and advance the suite early. + @Override + public boolean isRetrySafe() { + return false; + } private interface Sketchable { } diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java index f42258c7dfa..f9a0a009219 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java @@ -680,11 +680,22 @@ private void finalizeTest(int index, BaseTest testClass, String testName, boolea /// screenshot test shrugs off failed them outright, on a different test each /// run. A test that is genuinely broken still fails -- it times out the second /// time too, and the retry is one-shot per index. + /// + /// It IS conditioned on isRetrySafe(). Dropping the screenshot gate without + /// putting anything in its place made the retry reachable for tests whose + /// runTest() starts a worker and returns: resetForRetry() clears the shared + /// completion state, so a late done() from the first attempt's thread would + /// complete the second attempt, advance the suite before it had finished and + /// let that worker's side effects bleed into later tests. Screenshot-taking + /// was never the property that made a retry safe -- having no work in flight + /// is -- and VideoIODecodedFramesScreenshotTest takes a screenshot AND starts + /// a thread, so the old gate did not establish it either. private boolean shouldRetryAfterSilentTimeout(int index, BaseTest testClass) { return retriedTestIndex != index && !"HTML5".equals(Display.getInstance().getPlatformName()) && !testClass.isFailed() - && !testClass.isCaptureStarted(); + && !testClass.isCaptureStarted() + && testClass.isRetrySafe(); } private boolean shouldRetryAfterTransportFailure(int index, BaseTest testClass) { diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/MotionSensorDeviceTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/MotionSensorDeviceTest.java index b60053f03ea..f02ac6d1171 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/MotionSensorDeviceTest.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/MotionSensorDeviceTest.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2026, 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.codenameone.examples.hellocodenameone.tests; import com.codename1.sensors.MotionEvent; @@ -25,6 +47,15 @@ */ public class MotionSensorDeviceTest extends BaseTest { + /// Not safe for the runner's silent-timeout retry: it polls the sensors on a Display.startThread worker, + /// and that worker outlives runTest(). A retry resets the shared + /// completion state, so a late done() from the first attempt's worker + /// would complete the second attempt and advance the suite early. + @Override + public boolean isRetrySafe() { + return false; + } + @Override public boolean shouldTakeScreenshot() { return false; diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/VideoIODecodedFramesScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/VideoIODecodedFramesScreenshotTest.java index ade79fdc5a7..f803a7788aa 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/VideoIODecodedFramesScreenshotTest.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/VideoIODecodedFramesScreenshotTest.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2026, 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.codenameone.examples.hellocodenameone.tests; import com.codename1.io.FileSystemStorage; @@ -50,6 +72,15 @@ * {@link VideoIORoundTripTest} (frame order, brightness ramp, PCM verification).

*/ public class VideoIODecodedFramesScreenshotTest extends AbstractAnimationScreenshotTest { + + /// Not safe for the runner's silent-timeout retry: it decodes frames on a worker thread, + /// and that worker outlives runTest(). A retry resets the shared + /// completion state, so a late done() from the first attempt's worker + /// would complete the second attempt and advance the suite early. + @Override + public boolean isRetrySafe() { + return false; + } private static final int FRAMES = 6; // Encode resolution: large enough that the rendered digit survives H.264. private static final int VW = 192; diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/VideoIORoundTripTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/VideoIORoundTripTest.java index 65c999b1b12..422c70daf3b 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/VideoIORoundTripTest.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/VideoIORoundTripTest.java @@ -58,6 +58,15 @@ * This is an assertion test, not a screenshot test, so it does not affect baselines. */ public class VideoIORoundTripTest extends BaseTest { + + /// Not safe for the runner's silent-timeout retry: it encodes and decodes on a worker thread, + /// and that worker outlives runTest(). A retry resets the shared + /// completion state, so a late done() from the first attempt's worker + /// would complete the second attempt and advance the suite early. + @Override + public boolean isRetrySafe() { + return false; + } private static final int W = 128; private static final int H = 96; private static final int FRAMES = 6; diff --git a/vm/ByteCodeTranslator/src/nativeMethods.m b/vm/ByteCodeTranslator/src/nativeMethods.m index fbdb388583c..3dd845aadd9 100644 --- a/vm/ByteCodeTranslator/src/nativeMethods.m +++ b/vm/ByteCodeTranslator/src/nativeMethods.m @@ -2031,12 +2031,32 @@ JAVA_VOID java_lang_Object_wait___long_int(CODENAME_ONE_THREAD_STATE, JAVA_OBJEC struct timeval tv; gettimeofday(&tv, NULL); struct timespec ts; - ts.tv_sec = tv.tv_sec + (long)(timeout / 1000); - ts.tv_nsec = tv.tv_usec * 1000 + (timeout % 1000) * 1000000 + nanos; - if ( ts.tv_nsec > 1000000000 ){ - ts.tv_nsec -= 1000000000; - ts.tv_sec++; - } + /* Built in 64-bit throughout. `timeout` is a JAVA_LONG and time_t is + 64-bit everywhere we run, but `long` is only 32 bits on Windows + (LLP64) -- so casting the seconds through it truncated + Long.MAX_VALUE / 1000 (9223372036854775) to -1511828489, putting the + deadline about 48 years in the PAST. wait(Long.MAX_VALUE), the + ordinary park-until-notified idiom, therefore returned immediately + instead of blocking until notified. LP64 platforms were unaffected, + which is why this only ever showed up on Windows. + + The addend is capped so the deadline cannot overflow time_t on any + platform; ~34,000 years is indistinguishable from never for a wait, + and the condition-variable wrapper chunks it down to timeouts the + host API can express. */ + JAVA_LONG addSeconds = timeout / 1000; + if (addSeconds > ((JAVA_LONG)1 << 40)) { + addSeconds = ((JAVA_LONG)1 << 40); + } + /* Normalized in 64-bit as well: tv_usec * 1000 plus the sub-second part + of the timeout plus nanos can exceed 2e9, which no longer fits a + 32-bit tv_nsec, and a single subtract-one-second could not normalize + it anyway. */ + JAVA_LONG nsec = (JAVA_LONG)tv.tv_usec * 1000 + + (timeout % 1000) * 1000000 + + (JAVA_LONG)nanos; + ts.tv_sec = (time_t)((JAVA_LONG)tv.tv_sec + addSeconds + nsec / 1000000000); + ts.tv_nsec = (long)(nsec % 1000000000); pthread_cond_timedwait(&data->__codenameOneCondition, &data->__codenameOneMutex, &ts); }