diff --git a/README.md b/README.md index a10ca97..78f7958 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,8 @@ Qualify the registered editor and loaded JavaFX natives in real disposable IDE w python3 scripts/check-ide-host.py --java-home "$JAVA_HOME" --target-java-home "$TARGET_JAVA_HOME" --idea-home "$IDEA_HOME" ``` +Worker startup cancellation and ready-preview cleanup are covered by the [native lifecycle regression](docs/native-worker-lifecycle.md); host checks also reject new native JVM crash reports. + These graphical checks require a macOS desktop session; other platforms are not qualified. See [workflow limits](docs/first-connected-workflow.md#limits-and-next-stage) before using arbitrary application documents. ## Implementation references diff --git a/designer-standalone/src/test/java/de/neitzel/fxdesigner/standalone/WorkerLifecycleSmoke.java b/designer-standalone/src/test/java/de/neitzel/fxdesigner/standalone/WorkerLifecycleSmoke.java new file mode 100644 index 0000000..b818f18 --- /dev/null +++ b/designer-standalone/src/test/java/de/neitzel/fxdesigner/standalone/WorkerLifecycleSmoke.java @@ -0,0 +1,94 @@ +package de.neitzel.fxdesigner.standalone; + +import de.neitzel.fxdesigner.build.SnapshotStore; +import de.neitzel.fxdesigner.protocol.*; +import de.neitzel.fxdesigner.workflow.*; +import java.nio.file.*; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.*; + +/** Real target processes: cancellation must not detach JavaFX beneath native callbacks. */ +public final class WorkerLifecycleSmoke { + public static void main(String[] args) throws Exception { + Path root = Path.of(args[0]), fixture = Path.of(args[1]); + int count = args.length > 2 ? Integer.parseInt(args[2]) : 24; + var store = new SnapshotStore(fixture.resolve(".neitzel-fx-designer/snapshots")); + var tools = + List.of("target-tools", "contracts", "preview-api").stream() + .map( + n -> + root.resolve("designer-" + n + "/target/designer-" + n + "-0.1.0-SNAPSHOT.jar")) + .toList(); + for (int i = 0; i < count; i++) { + var output = new StringBuffer(); + var worker = + new PreviewWorker( + new DraftOverlay(store.lease(store.state("fixture").lastSuccessful()), List.of())); + CompletableFuture request = null; + Process process; + boolean readyClose = args.length > 3 || i >= count - 4; + int delay = readyClose ? 0 : new int[] {100, 150, 200, 250}[i % 4]; + try { + worker.start(tools, output::append); + var exited = worker.exited().toCompletableFuture(); + request = + CompletableFuture.runAsync( + () -> { + try { + worker.request( + WireProtocol.Operation.INTERACTIVE, + Map.of("lifecycle-fixture", 1L), + 640, + 480, + PreviewMessages.encode(Map.of("example", "full", "node", "")), + Duration.ofSeconds(20)); + } catch (Exception error) { + throw new CompletionException(error); + } + }); + if (readyClose) request.get(25, TimeUnit.SECONDS); + else Thread.sleep(delay); + worker.close(); + process = exited.get(5, TimeUnit.SECONDS); + } finally { + worker.close(); + } + if (request != null) { + try { + request.get(5, TimeUnit.SECONDS); + } catch (ExecutionException expectedCancellation) { + if (readyClose) throw expectedCancellation; + } + } + if (readyClose) { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + while (!output.toString().contains("Application cleanup: full") + && System.nanoTime() < deadline) Thread.sleep(10); + if (!output.toString().contains("Application cleanup: full")) + throw new AssertionError("Ready worker skipped example cleanup: " + output); + } + if ((process.exitValue() != 0 && (readyClose || process.exitValue() != 137)) + || output.toString().contains("Java has been detached already") + || output.toString().contains("A fatal error has been detected")) + throw new AssertionError( + "Native lifecycle failure: iteration=" + + i + + " delay=" + + delay + + " pid=" + + process.pid() + + " exit=" + + process.exitValue() + + "\n" + + output); + System.out.println( + "PASS worker lifecycle " + + i + + " exit=" + + process.exitValue() + + " " + + (readyClose ? "ready close" : "startup cancel " + delay + "ms")); + } + } +} diff --git a/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/WorkerMain.java b/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/WorkerMain.java index d747b36..46d1dd0 100644 --- a/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/WorkerMain.java +++ b/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/WorkerMain.java @@ -14,26 +14,55 @@ import javafx.application.Platform; public final class WorkerMain { private static volatile TargetPreview preview; private static volatile boolean fx; + private static final Object lifecycle = new Object(); + private static final java.util.concurrent.atomic.AtomicBoolean stopping = + new java.util.concurrent.atomic.AtomicBoolean(); private static synchronized void send(Socket socket, Message message) throws IOException { FrameCodec.write(socket.getOutputStream(), message); socket.getOutputStream().flush(); } + private static void terminate() { + try { + ProcessHandle.current().descendants().forEach(ProcessHandle::destroyForcibly); + } finally { + Runtime.getRuntime().halt(0); + } + } + private static void stop() { + if (!stopping.compareAndSet(false, true)) return; + // Independent of FX responsiveness and of arbitrary application cleanup callbacks. Thread.ofPlatform() .daemon() + .name("designer-shutdown-deadline") .start( () -> { try { Thread.sleep(1500); } catch (InterruptedException ignored) { } - ProcessHandle.current().descendants().forEach(ProcessHandle::destroyForcibly); - Runtime.getRuntime().halt(0); + terminate(); }); - if (preview != null) preview.close(); - if (fx) Platform.runLater(Platform::exit); + TargetPreview owned; + synchronized (lifecycle) { + owned = preview; + } + if (!fx) { + terminate(); + return; + } + Platform.runLater( + () -> { + try { + if (owned != null) owned.close(); + } finally { + // Do not call Platform.exit(): macOS can deliver an activation callback after + // Glass detaches its Java thread. Keep Glass attached until this owned JVM exits. + terminate(); + } + }); } public static void main(String[] args) throws Exception { @@ -132,22 +161,27 @@ public final class WorkerMain { }); fx = true; if (!started.await(10, TimeUnit.SECONDS)) throw new IOException("FX startup timeout"); - preview = - TargetPreview.open( - descriptor, - loader, - Duration.ofSeconds(args.length > 2 ? Integer.parseInt(args[2]) : 20)); + synchronized (lifecycle) { + if (stopping.get()) return; + preview = + TargetPreview.open( + descriptor, + loader, + Duration.ofSeconds(args.length > 2 ? Integer.parseInt(args[2]) : 20)); + } } preview .ready() .whenComplete( (ready, error) -> { + if (stopping.get()) return; if (error != null) { error(socket, p, error); return; } Platform.runLater( () -> { + if (stopping.get()) return; try { send( socket, diff --git a/docs/native-worker-lifecycle.md b/docs/native-worker-lifecycle.md new file mode 100644 index 0000000..4774008 --- /dev/null +++ b/docs/native-worker-lifecycle.md @@ -0,0 +1,40 @@ +# Native target-worker shutdown correction + +The stage-3 JavaFX 21 worker crash was reproduced with the actual `PreviewWorker` transport and immutable classpath snapshot. This is a worker lifecycle correction; neither the IntelliJ JavaFX 26 toolkit nor NeitzelLib needs a patch. + +## Reproduction and diagnosis + +Cancelling the `full` interactive example during startup produced `SIGSEGV`, exit 134, and the original `libglass.dylib` frame (`JNI_OnLoad+0x11d0`). Worker output identified the actual callback: `Java has been detached already` in `GlassApplication applicationWillUnhide:`. The committed regression failed at iteration 8, cancelling 100 ms after sending the request. Earlier probes also reproduced it at 150 ms. + +The old shutdown path queued `Platform.exit()` while the worker remained alive for up to its 1.5-second shutdown deadline. macOS could deliver a queued application activation notification after Glass detached its Java thread. The [OpenJFX 21 native callback](https://raw.githubusercontent.com/openjdk/jfx/jfx21/modules/javafx.graphics/src/main/native-glass/mac/GlassApplication.m) calls into Java when receiving this notification, consistent with both crash reports. The symbol shown by the native report is not evidence that the crash occurred in initial library loading. + +The isolated experiment removed only the early toolkit exit and passed 40 worker cycles. One earlier run required the host's permitted forced-kill fallback during cancellation; that is distinct from a native crash. Forty ready-window closes, twenty canvas-only cancellations and twenty-five simple-example cancellations had passed before the fix; those narrower cases alone were insufficient regression coverage. + +Local before/after evidence, including fatal reports, is retained in `/tmp/neitzel-native-crash-20261112/`. Temporary probes are outside production sources. The permanent regression is `WorkerLifecycleSmoke`, invoked for both launch arrangements by `scripts/check-connected-workflow.py` after building a fresh fixture snapshot. + +## Shutdown behavior + +- An atomic transition admits shutdown once, regardless of whether socket closure, owner EOF, a window close or an error triggers it. +- Preview registration and shutdown coordinate so a newly constructed preview cannot escape ownership. Readiness and queued response callbacks stop creating/rendering windows once shutdown starts. +- A ready preview closes on the FX thread, running its example cleanup and closing its window. The worker then terminates as one process, keeping Glass attached until termination; it does not call `Platform.exit()` first. +- The independent 1.5-second watchdog still terminates a worker whose FX thread or application cleanup is unresponsive. The host's bounded forced-kill fallback remains in place. Snapshot leases remain held until actual process exit. +- Discovery-only workers and cancellation before FX initialization terminate without starting application cleanup that has no corresponding session. Application shutdown hooks are not the preview cleanup contract: use the example session's cleanup hook, which still cannot be guaranteed after forced termination or owner/process death. + +This is deliberately confined to disposable target JVMs. Normal JavaFX application shutdown in either editor host is unchanged. + +## Regression and qualification + +`WorkerLifecycleSmoke` uses actual target processes and authenticated requests, varying cancellation across 100, 150, 200 and 250 ms during full interactive startup, then closing ready previews. It checks process exit, rejects native fatal output and detached-thread warnings, and verifies the fixture's cleanup marker after ready close. Cancellation may use the documented forced-kill fallback; ready previews must exit normally and execute cleanup. + +The connected suite runs 24 cycles per launch arrangement (48 total). The registered IntelliJ suite now waits for owned workers to exit after host shutdown. Both runners reject newly created JVM fatal-error reports, independently of UI success markers. + +Qualification on 2026-11-12 passed on macOS arm64, host JDK 25 / JavaFX 26, target JDK 21 / JavaFX 21.0.7: + +- Full bootstrap: 64 Maven tests, 7 IntelliJ adapter tests, bytecode/dependency/package checks and documentation validation. +- 32 target-fixture processes, 402 assertions, plus 14 real Build and Refresh checks. +- All 48 lifecycle cycles exited normally (no forced-kill fallback needed), including 8 ready-preview cleanup checks. +- Connected standalone/IntelliJ workflows, native Kit checks, crash/retry and abrupt owner-death cleanup passed. +- Both registered IntelliJ launch arrangements passed exclusive JavaFX 26 checks and worker shutdown verification. +- No new fatal JVM report was produced. Logs are `bootstrap.log` and `registered-ide.log` in the local evidence directory above; per-cycle logs are under `target/connected-workflow/worker-lifecycle-*.log`. + +The native lifecycle issue is closed for this qualified baseline. The human Component-navigation review remains separate. diff --git a/docs/target-runtime-sessions.md b/docs/target-runtime-sessions.md index b01616d..089d0da 100644 --- a/docs/target-runtime-sessions.md +++ b/docs/target-runtime-sessions.md @@ -56,11 +56,9 @@ Run the bootstrap and registered IDE commands in [README](../README.md). A succe These checks are pending; automated window/control checks do not count as human acceptance. -## Open native-runtime observation +## Native-runtime lifecycle correction -The final registered IntelliJ runs passed their assertions, but the classpath run also produced a Java 21 / JavaFX 21.0.7 target-worker `SIGSEGV` in `libglass.dylib` (`JNI_OnLoad+0x11d0`, Cocoa application-unhide notification). The worker was 1.12 seconds old; this was not the IntelliJ JavaFX 26 JVM. The report does not establish whether cancellation/teardown overlapped toolkit startup. Root cause remains unconfirmed, and successful assertions must not be read as crash-free native qualification. - -The local report is retained under `target/stage3-qualification/hs_err_pid73625.log` (not committed). Reproduce rapid worker replacement and owner close during startup, correlate worker/session lifecycle timestamps, and check for native fatal reports in addition to host success markers before closing the stage-3 lifecycle gate. Existing earlier target and host runs passed without this report. +The native JavaFX 21 target-worker crash observed during stage-3 qualification has been reproduced and corrected. See the [diagnosis, shutdown contract and lifecycle regression](native-worker-lifecycle.md). The runners now reject native crash reports even when UI assertions pass. Human navigation and interactive-preview review remains open. ## Boundaries diff --git a/integration-fixtures/preview/classpath/app/src/test/java/fixture/preview/Examples.java b/integration-fixtures/preview/classpath/app/src/test/java/fixture/preview/Examples.java index 6ea722e..67f0495 100644 --- a/integration-fixtures/preview/classpath/app/src/test/java/fixture/preview/Examples.java +++ b/integration-fixtures/preview/classpath/app/src/test/java/fixture/preview/Examples.java @@ -63,6 +63,7 @@ public final class Examples implements PreviewProvider