Skip to content

Commit fd26fd3

Browse files
committed
Fix OSC 9;4 taskbar progress bar not cleared on build end or SIGINT
When Gradle exits (normally or via SIGINT/Ctrl+C), terminals that support the OSC 9;4 protocol (Ghostty, iTerm2, ConEmu, kitty) were left with a stale taskbar progress indicator. The EndOutputEvent path does not fire on abrupt JVM exit, so the reset must be sent from a JVM shutdown hook. Register a shutdown hook in ConsoleConfigureAction that writes the OSC 9;4;0 reset sequence directly to raw stdout. The hook is registered solely based on terminal capability (supportsTaskbarProgress()), keeping the decision decoupled from the user's ConsoleOutput choice. Add integration tests that verify the reset sequence is emitted both after a successful build and after a SIGINT (Unix only). The SIGINT test uses a new sendSignal(int) method on GradleHandle and ExecHandle. The signal logic lives in ExecHandleRunner, which resolves the PID via Process.pid() on Java 9+ and falls back to reflection on Java 8. Fixes: #37022 Signed-off-by: Reinhold Degenfellner <[email protected]>
1 parent 0a84d67 commit fd26fd3

11 files changed

Lines changed: 262 additions & 5 deletions

File tree

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/*
2+
* Copyright 2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.gradle.internal.logging.console
18+
19+
import org.gradle.api.logging.configuration.ConsoleOutput
20+
import org.gradle.integtests.fixtures.AbstractIntegrationSpec
21+
import org.gradle.test.fixtures.ConcurrentTestUtil
22+
import org.gradle.test.fixtures.server.http.BlockingHttpServer
23+
import org.gradle.test.preconditions.IntegTestPreconditions
24+
import org.gradle.test.preconditions.UnitTestPreconditions
25+
import org.gradle.test.precondition.Requires
26+
import org.junit.Rule
27+
import spock.lang.Issue
28+
29+
/**
30+
* Verifies that the OSC 9;4;0 taskbar progress reset sequence is emitted when a build ends,
31+
* whether by normal completion or cancellation (Ctrl+C / SIGINT).
32+
*
33+
* @see <a href="https://github.com/gradle/gradle/issues/37022">Issue #37022</a>
34+
*/
35+
@Issue("https://github.com/gradle/gradle/issues/37022")
36+
class TaskbarProgressResetFunctionalTest extends AbstractIntegrationSpec {
37+
/** OSC 9;4 prefix — signals that taskbar progress is being reported. */
38+
static final String OSC_PROGRESS_PREFIX = "\u001B]9;4;"
39+
40+
/** OSC 9;4;0 BEL — the "remove taskbar progress" sequence. */
41+
static final String OSC_RESET = OSC_PROGRESS_PREFIX + "0\u0007"
42+
public static final int SIGINT = 2
43+
44+
@Rule
45+
BlockingHttpServer server = new BlockingHttpServer()
46+
47+
def setup() {
48+
server.start()
49+
// ConEmuPID triggers supportsTaskbarProgress() == true in the client JVM.
50+
// ConsoleOutput.Rich enables the progress bar so OSC 9;4 sequences are emitted.
51+
executer
52+
.withEnvironmentVars(ConEmuPID: "dummy")
53+
.withConsole(ConsoleOutput.Rich)
54+
}
55+
56+
@Requires(value = [UnitTestPreconditions.Unix, IntegTestPreconditions.NotEmbeddedExecutor],
57+
reason = "sends SIGINT to a forked process works only on Unix and with a separate process")
58+
def "sends OSC 9;4;0 reset sequence when build receives SIGINT"() {
59+
given:
60+
// Long sleep ensures the task is still running when SIGINT is sent.
61+
buildFile << """
62+
task block {
63+
doFirst {
64+
Thread.sleep(10_000)
65+
}
66+
}
67+
"""
68+
69+
when:
70+
def gradle = executer.withTasks("block").start()
71+
72+
// Wait until the progress bar has started emitting OSC 9;4 sequences.
73+
ConcurrentTestUtil.poll {
74+
assert gradle.standardOutput.contains(OSC_PROGRESS_PREFIX)
75+
}
76+
77+
gradle.sendSignal(SIGINT)
78+
gradle.waitForFailure()
79+
80+
then:
81+
gradle.standardOutput.contains(OSC_RESET)
82+
}
83+
84+
def "sends OSC 9;4;0 reset sequence after a successful build"() {
85+
given:
86+
buildFile << """
87+
task block {
88+
doFirst {
89+
${server.callFromBuild("block")}
90+
}
91+
}
92+
"""
93+
def block = server.expectAndBlock("block")
94+
95+
when:
96+
def gradle = executer.withTasks("block").start()
97+
98+
block.waitForAllPendingCalls()
99+
ConcurrentTestUtil.poll {
100+
assert gradle.standardOutput.contains(OSC_PROGRESS_PREFIX)
101+
}
102+
103+
block.releaseAll()
104+
gradle.waitForFinish()
105+
106+
then:
107+
gradle.standardOutput.contains(OSC_RESET)
108+
}
109+
}

platforms/core-runtime/logging/src/main/java/org/gradle/internal/logging/console/BuildStatusRenderer.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,9 @@ private void phaseStarted(ProgressStartEvent progressStartEvent, Phase phase) {
129129
}
130130

131131
private void phaseHasMoreProgress(ProgressStartEvent progressStartEvent) {
132-
progressBar.moreProgress(progressStartEvent.getTotalProgress());
132+
if (progressBar != null) {
133+
progressBar.moreProgress(progressStartEvent.getTotalProgress());
134+
}
133135
}
134136

135137
private void phaseProgressed(ProgressCompleteEvent progressEvent) {

platforms/core-runtime/logging/src/main/java/org/gradle/internal/logging/console/ProgressBar.java

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -278,10 +278,26 @@ private String buildTaskbarProgressSequence(int progressPercent, boolean isError
278278
if (!consoleMetaData.supportsTaskbarProgress()) {
279279
return "";
280280
}
281-
282-
// ESC ] 9 ; 4 ; state ; progress BEL
283-
// Using BEL (0x07) instead of ST (ESC \) for broader compatibility
284281
int state = isError ? 2 : 1; // 1=normal, 2=error
285-
return "\u001B]9;4;" + state + ";" + progressPercent + "\u0007";
282+
return buildOsc94Sequence(state + ";" + progressPercent);
283+
}
284+
285+
/**
286+
* Returns the OSC 9;4;0 sequence to remove taskbar progress (ConEmu, Ghostty),
287+
* or an empty string if the terminal does not support taskbar progress.
288+
* Should be sent when the build ends or is interrupted (e.g. SIGINT).
289+
*/
290+
public static String buildTaskbarProgressResetSequence(ConsoleMetaData consoleMetaData) {
291+
if (!consoleMetaData.supportsTaskbarProgress()) {
292+
return "";
293+
}
294+
// State 0 = remove; progress field is omitted as it is not applicable
295+
return buildOsc94Sequence("0");
296+
}
297+
298+
// ESC ] 9 ; 4 ; state [; progress] BEL
299+
// Using BEL (0x07) instead of ST (ESC \) for broader compatibility
300+
private static String buildOsc94Sequence(String command) {
301+
return "\u001B]9;4;" + command + "\u0007";
286302
}
287303
}

platforms/core-runtime/logging/src/main/java/org/gradle/internal/logging/sink/ConsoleConfigureAction.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import org.gradle.internal.logging.console.AnsiConsole;
2323
import org.gradle.internal.logging.console.ColorMap;
2424
import org.gradle.internal.logging.console.Console;
25+
import org.gradle.internal.logging.console.ProgressBar;
2526
import org.gradle.internal.nativeintegration.console.ConsoleDetector;
2627
import org.gradle.internal.nativeintegration.console.ConsoleMetaData;
2728
import org.gradle.internal.nativeintegration.console.FallbackConsoleMetaData;
@@ -57,6 +58,27 @@ public static void execute(OutputEventRenderer renderer, ConsoleOutput consoleOu
5758
} else if (consoleOutput == ConsoleOutput.Colored) {
5859
configureColoredConsole(renderer, consoleMetadata, stdout, stderr);
5960
}
61+
62+
registerTaskbarReset(consoleMetadata, stdout);
63+
}
64+
65+
private static void registerTaskbarReset(ConsoleMetaData consoleMetadata, OutputStream stdout) {
66+
String reset = ProgressBar.buildTaskbarProgressResetSequence(consoleMetadata);
67+
if (!reset.isEmpty()) {
68+
try {
69+
byte[] resetBytes = reset.getBytes(StandardCharsets.UTF_8);
70+
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
71+
try {
72+
stdout.write(resetBytes);
73+
stdout.flush();
74+
} catch (IOException ignored) {
75+
//ignore
76+
}
77+
}, "taskbar-progress-reset"));
78+
} catch (SecurityException | IllegalStateException ignored) {
79+
// Unable to register shutdown hook; proceed without it
80+
}
81+
}
6082
}
6183

6284
public static ConsoleMetaData createProxyingConsoleMetaData(ConsoleMetaData metaData, ConsoleUnicodeSupport consoleUnicodeSupport) {

platforms/core-runtime/logging/src/test/groovy/org/gradle/internal/logging/console/ProgressBarTest.groovy

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,33 @@ class ProgressBarTest extends Specification {
230230
id = unicode ? "unicode" : "ascii"
231231
}
232232
233+
def "builds taskbar progress reset sequence when supported (#id)"() {
234+
given:
235+
def consoleMetaData = Stub(ConsoleMetaData) {
236+
supportsTaskbarProgress() >> supported
237+
}
238+
239+
expect:
240+
ProgressBar.buildTaskbarProgressResetSequence(consoleMetaData) == resetSequence
241+
242+
where:
243+
supported | resetSequence
244+
true | "\u001B]9;4;0\u0007"
245+
false | ""
246+
id = supported ? "supported" : "unsupported"
247+
}
248+
249+
def "builds osc 9;4 sequence for command (#command)"() {
250+
expect:
251+
ProgressBar.buildOsc94Sequence(command) == expected
252+
253+
where:
254+
command | expected
255+
"0" | "\u001B]9;4;0\u0007"
256+
"1;10" | "\u001B]9;4;1;10\u0007"
257+
"2;10" | "\u001B]9;4;2;10\u0007"
258+
}
259+
233260
def "does not emit taskbar progress when not supported (#id)"() {
234261
given:
235262
init(unicode, 10, false)

platforms/core-runtime/process-services/src/main/java/org/gradle/process/internal/ExecHandle.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,14 @@ public interface ExecHandle extends Describable {
4545

4646
ExecHandleState getState();
4747

48+
/**
49+
* Sends the given signal to the process.
50+
*
51+
* @throws UnsupportedOperationException if called on Windows
52+
* @throws IllegalStateException if the process has not started yet
53+
*/
54+
void sendSignal(int signal);
55+
4856
/**
4957
* Aborts the process, blocking until the process has exited. Does nothing if the process has already completed.
5058
*/

subprojects/core/src/main/java/org/gradle/process/internal/DefaultExecHandle.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,11 @@ this, new CompositeStreamsHandler(), processLauncher, executor, CurrentBuildOper
301301
return this;
302302
}
303303

304+
@Override
305+
public void sendSignal(int signal) {
306+
execHandleRunner.sendSignal(signal);
307+
}
308+
304309
@Override
305310
public void removeStartupContext() {
306311
lock.lock();

subprojects/core/src/main/java/org/gradle/process/internal/ExecHandleRunner.java

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,16 @@
1717
package org.gradle.process.internal;
1818

1919
import net.rubygrapefruit.platform.ProcessLauncher;
20+
import org.apache.commons.lang3.StringUtils;
2021
import org.gradle.api.JavaVersion;
2122
import org.gradle.api.logging.Logger;
2223
import org.gradle.api.logging.Logging;
2324
import org.gradle.internal.operations.BuildOperationRef;
2425
import org.gradle.internal.operations.CurrentBuildOperationRef;
2526

27+
import com.google.common.io.CharStreams;
28+
import org.gradle.internal.os.OperatingSystem;
29+
import java.io.InputStreamReader;
2630
import java.lang.reflect.InvocationTargetException;
2731
import java.lang.reflect.Method;
2832
import java.util.Iterator;
@@ -31,6 +35,8 @@
3135
import java.util.concurrent.locks.ReentrantLock;
3236
import java.util.stream.Stream;
3337

38+
import static java.nio.charset.StandardCharsets.UTF_8;
39+
3440
public class ExecHandleRunner implements Runnable {
3541
private static final Logger LOGGER = Logging.getLogger(ExecHandleRunner.class);
3642

@@ -60,6 +66,49 @@ public ExecHandleRunner(
6066
this.processBuilderFactory = new ProcessBuilderFactory();
6167
}
6268

69+
public void sendSignal(int signal) {
70+
if (OperatingSystem.current().isWindows()) {
71+
throw new UnsupportedOperationException("Sending signals is not supported on Windows");
72+
}
73+
lock.lock();
74+
try {
75+
if (process == null) {
76+
throw new IllegalStateException("Cannot send signal " + signal + ": the process has not started yet");
77+
}
78+
try {
79+
long pid = getProcessId(process);
80+
String[] command = {"kill", "-" + signal, String.valueOf(pid)};
81+
Process kill = new ProcessBuilder(command)
82+
.redirectErrorStream(true)
83+
.start();
84+
int exitCode = kill.waitFor();
85+
if (exitCode != 0) {
86+
String output = CharStreams.toString(new InputStreamReader(kill.getInputStream(), UTF_8)).trim();
87+
String message = StringUtils.join(command, " ") + " failed with exit code " + exitCode;
88+
throw new RuntimeException(message + (output.isEmpty() ? "" : output));
89+
}
90+
} catch (RuntimeException e) {
91+
throw e;
92+
} catch (Exception e) {
93+
throw new RuntimeException("Failed to send signal " + signal + " to process", e);
94+
}
95+
} finally {
96+
lock.unlock();
97+
}
98+
}
99+
100+
private static long getProcessId(Process process) throws Exception {
101+
try {
102+
// Java 9+: Process.pid()
103+
return (Long) Process.class.getMethod("pid").invoke(process);
104+
} catch (NoSuchMethodException e) {
105+
// Java 8 fallback: UNIXProcess exposes a private 'pid' int field
106+
java.lang.reflect.Field pidField = process.getClass().getDeclaredField("pid");
107+
pidField.setAccessible(true);
108+
return ((Number) pidField.get(process)).longValue();
109+
}
110+
}
111+
63112
public void abortProcess() {
64113
lock.lock();
65114
try {

testing/internal-integ-testing/src/main/groovy/org/gradle/integtests/fixtures/executer/AbstractGradleExecuter.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1726,5 +1726,10 @@ public void waitForExit() {
17261726
public boolean isRunning() {
17271727
return delegate.isRunning();
17281728
}
1729+
1730+
@Override
1731+
public GradleHandle sendSignal(int signal) {
1732+
return delegate.sendSignal(signal);
1733+
}
17291734
}
17301735
}

testing/internal-integ-testing/src/main/groovy/org/gradle/integtests/fixtures/executer/ForkingGradleHandle.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,12 @@ public GradleHandle abort() {
172172
return this;
173173
}
174174

175+
@Override
176+
public GradleHandle sendSignal(int signal) {
177+
getExecHandle().sendSignal(signal);
178+
return this;
179+
}
180+
175181
@Override
176182
public boolean isRunning() {
177183
ExecHandle execHandle = this.execHandleRef.get();

0 commit comments

Comments
 (0)