Skip to content

Commit 47cb783

Browse files
authored
Fix OSC 9;4 progress bar not cleared on SIGINT (#37038)
2 parents 0a84d67 + ef03f1d commit 47cb783

12 files changed

Lines changed: 269 additions & 9 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/integTest/groovy/org/gradle/api/CrossBuildScriptCachingIntegrationSpec.groovy

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -674,10 +674,13 @@ class CrossBuildScriptCachingIntegrationSpec extends AbstractIntegrationSpec {
674674
}
675675

676676
void scriptsAreReused(List<ClassDetails> before, List<ClassDetails> after) {
677-
assert before.size() == after.size()
678-
for (int i = 0; i < before.size(); i++) {
679-
def script1 = before[i]
680-
def script2 = after[i]
677+
def sort = { List<ClassDetails> list -> list.sort(false) { a, b -> a.path <=> b.path ?: a.className <=> b.className } }
678+
def sortedBefore = sort(before)
679+
def sortedAfter = sort(after)
680+
assert sortedBefore.size() == sortedAfter.size()
681+
for (int i = 0; i < sortedBefore.size(); i++) {
682+
def script1 = sortedBefore[i]
683+
def script2 = sortedAfter[i]
681684
assert script1.path == script2.path
682685
assert script1.className == script2.className
683686
assert script1.classpath == script2.classpath

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
}

0 commit comments

Comments
 (0)