gradle/gradle · error · IllegalStateException

Cannot send signal {signal}: the process has not started yet

Error message

Cannot send signal {signal}: the process has not started yet

What it means

ExecHandleRunner.sendSignal(int) requires the field 'process' to be set, which happens only in startProcess() while holding the runner's lock. If sendSignal is invoked before that (handle still INIT/STARTING, process == null), it throws IllegalStateException('Cannot send signal N: the process has not started yet'). The runner schedules asynchronously, so 'start() returned' does not imply the runner thread already spawned the OS process unless start() was awaited.

Source

Thrown at platforms/core-runtime/process-services-base/src/main/java/org/gradle/process/internal/ExecHandleRunner.java:77

        if (execHandle == null) {
            throw new IllegalArgumentException("execHandle == null!");
        }
        this.execHandle = execHandle;
        this.streamsHandler = streamsHandler;
        this.processLauncher = processLauncher;
        this.executor = executor;
        this.associatedBuildOperation = associatedBuildOperation;
        this.processBuilderFactory = new ProcessBuilderFactory();
    }

    public void sendSignal(int signal) {
        if (OperatingSystem.current().isWindows()) {
            throw new UnsupportedOperationException("Sending signals is not supported on Windows");
        }
        lock.lock();
        try {
            if (process == null) {
                throw new IllegalStateException("Cannot send signal " + signal + ": the process has not started yet");
            }
            try {
                long pid = getProcessId(process);
                String[] command = {"kill", "-" + signal, String.valueOf(pid)};
                Process kill = new ProcessBuilder(command)
                    .redirectErrorStream(true)
                    .start();
                int exitCode = kill.waitFor();
                if (exitCode != 0) {
                    String output = CharStreams.toString(new InputStreamReader(kill.getInputStream(), UTF_8)).trim();
                    String message = StringUtils.join(command, " ") + " failed with exit code " + exitCode;
                    throw new RuntimeException(message + (output.isEmpty() ? "" : output));
                }
            } catch (RuntimeException e) {
                throw e;
            } catch (Exception e) {
                throw new RuntimeException("Failed to send signal " + signal + " to process", e);
            }

View on GitHub (pinned to 534f27719b)

Solutions

  1. Signal via the ExecHandle after start() completes (start() waits out of STARTING), not via a hand-held runner.
  2. Guard with the handle's state: only send when getState() == STARTED/DETACHED.
  3. Make the watchdog tolerant: retry sendSignal briefly or treat not-yet-started as 'nothing to kill'.
  4. Catch IllegalStateException around sendSignal when racing is unavoidable.

Example fix

// before
ExecHandleRunner runner = ...; // handed out before start
new Thread(() -> runner.sendSignal(15)).start(); // process may still be null

// after
ExecHandle handle = builder.build().start(); // waits until the process is running
if (handle.getState() == ExecHandleState.STARTED) {
    handle.abort(); // or sendSignal via the started runner
}
Defensive patterns

Strategy: validation

Validate before calling

// Only signal once the handle confirms the process is running
if (handle.getState() == ExecHandleState.STARTED || handle.getState() == ExecHandleState.DETACHED) {
    runner.sendSignal(signal);
} else {
    LOG.debug("process not started yet (state={}), skipping signal", handle.getState());
}

Try / catch

try {
    runner.sendSignal(signal);
} catch (IllegalStateException e) {
    if (e.getMessage().contains("has not started yet")) {
        // startup race - retry after the handle reaches STARTED or give up
    } else {
        throw e;
    }
}

Prevention

When it happens

Trigger: Calling sendSignal from a watchdog/timeout thread that fires before the fork completes (heavy JVM fork, cold daemon), or keeping an ExecHandleRunner and signalling it directly instead of going through the started ExecHandle.

Common situations: Timeout watchdogs racing a slow process spawn on loaded CI machines; retry loops that signal immediately after scheduling the runner; signalling a runner whose handle was built but never started.

Related errors


AI-assisted analysis of gradle/gradle@534f27719b (2026-08-22). Data as JSON: /api/errors/8854b653a259d99d. Report an issue: GitHub.