Troubleshooting¶
Common surprises and their fixes, ordered roughly by how often they bite.
Launch-and-attach harness¶
"Launched process exited with code N before attach (PROCESS_ALIVE)"¶
The process died before Spectre could attach. The exception message includes the exit code, paths to stdout/stderr capture files, and a stderr excerpt. Fix the app startup failure first (missing main class, bad classpath, AWT headless, etc.).
On a Gradle-ish launch, if the ./gradlew client exits before any app JVM is
discovered (wrapper download failure, bad env, build script error), you get this same
stage — often with detail that the Gradle client exited before an app JVM appeared —
rather than a name-filter miss. Read the captured stderr first; do not assume
--app-name is wrong until the client stays alive.
"No attachable JVM … (JVM_ATTACHABLE)" on a Gradle launch¶
./gradlew :app:run spawns the app JVM from the Gradle daemon, not the gradlew
client. This stage means the client is still running but no matching app JVM was
found in time. Pass LaunchSpec.appJvmNameFilter / spectre launch --app-name
<MainClass> so discovery can match the app among daemon children. Never kill the
Gradle daemon to "fix" teardown — the harness only tears down the discovered app JVM.
Cold Gradle daemons (for example after ./gradlew --stop) and first-time compile of
the app module can take longer than a direct java boot. Spectre expands the default
JVM_ATTACHABLE budget to 120s for Gradle-ish launches that leave stage timeouts at
defaults; set LaunchStageTimeouts.jvmAttachableMs explicitly if you need a different
budget. If the timeout still fires, check captured stdout/stderr for compile errors
before assuming a name-filter miss.
You will also see a loud Gradle-ish warning naming daemon, sandbox, and JEP 451
caveats. Prefer a prod-like launch (java -jar, installDist) when you control the build.
"Agent bootstrap failed (AGENT_BOOTSTRAP)"¶
The target JVM was found but the agent did not bind its UDS in time, or
ComposeAutomator was not on the target classpath. Ensure the app depends on
spectre-core, and for direct launches that Spectre injects
-XX:+EnableDynamicAgentLoading (Gradle launches must set that flag in the build).
"Attached but no window (FIRST_WINDOW)"¶
Attach succeeded but windows() stayed empty until timeout. The app may still be
showing a splash screen, or the Compose tree never registered a window. Increase
LaunchStageTimeouts.firstWindowMs or fix the UI startup path.
"I called a wait helper from the EDT"¶
java.lang.IllegalStateException: waitForIdle must not be called from the AWT event
dispatch thread; wrap the call with withContext(Dispatchers.Default) or similar.
All three wait helpers — waitForNode, waitForIdle, and waitForVisualIdle — refuse
to run on the AWT event dispatch thread. They snapshot semantics via
invokeAndWait/readOnEdt. Running them on the EDT would either deadlock or skip the
bounded worker that enforces the timeout, so the helpers raise
IllegalStateException instead. The exact wait name in the message tells you which
call to wrap.
JUnit test methods don't run on the EDT, so a a runSpectreTest { … } body is fine (or plain runBlocking as a fallback)
there — no withContext needed. The error appears when the call originates from a
coroutine on Dispatchers.Main or any Swing-backed dispatcher, e.g.:
// inside a Dispatchers.Main coroutine — wrong:
automator.waitForVisualIdle() // throws IllegalStateException
// fix: hop off the EDT first
withContext(Dispatchers.Default) {
automator.waitForVisualIdle()
}
See Synchronization.
"My selector returns null or an empty list"¶
In order of likelihood:
- The UI hasn't rendered yet. Spectre doesn't auto-wait. Use
waitForNode(...)before the first read. - The node is composed but offscreen. It still appears in the semantics tree, but
boundsOnScreenmay be empty. Scroll to it first. - You're reading after an interaction without waiting. Add
waitForIdle()/waitForVisualIdle()after the click or type. - The selector doesn't match. Run
println(automator.printTree())and check the actual test tags/text/role. Localised text is the usual culprit. - The tree is empty. If
printTree()returns"", the composition probably crashed before any node registered. Check the test JVM's stderr for exceptions from the EDT or composition thread; they do not always propagate to the test method.
"Tests fight over OS focus when run in parallel"¶
Real RobotDriver dispatches OS-level input. Two parallel test JVMs racing for the
same screen will collide.
Use synthetic input:
import dev.sebastiano.spectre.core.ComposeAutomator
import dev.sebastiano.spectre.core.RobotDriver
val automator = ComposeAutomator.inProcess(
robotDriver = RobotDriver.synthetic(rootWindow = composeWindow),
)
Synthetic input posts AWT events directly into the target window's event queue — no global focus, no cursor motion. The trade-off is that some interactions (system-level shortcuts, real OS drag-and-drop) won't behave the same way. See Driving input.
"typeText or pasteText didn't reach my field"¶
typeText dispatches key press/release pairs and avoids the clipboard. It supports
ASCII letters, digits, space, newline, and common US-keyboard punctuation. Use
pasteText for arbitrary Unicode or large text: it writes to the system clipboard,
dispatches the platform paste shortcut (Cmd+V on macOS,
Ctrl+V elsewhere), waits for the paste to land, and restores the
previous clipboard contents. A few failure modes follow from those contracts:
- Nothing has focus.
typeTexttypes into whatever the focused component is. With the realRobotDriver()that means the OS/AWT focus owner. WithRobotDriver.synthetic(rootWindow), Spectre can also route key events through the key-listening Compose Desktop host when AWT has no focus owner (for example,apple.awt.UIElement=truehelper JVMs), but Compose still needs an internally focused text field. If your test never clicked into the field — or the click landed on something else, e.g., a parent that absorbed it — the input either no-ops or lands in the wrong place. UseclearAndTypeText(node, …)(which clicks first) or precede the call with an explicitautomator.click(field). - The field doesn't accept paste. Some Compose components (and any read-only text field) ignore the system paste shortcut. Verify the field accepts pasted input outside the test before assuming Spectre is at fault.
- macOS
apple.awt.UIElement=true. UI-element/helper mode is supported forRobotDriver.synthetic(rootWindow = ...)per-charactertypeText: Spectre bypasses the missing AWT focus owner and targets the key-listening Compose host under the root window. It can still break clipboard-backedpasteText: the field may be focused and the paste shortcut may be delivered, but Compose reads stale or empty clipboard contents. Disableapple.awt.UIElement=truefor the JVM hosting the test window when you needpasteText, or usetypeTextfor supported ASCII input. - macOS pasteboard race. macOS's
NSPasteboardwrites are asynchronous, so Spectre polls the clipboard until it reads back the requested text before dispatching Cmd+V. If your environment has a clipboard manager or another process actively rewriting the clipboard, the poll can time out and the paste lands stale. Disable clipboard managers in the test environment. RobotDriver.headless()throws ontypeTextandpasteText. It throws on every input, clipboard, and screenshot call by design (see Driving input), so text entry against a headless driver surfaces anUnsupportedOperationExceptionat the call site rather than silently dropping. UseRobotDriver.synthetic(rootWindow)or the defaultRobotDriver()for any real-input scenario.- Compose's paste action runs on its own dispatcher. After the keystroke,
Spectre pumps the EDT and sleeps briefly so the paste handler can read the
clipboard before the previous contents are restored. If you stack many
typeTextcalls back-to-back in a tight loop and observe truncated text, give the field awaitForIdle()between calls.
Use pressKey(...) for individual key events (modifier shortcuts, navigation keys,
<kbd>Tab</kbd>, <kbd>Esc</kbd>) — those go through the AWT key map, not the
clipboard, so none of the paste-specific caveats apply.
Compose Hot Reload¶
"wait --reload-settled says hotReloadUnavailable"¶
The attached session is not reload-aware: attach did not find HR orchestration properties on the process, so no Hot Reload client was created. Common causes:
- The target is not running under Compose Hot Reload — ordinary
run/ packaged launches are fine for Spectre; reload wait only activates when HR properties are discoverable. - Port discovery found nothing — Spectre reads
compose.reload.orchestration.portand/orcompose.reload.pidFilefrom the target process’s JVM arguments, then the pid file’sorchestration.portfield. Confirm the app was started by an HR-aware run configuration that still exposes those properties.
If properties are present but the orchestration server is down or the port is stale, wait
returns timeout, not hotReloadUnavailable (the session is still treated as reload-aware).
See Compose Hot Reload awareness.
"reloadFailed vs timeout"¶
reloadFailed— HR completed aReloadClassesResultwithisSuccess = false(for example a redefine error). Fix the reload in the app/IDE; Spectre is only reporting HR’s outcome.timeout— the settle chain (Request→Result→UIRendered→Ping/Ack) did not finish in time, or a reload-aware session never connected to orchestration (stale port, HR not listening yet). Retry with a larger--timeout-ms, confirm HR is healthy and still advertising a live port, then re-query the tree.
"Clicks fail with nodeNotFound after a hot reload"¶
On reload-aware sessions, node keys from tree / find are generation-stamped and cleared
after a successful reload settle. Pre-reload keys (and guessed g{n}:… stamps before a fresh
tree) return nodeNotFound. Arm spectre wait --reload-settled <session> before the
reload, then tree / find again and use the new stamped keys. Do not reuse keys from an
older capture.json for clicks after settle — capture writes raw keys for inspection.
"Older Hot Reload builds"¶
Spectre tests against the Compose Hot Reload 1.2 line (pinned 1.2.0, minimum 1.2.0-alpha+211). Older orchestration servers may omit message types Spectre needs; upgrade HR rather than expecting settle wait to work.
"The JVM is headless"¶
Spectre input and screenshots need AWT. If the JVM is launched with
-Djava.awt.headless=true, real RobotDriver() cannot drive the window, and synthetic
input still needs a real AWT window hierarchy to dispatch into. Move live Spectre tests
to a non-headless test task (systemProperty("java.awt.headless", "false")). On Linux CI
under xvfb, also set systemProperty("skiko.renderApi", "SOFTWARE_COMPAT") on the
same Test task so Skiko does not try to create a GPU-backed OpenGL context. Use
RobotDriver.headless() only for read-only semantics-tree checks. See
Running on CI for a complete workflow example.
"The test hangs behind a Swing Error dialog"¶
A modal Swing dialog with text such as Error: No Component provided usually means the
UI library threw an uncaught composition exception and JBR surfaced it as a blocking
JOptionPane. Check stderr for the real exception and make sure required
CompositionLocals are provided. For Jewel standalone windows, use Jewel's window
wrapper when your UI reads Jewel locals such as LocalComponent.
"Captured screenshot pixels look slightly off"¶
screenshot(windowIndex) and screenshot(node) use the platform window-capture backend when
spectre-recording is on the runtime classpath. They fail if that backend is unavailable or cannot
identify the requested window; they never substitute a screen-region crop. capture() and
waitForVisualIdle() use the same window-scoped still when recording is present and the
environment can actually run it (GitHub Actions hosted Windows is treated as non-interactive for
WGC, so those paths fall back to Robot region capture of the Compose surface). Without recording
(for example an inject payload that omits it) they likewise use region capture. On Linux X11/Xvfb
native stills read visible framebuffer pixels — keep the target frontmost. Settle the UI
(waitForIdle / waitForVisualIdle) before capture() so the semantics snapshot and PNG stay a
usable pair across one-shot native still latency.
waitForVisualIdle() samples window-scoped pixels for tracked Compose surfaces when
spectre-recording is present (same native still path as screenshot(windowIndex)). Without that
backend it falls back to Robot region capture of each surface rectangle — keep the target frontmost
and unobscured in that fallback mode (Linux X11 region and some embedded cases still need
visibility).
Rule of thumb: when you're validating colours from a screenshot(), always think
about the node's interaction state first. If the node is currently focused,
hovered, or pressed, the captured pixels include whatever indication overlay the
component's theme draws on top — a translucent state layer for press / hover, a focus
halo, a ripple, etc. The overlay alpha-blends with the underlying colour, so the
bytes that come out of screenshot() are the blended result, not the raw painted
colour. The effect is platform-independent (same on macOS, Windows, and Linux) and
easy to mistake for a colour-space or render-pipeline bug.
The fix is to assert against the right expected colour for the state you're actually capturing, not to reach for tricks that bypass the indication. If your node is focused at capture time, your expected value is raw colour + focus overlay; if it's pressed, raw colour + press overlay; if it's idle, the raw colour. The indication is part of the rendered output, not noise to suppress.
In practice that means either:
- Pin the node to a known interaction state before capture and compute the
expected colour for that state. If you want the raw colour, make sure the node
isn't focused / hovered / pressed when
screenshot()runs. If you specifically want to verify the focused appearance, focus the node first and compare against the blended expected value. - Compute the blended expected value at assertion time if reproducing the exact theme overlay yourself. Compose Foundation's default state-layer alphas are part of the public theme contract; for ad-hoc baselines, capture the expected pixels once from a known-good run and store those as the baseline rather than hard-coding RGB literals derived from the unblended source colour.
"Linux Wayland recording — UnsupportedOperationException from x11grab"¶
FfmpegBackend.LinuxX11Grab deliberately throws on Wayland sessions rather than
silently capturing black frames (Wayland's security model blocks framebuffer reads by
clients other than the compositor, so x11grab through XWayland would otherwise return
uniform black). The error message points you at the fix:
ffmpeg's x11grab silently captures black frames on Wayland sessions even with XWayland in the loop … Use Wayland-native capture instead: construct
dev.sebastiano.spectre.recording.AutoRecorder(which routes Wayland sessions through xdg-desktop-portal + PipeWire automatically), or instantiateWaylandPortalRecorderdirectly.
If you're driving FfmpegRecorder directly on Linux, switch to AutoRecorder — it
detects the session type and routes through the portal-based recorder when the
spectre-recording-linux helper artifact is on the runtime classpath. If you'd rather
force an Xorg session, verify with:
echo "$XDG_SESSION_TYPE" # should be "x11"
echo "$WAYLAND_DISPLAY" # should be empty
ls "$XDG_RUNTIME_DIR" | grep wayland # should be empty
(or pick "Ubuntu on Xorg" at the GDM login screen, or run under Xvfb).
See Recording limitations for the full Wayland story.
"macOS sandbox-exec blocks my Compose Desktop/Spectre test"¶
A macOS process sandbox can block AWT, Swing, Compose Desktop, and java.awt.Robot
even when the JVM is not headless. If your Gradle test runs inside sandbox-exec,
the sandbox profile must allow the desktop Mach services documented in
Running on CI.
| Symptom | Likely cause | What to check |
|---|---|---|
Connection Invalid error for service com.apple.hiservices-xpcservice |
Missing HiServices lookup | Allow com.apple.hiservices-xpcservice. |
Frame.isVisible()/Frame.isShowing() are true, but no window appears |
Missing render/window-manager services | Allow com.apple.CARenderServer and com.apple.windowmanager.server; also verify com.apple.windowserver.active. |
| Pixel probes sample the desktop background instead of the test window colour | The window exists in Java state but macOS is not painting it | Check com.apple.CARenderServer first, then window-manager logs. |
Robot.createScreenCapture(...) hangs after the Robot is created |
Missing capture service or TCC grant | Grant Screen Recording to the launching app, restart that app, and allow com.apple.replayd. |
log: Cannot run while sandboxed |
/usr/bin/log is itself blocked by sandbox-exec |
Use a narrow read-only macOS log diagnostic lane outside the sandbox. |
| Spectre times out waiting for/capturing a window | Stale Gradle daemon or missing UI Mach services | Re-run with ./gradlew --no-daemon spectreTest --tests '*SpectreSmokeTest' and inspect sandbox denies. |
Screen Recording belongs to the app that launched the JVM, not to java itself.
If you grant permission to Terminal.app, iTerm2, IntelliJ IDEA, or another launcher,
fully quit and restart that app before retrying. TCC permission alone is not enough
inside a sandbox: Robot capture can still hang until com.apple.replayd is
allowed.
For missing-service diagnosis, inspect recent macOS logs from outside the sandbox:
/usr/bin/log show --last 3m --style compact --predicate 'process == "java" OR eventMessage CONTAINS "Sandbox:" OR eventMessage CONTAINS "ClientCallsAuxiliary" OR eventMessage CONTAINS "deny"'
/usr/bin/log show --last 2m --style compact --predicate 'processID == <PID> OR eventMessage CONTAINS "<PID>"'
Look for patterns such as Sandbox: java(...) deny(1) mach-lookup,
Service "com.apple.CARenderServer" failed bootstrap look up,
WMClientWindowManager: Invalid connection, ScreenCaptureKit, ReplayKit,
com.apple.replayd, and kTCCServiceScreenCapture.
"macOS recording errors out or produces no file"¶
- Screen Recording permission for Spectre Capture Helper. Capture uses the
bundled helper app (
SpectreCaptureHelper.app, display name Spectre Capture Helper). With a human present, run:
The request command opens a small guide: Open Settings (Screen Recording deep
link), drag the helper icon into the list if missing, wait until the UI shows
Done ✓, then close. Capture/record paths never open this guide themselves —
they fail fast with a structured error agents can relay.
- Settings row should say Spectre Capture Helper, not Terminal/IntelliJ/java.
If you only see the spawning app, upgrade Spectre and unset
SPECTRE_SCREENCAPTURE_HELPER so the bundled app is used.
- macOS may re-prompt after updates. Run spectre permissions request again;
the guide uses re-approval copy when you re-open it after a prior grant lapsed.
- The SCK helper artifact is missing. If spectre-recording-macos is not on the
runtime classpath, the Swift helper is not present and AutoRecorder.startWindow(...)
throws instead of silently switching capture modes. Add the helper artifact as
runtimeOnly(...) or testRuntimeOnly(...). Use startRegion(...) explicitly if region
capture is an acceptable fallback for your test.
- Operational SCK errors propagate. Permission denied, target window not found,
helper crashed during init — these all throw IllegalStateException rather than
silently falling back, so you see the real cause.
"macOS RobotDriver throws IllegalStateException about TCC"¶
The default RobotDriver() lazily probes the two macOS TCC entries java.awt.Robot
needs and throws on first use if either is denied:
- Accessibility — required for mouse and keyboard delivery. Without it,
Robot.mouseMove,Robot.mousePress,Robot.keyPress, etc. return without the OS delivering anything. The probe runs on the firstclick(...),typeText(...),pressKey(...), etc. and asksSystem Events(viaosascript) for the active process. A "not allowed assistive access" denial throws with an actionable message naming the wrapping app to grant; an inconclusive probe (noosascript, AppleEvents/Automation refusal, etc.) prints a one-shot stderr warning and proceeds. - Screen Recording — required for
Robot.createScreenCaptureto return real pixels. Without it the call silently returns an all-black image. A locked screen can produce the same symptom, so the probe first checks whether macOS reports the console session as locked; if so, unlock the screen and retry. If the session is unlocked, the probe captures a small region near the screen origin (which on macOS overlaps the menu bar) and treats an all-black result as denial. False positives are rare in practice; if your screen really is fully black at the origin, switch toRobotDriver.headless()for tests or grant Screen Recording to silence the probe.
Fix: grant System Settings → Privacy & Security → Accessibility (or → Screen &
System Audio Recording) to whichever app launched the JVM — IntelliJ, Terminal,
iTerm2, Claude.app, etc. macOS attributes Robot operations to the wrapping app
that opened the JVM, not the JVM binary itself. Fully quit and relaunch the
wrapping app afterwards so macOS picks up the new entitlement, and ./gradlew
--stop the Gradle daemon if you launched from a shell.
macOS 26+ Screen Recording
Starting with macOS 26, toggling Screen Recording in System Settings grants
picker-based access only. The first direct Robot.createScreenCapture call
(i.e. the first automator.screenshot(...)) pops a second system dialog —
"allow App to bypass the system private window picker" — that you must
accept. This dialog appears once per app per boot; subsequent calls are
silent. CI runners that require fully-headless captures should use a notarised
wrapper with the appropriate entitlement pre-granted.
The two probes are independent: a consumer who only takes screenshots is not punished for missing Accessibility, and vice versa. Probe results are cached after the first call, so subsequent operations have no extra cost.
If you don't need real OS input or capture (in CI, in tests, etc.) use
RobotDriver.headless() — it bypasses the AWT Robot entirely and skips the TCC
probe.
For a passive, opt-in startup rollup of both entries (handy for harnesses that
want to log a banner), MacOsRecordingPermissions.diagnose() in :recording
returns a human-readable diagnostic without throwing.
"JBR vs Temurin: which JDK should I use?"¶
- Locally: JBR 21 is the dev-loop default. JBR 25 is exercised by the scheduled runtime matrix and by the IDE-hosted UI test (IntelliJ 2026.2 / platform 262 bundles JBR 25).
- On CI (per-PR): Temurin 21 for speed. The full JBR/Temurin × OS matrix is scheduled and release-gated, not per-PR.
- For consumers: any JDK 21+ works for non-IDE modules. The codified supported set and what “supported” means operationally live in Stability policy — JVM runtime support tiers.
Agent attach needs a real JDK (with jdk.attach) on the attacher process; the
target only needs to allow dynamic agent loading. IntelliJ-hosted Compose always
implies the IDE’s bundled JBR — do not ship a second skiko into the plugin classloader
(see IntelliJ guide).
The sample IntelliJ plugin module configures its sandbox JDK via the IntelliJ Platform Gradle plugin; you do not pick Temurin for that path.
Still stuck?¶
- Read Architecture for the module-level invariants.
- Check the open issues — the platform caveats are usually tracked.
- Drop a question or repro on the issue tracker.