From b16391663454b2f0833d8633ca7a686cef2a2ac9 Mon Sep 17 00:00:00 2001 From: Mike Barkmin Date: Wed, 7 Oct 2026 20:23:46 +0200 Subject: [PATCH] Start controlled smoke countdown after the first rendered frame --- .../scratch4j/runner/LauncherSource.java | 8 +++- .../scratch4j/runner/LauncherSourceTest.java | 44 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/runner/src/main/java/org/openpatch/scratch4j/runner/LauncherSource.java b/runner/src/main/java/org/openpatch/scratch4j/runner/LauncherSource.java index 9aad4e2..1a2f60a 100644 --- a/runner/src/main/java/org/openpatch/scratch4j/runner/LauncherSource.java +++ b/runner/src/main/java/org/openpatch/scratch4j/runner/LauncherSource.java @@ -8,7 +8,8 @@ * {@code main} when it has one, otherwise instantiates it (the first * {@code Stage} creates the library's singleton {@code Window}), and * implements the smoke-test switch {@code -Dscratch4j.ide.exitAfter=N}, which - * closes the window after N seconds so CI can run programs headlessly. + * closes the window after N seconds so CI can run programs headlessly. With + * the control channel enabled, that countdown starts after the first frame. * *

The class name is fixed so re-running does not litter the build folder; * it lives in the default package like the student's classes. @@ -45,6 +46,11 @@ public static void main(String[] args) throws Exception { if (exitAfter > 0) { Thread hook = new Thread(() -> { try { + // Native graphics startup can take longer than the smoke duration. + // Give controlled runs their full duration after rendering starts. + if (Boolean.getBoolean("scratch4j.ide.control")) { + while (IdeControl.frames < 1) Thread.sleep(25); + } Thread.sleep(exitAfter * 1000L); Window.getInstance().exit(); } catch (InterruptedException e) { diff --git a/runner/src/test/java/org/openpatch/scratch4j/runner/LauncherSourceTest.java b/runner/src/test/java/org/openpatch/scratch4j/runner/LauncherSourceTest.java index 5e819ba..09d4790 100644 --- a/runner/src/test/java/org/openpatch/scratch4j/runner/LauncherSourceTest.java +++ b/runner/src/test/java/org/openpatch/scratch4j/runner/LauncherSourceTest.java @@ -56,6 +56,50 @@ public enum TextureSampling { POINT } root.resolve("classes")).success()).isTrue(); } + @Test + void controlledSmokeCountdownWaitsForTheFirstFrame() throws Exception { + Path launcher = root.resolve("Scratch4JLauncher.java"); + Path main = root.resolve("Delayed.java"); + Path window = root.resolve("org/openpatch/scratch/Window.java"); + Path classes = root.resolve("classes"); + Files.createDirectories(window.getParent()); + Files.writeString(launcher, LauncherSource.generate()); + Files.writeString(main, """ + public class Delayed { + public static void main(String[] args) throws Exception { + Thread.sleep(2000); + System.out.println("FIRST_FRAME"); + Scratch4JLauncher.IdeControl.frames = 1; + Thread.sleep(10000); + } + } + """); + Files.writeString(window, """ + package org.openpatch.scratch; + public class Window { + public static Window getInstance() { return new Window(); } + public void setDebug(boolean value) {} + public Stage getStage() { return null; } + public void exit() { System.out.println("SMOKE_EXIT"); System.exit(0); } + } + """); + Path jar = LibraryJarSource.allJar(root.resolve("bundled")); + assertThat(new CompilerService().compile(List.of(launcher, main, window), List.of(jar), classes) + .errors()).isEmpty(); + Process process = new ProcessBuilder(Path.of(System.getProperty("java.home"), "bin", "java").toString(), + "-Dscratch4j.ide.control=true", "-Dscratch4j.ide.exitAfter=1", "-cp", + classes + java.io.File.pathSeparator + jar, "Scratch4JLauncher", "Delayed") + .redirectErrorStream(true).start(); + try { + assertThat(process.waitFor(15, java.util.concurrent.TimeUnit.SECONDS)).isTrue(); + String output = new String(process.getInputStream().readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + assertThat(process.exitValue()).as(output).isZero(); + assertThat(output).contains("FIRST_FRAME", "SMOKE_EXIT"); + } finally { + process.destroyForcibly(); + } + } + @Test void launcherStartsJava25CompactSourceMain() throws Exception { Path launcher = root.resolve("Scratch4JLauncher.java");