From 40788049fc2b02ce0282107e82ea2be94ce22a3d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 28 Oct 2026 05:04:19 +0200 Subject: [PATCH 1/3] JavaScript port: never read the display canvas back (#6123) Every touchstart, mousedown and wheel event ran hitTest(), a 1x1 pixel read-back of the display canvas through a worker round trip, even in an application with no native peers, where every caller ignores the answer. Chrome counts getImageData calls per context and after a couple moves the canvas off the GPU for good, so every later frame was rasterized in software: a nine-row form scrolled at ~12fps on a budget Android phone. - hitTest() answers true without probing unless native peers are painted behind the canvas. - Reads that remain (getRGB of a surface, the peer pointer toggle, and the lens/glass/colour-matrix self-region effects) go through readCanvasPixels, which copies the region into a scratch canvas and reads that, so the source canvas is never read in place. - The composited rendering check now fails if a wheel, repeated mouse drags or repeated phone swipes read anything back from the display of its peer-free app. Co-Authored-By: Claude Opus 5.5 --- .../impl/html5/HTML5Implementation.java | 9 ++ .../test-javascript-composited-rendering.mjs | 62 +++++++++++++ .../src/javascript/browser_bridge.js | 93 +++++++++++++++---- 3 files changed, 148 insertions(+), 16 deletions(-) diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java index 98037c79ba3..7b53f4effe7 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java @@ -727,6 +727,15 @@ private int getClientY(MouseEvent evt) { } private boolean hitTest(int x, int y) { + // With no native peer behind the canvas there is nothing a pointer could + // fall through to, so the canvas owns every point. Probing anyway cost a + // blocking round trip and a pixel read-back of the display canvas on every + // touchstart and wheel event, and Chrome answers repeated read-backs by + // taking the canvas off the GPU for good: on a low-end Android phone every + // later frame was then rasterized in software, ~80ms each (#6123). + if (!(Accessor.getActivePeerCount() > 0 && paintNativePeersBehind())) { + return true; + } if (outputCanvas != null) { // Single-pixel alpha probe of the display surface. A fully // transparent pixel means the pointer falls through (no component diff --git a/scripts/test-javascript-composited-rendering.mjs b/scripts/test-javascript-composited-rendering.mjs index a2a53538e11..e47a27eca37 100644 --- a/scripts/test-javascript-composited-rendering.mjs +++ b/scripts/test-javascript-composited-rendering.mjs @@ -47,6 +47,8 @@ * cancelled those keys on the accessibility tree's listbox element); * phone (Chromium with touch emulation): * - a swipe scrolls the content (#5912: it did nothing at all); + * - a swipe, a wheel and a mouse drag read no pixels back from the display: this app + * has no native peers, and a read-back takes Chrome's canvas off the GPU (#6123); * - a tap on the hamburger opens the side menu, and a tap on a button fires it (#5912); * - with the side menu open, none of the form's DOM text floats over the menu's shade. * iOS user agent (Chromium presenting itself as an iPhone): @@ -139,6 +141,39 @@ function canvasPixels(page, points) { }, points); } +// Pixel read-backs of the display while a pointer is used. An application without native +// peers has nothing a pointer could fall through to, so it must read nothing back: each +// touchstart and wheel event used to cost a worker round trip and a getImageData on the +// display canvas, and Chrome answers repeated read-backs by taking that canvas off the GPU +// for good -- a nine-row form then scrolled at ~12fps on a budget Android phone (#6123). +// Both are counted: the worker's __cn1_surface_read__ requests catch the port asking, and a +// direct getImageData on the display canvas catches any bridge path that reads it in place. +// Installed after boot and read before the test itself samples pixels (canvasPixels). +function watchReadbacks(page) { + return page.evaluate(() => { + const state = { surfaceReads: 0, displayReads: 0 }; + const worker = window.__parparWorker; + if (!worker) throw new Error('no __parparWorker to observe'); + worker.addEventListener('message', (e) => { + const d = e.data || {}; + if (d.symbol === '__cn1_surface_read__') state.surfaceReads++; + }); + const original = CanvasRenderingContext2D.prototype.getImageData; + CanvasRenderingContext2D.prototype.getImageData = function(...args) { + if (this.canvas && this.canvas.id === 'codenameone-canvas') state.displayReads++; + return original.apply(this, args); + }; + window.__cn1ReadbackWatch = state; + }); +} + +async function checkNoReadbacks(page, label) { + const r = await page.evaluate(() => window.__cn1ReadbackWatch); + check(r && r.surfaceReads === 0 && r.displayReads === 0, + `${label} reads no pixels back from the display (no native peers)`, + r ? `${r.surfaceReads} surface read request(s), ${r.displayReads} display getImageData call(s)` : 'watch missing'); +} + function luminance(px) { return 0.2126 * px[0] + 0.7152 * px[1] + 0.0722 * px[2]; } @@ -340,9 +375,11 @@ async function desktop(name, browserType) { const title = await span(page, 'Hi World'); const toolbarBottom = Math.max(20, Math.floor(title.bottom)); const a = await page.screenshot({ path: path.join(outDir, `${name}-scroll-0.png`) }); + await watchReadbacks(page); await page.mouse.move(500, 300); for (let i = 0; i < 8; i++) { await page.mouse.wheel(0, 120); await page.waitForTimeout(120); } await page.waitForTimeout(1500); + await checkNoReadbacks(page, `${name}: the wheel`); const b = await page.screenshot({ path: path.join(outDir, `${name}-scroll-1.png`) }); const toolbarDiff = await diffFraction(page, a, b, [0, toolbarBottom]); const contentDiff = await diffFraction(page, a, b, [toolbarBottom + 20, 440]); @@ -367,6 +404,7 @@ async function desktop(name, browserType) { await boot(page, logs); const title = await span(page, 'Hi World'); const a = await page.screenshot(); + await watchReadbacks(page); await page.mouse.move(600, 400); await page.mouse.down(); for (let i = 1; i <= 15; i++) { await page.mouse.move(600, 400 - i * 15); await page.waitForTimeout(20); } @@ -375,6 +413,16 @@ async function desktop(name, browserType) { const b = await page.screenshot({ path: path.join(outDir, `${name}-drag.png`) }); const moved = await diffFraction(page, a, b, [Math.floor(title.bottom) + 20, 440]); check(moved > 0.05, `${name}: a mouse drag scrolls the content`, `${(moved * 100).toFixed(2)}% changed`); + // More presses from the same point: the first one of a page never probed (see the + // phone swipe). + for (let k = 0; k < 2; k++) { + await page.mouse.move(600, 400); + await page.mouse.down(); + for (let i = 1; i <= 15; i++) { await page.mouse.move(600, 400 - i * 15); await page.waitForTimeout(20); } + await page.mouse.up(); + await page.waitForTimeout(1000); + } + await checkNoReadbacks(page, `${name}: three mouse drags`); fs.writeFileSync(path.join(outDir, `${name}-drag-console.txt`), logs.join('\n')); await page.close(); } @@ -445,6 +493,7 @@ async function phone() { const band = [Math.floor(title.bottom) + 30, vp.height - 10]; const a = await page.screenshot({ path: path.join(outDir, 'phone-swipe-0.png') }); const cdp = await context.newCDPSession(page); + await watchReadbacks(page); const x = Math.round(vp.width / 2); const y0 = Math.round(vp.height * 0.8); const y1 = Math.round(vp.height * 0.3); @@ -458,6 +507,19 @@ async function phone() { const b = await page.screenshot({ path: path.join(outDir, 'phone-swipe-1.png') }); const moved = await diffFraction(page, a, b, band); check(moved > 0.05, 'phone: a swipe scrolls the content', `${(moved * 100).toFixed(2)}% changed`); + // More swipes from the same point: the probe never ran on the first press of a page, + // and a single gesture passed with the read-back still in place (measured on #6123's + // build: one read per press from the second on). + for (let k = 0; k < 2; k++) { + await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x, y: y0 }] }); + for (let i = 1; i <= 15; i++) { + await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x, y: Math.round(y0 + (y1 - y0) * i / 15) }] }); + await page.waitForTimeout(16); + } + await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); + await page.waitForTimeout(1000); + } + await checkNoReadbacks(page, 'phone: three swipes'); fs.writeFileSync(path.join(outDir, 'phone-swipe-console.txt'), logs.join('\n')); await page.close(); } diff --git a/vm/ByteCodeTranslator/src/javascript/browser_bridge.js b/vm/ByteCodeTranslator/src/javascript/browser_bridge.js index a10ea8e05e3..6036e185a70 100644 --- a/vm/ByteCodeTranslator/src/javascript/browser_bridge.js +++ b/vm/ByteCodeTranslator/src/javascript/browser_bridge.js @@ -2835,6 +2835,65 @@ // with JavaSEPort.applyLensBuffer(): the Simulator is the browser renderer's // pixel reference for the glass-tab animation. var LENS_MAG_FLAT = 0.75; + + // Reads RGBA bytes of a canvas region WITHOUT calling getImageData on that + // canvas. Chrome counts read-backs per context and, after a couple, moves the + // canvas off the GPU for the rest of the page's life ("Multiple readback + // operations using getImageData..."). For the full-screen display canvas that + // meant every later frame was rasterized in software -- ~80ms a frame on a + // low-end Android phone (#6123). Copying the region into a scratch canvas made + // for reading keeps the read-back off the source's count. Out-of-bounds pixels + // read as transparent, as getImageData's do. + var READBACK_SCRATCH_KEEP_PIXELS = 1 << 20; + var readbackScratch = null; + var readbackScratchCtx = null; + function readCanvasPixels(canvas, ctx, x, y, w, h) { + if (w <= 0 || h <= 0) { + return null; + } + var doc = global.document || (global.window && global.window.document); + if (!canvas || !doc || !doc.createElement) { + try { + return ctx.getImageData(x, y, w, h).data; + } catch (_ed) { + return null; + } + } + try { + if (!readbackScratch) { + readbackScratch = doc.createElement('canvas'); + } + if (readbackScratch.width < w || readbackScratch.height < h) { + readbackScratch.width = Math.max(readbackScratch.width, w); + readbackScratch.height = Math.max(readbackScratch.height, h); + readbackScratchCtx = null; + } + if (!readbackScratchCtx) { + readbackScratchCtx = readbackScratch.getContext('2d', { willReadFrequently: true }); + } + var sc = readbackScratchCtx; + sc.setTransform(1, 0, 0, 1, 0, 0); + sc.globalAlpha = 1; + sc.globalCompositeOperation = 'source-over'; + sc.clearRect(0, 0, w, h); + // The source rect is clipped to the canvas; draw only the part that is + // inside it, at its own offset, so the rest stays transparent. + var sx = Math.max(0, x), sy = Math.max(0, y); + var ex = Math.min(canvas.width, x + w), ey = Math.min(canvas.height, y + h); + if (ex > sx && ey > sy) { + sc.drawImage(canvas, sx, sy, ex - sx, ey - sy, sx - x, sy - y, ex - sx, ey - sy); + } + var data = sc.getImageData(0, 0, w, h).data; + if (w * h > READBACK_SCRATCH_KEEP_PIXELS) { + // One large getRGB must not pin a big CPU-backed canvas for the page's life. + readbackScratch = null; + readbackScratchCtx = null; + } + return data; + } catch (_er) { + return null; + } + } var LENS_TINT_HI = 150; var LENS_TINT_LO = 55; var LENS_LIFT_COEF = 0.40; @@ -2917,8 +2976,11 @@ return; } - var source = ctx.getImageData(rx, ry, rw, rh); - var src = source.data; + // Read through a scratch canvas: see readCanvasPixels (#6123). + var src = readCanvasPixels(ctx.canvas, ctx, rx, ry, rw, rh); + if (!src) { + return; + } var result = ctx.createImageData(rw, rh); var out = result.data; var hw = rw / 2.0, hh = rh / 2.0; @@ -3250,7 +3312,10 @@ if (availableWidth <= 0 || availableHeight <= 0) { return; } - var available = ctx.getImageData(ax0, ay0, availableWidth, availableHeight).data; + var available = readCanvasPixels(ctx.canvas, ctx, ax0, ay0, availableWidth, availableHeight); + if (!available) { + return; + } var padded = new Uint8ClampedArray(bufferWidth * bufferHeight * 4); for (var by = 0; by < bufferHeight; by++) { var sourceY = ry - pad + by - ay0; @@ -3440,7 +3505,10 @@ if (rw <= 0 || rh <= 0) { return; } - var part = ctx.getImageData(rx, ry, rw, rh).data; + var part = readCanvasPixels(ctx.canvas, ctx, rx, ry, rw, rh); + if (!part) { + return; + } var full = new Uint8ClampedArray(fullW * fullH * 4); var row; for (row = 0; row < rh; row++) { @@ -3867,13 +3935,10 @@ return null; } var x = r.x | 0, y = r.y | 0, w = r.w | 0, h = r.h | 0; - var img; - try { - img = s.ctx.getImageData(x, y, w, h); - } catch (_er) { + var data = readCanvasPixels(s.canvas, s.ctx, x, y, w, h); + if (!data) { return null; } - var data = img.data; var n = w * h; var out = new Array(n); for (var i = 0; i < n; i++) { @@ -7617,7 +7682,6 @@ // TRANSPARENT (an actual punched hole), so we additionally require the canvas // pixel under the cursor to be (near-)transparent before yielding to the peer. function installPeerPointerToggle() { - var alphaCtx = null; function canvasAlphaAt(canvas, x, y) { // Returns the canvas' painted alpha (0-255) at client point (x,y), or -1 if // it cannot be read. Maps CSS coords -> backing-store pixels (devicePixelRatio). @@ -7627,12 +7691,9 @@ var px = Math.round((x - rect.left) * (canvas.width / rect.width)); var py = Math.round((y - rect.top) * (canvas.height / rect.height)); if (px < 0 || py < 0 || px >= canvas.width || py >= canvas.height) { return -1; } - if (!alphaCtx) { - alphaCtx = canvas.getContext('2d', { willReadFrequently: true }) - || canvas.getContext('2d'); - } - if (!alphaCtx || typeof alphaCtx.getImageData !== 'function') { return -1; } - return alphaCtx.getImageData(px, py, 1, 1).data[3]; + // Never getImageData on the display canvas itself: see readCanvasPixels. + var px1 = readCanvasPixels(canvas, null, px, py, 1, 1); + return px1 ? px1[3] : -1; } catch (e) { return -1; } } function pointerOverPeer(x, y) { From 81243914e661a3c87e84579d94bf5941c4c7fe41 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 28 Oct 2026 05:44:11 +0200 Subject: [PATCH 2/3] Bastion: GuardTest installs a port instead of borrowing one new ConnectionRequest() asks the platform whether it supports access points, so the test passed only when an earlier test class in the same JVM had installed a port and failed with a NullPointerException whenever surefire ran it first. Co-Authored-By: Claude Opus 5.5 --- .../codenameone/examples/bastion/net/GuardTest.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/net/GuardTest.java b/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/net/GuardTest.java index 75b65854c4a..cbe66c67b37 100644 --- a/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/net/GuardTest.java +++ b/scripts/bastion/common/src/test/java/com/codenameone/examples/bastion/net/GuardTest.java @@ -19,7 +19,10 @@ package com.codenameone.examples.bastion.net; import com.codenameone.examples.bastion.AppConfig; +import com.codename1.impl.javase.JavaSEPort; import com.codename1.io.ConnectionRequest; +import com.codename1.io.Util; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import java.io.IOException; @@ -32,6 +35,14 @@ /// What the network guard lets through. class GuardTest { + /// `new ConnectionRequest()` asks the platform whether it supports access + /// points. Without this the test passed only when an earlier test class in + /// the same JVM had installed a port, and failed whenever it ran first. + @BeforeAll + static void port() { + Util.setImplementation(new JavaSEPort()); + } + @Test void cleartextAndUnlistedHostsAreRefused() { withDevelopment(() -> { From cd852345bde60e8ebe99235e7d73c0ff6e0c64cb Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 28 Oct 2026 07:47:02 +0200 Subject: [PATCH 3/3] vm-tests: the lens reads through readCanvasPixels now (#6123) Co-Authored-By: Claude Opus 5.5 --- .../tools/translator/JavascriptTargetIntegrationTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/JavascriptTargetIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/JavascriptTargetIntegrationTest.java index cccf29476a5..b05b73d9430 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/JavascriptTargetIntegrationTest.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/JavascriptTargetIntegrationTest.java @@ -176,7 +176,7 @@ void generatesBrowserBundleForJavascriptTarget(CompilerHelper.CompilerConfig con assertTrue(browserBridge.contains("cn1HostBridge") && browserBridge.contains("host-callback") && browserBridge.contains("host-call"), "Browser bridge should expose host-call plumbing for the JavaScript port shell"); assertTrue(browserBridge.contains("function applyLensSelfRegion") - && browserBridge.contains("ctx.getImageData(rx, ry, rw, rh)") + && browserBridge.contains("readCanvasPixels(ctx.canvas, ctx, rx, ry, rw, rh)") && browserBridge.contains("LENS_GLASS_TINT_STR") && browserBridge.contains("_lab = nums[ni++]") && browserBridge.contains("_ltintStrength = nums[ni++]"),