Skip to content

Commit f1ccbd4

Browse files
authored
Merge pull request #3851 from gian21391/update-replace-thread-stop
Update: replace removed Thread.stop() with stream-close cancellation
2 parents 978dbfa + 73ac4b9 commit f1ccbd4

2 files changed

Lines changed: 235 additions & 31 deletions

File tree

app/update/src/main/java/org/phoebus/applications/update/Update.java

Lines changed: 77 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import java.io.BufferedInputStream;
1111
import java.io.File;
12+
import java.io.IOException;
1213
import java.io.InputStream;
1314
import java.nio.file.Files;
1415
import java.nio.file.StandardCopyOption;
@@ -19,6 +20,7 @@
1920
import java.util.concurrent.CountDownLatch;
2021
import java.util.concurrent.TimeUnit;
2122
import java.util.concurrent.atomic.AtomicLong;
23+
import java.util.concurrent.atomic.AtomicReference;
2224
import java.util.logging.Level;
2325
import java.util.logging.Logger;
2426
import java.util.prefs.Preferences;
@@ -163,38 +165,13 @@ protected File download(final JobMonitor monitor) throws Exception
163165
final File file = File.createTempFile("phoebus_update", ".zip");
164166
file.deleteOnExit();
165167

166-
// Watcher thread that displays file size in monitor
168+
// Watcher thread that displays file size in monitor.
169+
// Reference to the download stream so the watcher can close it on 'cancel'.
167170
final CountDownLatch done = new CountDownLatch(1);
168-
final Thread download_thread = Thread.currentThread();
169-
final Thread watcher = new Thread(() ->
170-
{
171-
try
172-
{
173-
while (! done.await(1, TimeUnit.SECONDS))
174-
{
175-
final long size = file.length();
176-
final long full = full_size.get();
177-
if (full > 0)
178-
{
179-
int percent = (int) ((size*100) / full);
180-
monitor.updateTaskName(String.format("Downloading %d %% (%.3f/%.3f MB)", percent, size/1.0e6, full/1.0e6));
181-
}
182-
else
183-
monitor.updateTaskName(String.format("Downloading %.3f MB", size/1.0e6));
184-
185-
// Force the download thread to stop on 'cancel'.
186-
// 'interrupt()' has no effect, and Files.copy is not
187-
// otherwise instrumented.
188-
if (monitor.isCanceled())
189-
download_thread.stop();
190-
}
191-
monitor.updateTaskName(String.format("Download Finished"));
192-
}
193-
catch (Exception ex)
194-
{
195-
logger.log(Level.WARNING, "Download watch thread", ex);
196-
}
197-
}, "Watch Download");
171+
final AtomicReference<InputStream> download_stream = new AtomicReference<>();
172+
final Thread watcher = new Thread(
173+
() -> watchDownload(monitor, file, full_size, done, download_stream),
174+
"Watch Download");
198175
watcher.setDaemon(true);
199176
watcher.start();
200177

@@ -203,6 +180,7 @@ protected File download(final JobMonitor monitor) throws Exception
203180
final InputStream src = getDownloadStream();
204181
)
205182
{
183+
download_stream.set(src);
206184
logger.info("Download into " + file);
207185
Files.copy(src, file.toPath(), StandardCopyOption.REPLACE_EXISTING);
208186
return file;
@@ -218,6 +196,74 @@ protected File download(final JobMonitor monitor) throws Exception
218196
}
219197
}
220198

199+
/** Watch an in-progress download, reporting size to the monitor and
200+
* aborting on 'cancel'.
201+
*
202+
* <p>Runs on the "Watch Download" thread until {@code done} is counted
203+
* down by {@link #download(JobMonitor)}.
204+
*
205+
* @param monitor {@link JobMonitor} to update and poll for cancellation
206+
* @param file Partially downloaded file, polled for its current size
207+
* @param full_size Expected total size, or &le; 0 if unknown
208+
* @param done Latch that the download signals once the copy has finished
209+
* @param download_stream Holds the source stream to close on 'cancel'
210+
*/
211+
private void watchDownload(final JobMonitor monitor,
212+
final File file,
213+
final AtomicLong full_size,
214+
final CountDownLatch done,
215+
final AtomicReference<InputStream> download_stream)
216+
{
217+
try
218+
{
219+
while (! done.await(1, TimeUnit.SECONDS))
220+
{
221+
final long size = file.length();
222+
final long full = full_size.get();
223+
if (full > 0)
224+
{
225+
int percent = (int) ((size*100) / full);
226+
monitor.updateTaskName(String.format("Downloading %d %% (%.3f/%.3f MB)", percent, size/1.0e6, full/1.0e6));
227+
}
228+
else
229+
monitor.updateTaskName(String.format("Downloading %.3f MB", size/1.0e6));
230+
231+
// Force the download to stop on 'cancel'.
232+
// 'interrupt()' has no effect, and Files.copy is not
233+
// otherwise instrumented, so close the source stream to
234+
// break the in-progress copy out of its blocking read.
235+
if (monitor.isCanceled())
236+
closeDownloadStream(download_stream.get());
237+
}
238+
monitor.updateTaskName(String.format("Download Finished"));
239+
}
240+
catch (Exception ex)
241+
{
242+
logger.log(Level.WARNING, "Download watch thread", ex);
243+
}
244+
}
245+
246+
/** Close the source stream to abort a cancelled download.
247+
*
248+
* <p>A failed close is logged rather than thrown so it doesn't end the
249+
* watcher; it will retry on the next poll.
250+
*
251+
* @param src Stream to close, may be <code>null</code>
252+
*/
253+
private void closeDownloadStream(final InputStream src)
254+
{
255+
if (src == null)
256+
return;
257+
try
258+
{
259+
src.close();
260+
}
261+
catch (IOException ex)
262+
{
263+
logger.log(Level.WARNING, "Cannot close download stream on cancel", ex);
264+
}
265+
}
266+
221267
/** Update installation
222268
* @param monitor {@link JobMonitor}
223269
* @param install_location Existing {@link Locations#install()}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
/*******************************************************************************
2+
* Copyright (c) 2026 Oak Ridge National Laboratory.
3+
* All rights reserved. This program and the accompanying materials
4+
* are made available under the terms of the Eclipse Public License v1.0
5+
* which accompanies this distribution, and is available at
6+
* http://www.eclipse.org/legal/epl-v10.html
7+
******************************************************************************/
8+
package org.phoebus.applications.update;
9+
10+
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
11+
import static org.junit.jupiter.api.Assertions.assertEquals;
12+
import static org.junit.jupiter.api.Assertions.assertThrows;
13+
import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
14+
15+
import java.io.ByteArrayInputStream;
16+
import java.io.File;
17+
import java.io.IOException;
18+
import java.io.InputStream;
19+
import java.nio.charset.StandardCharsets;
20+
import java.nio.file.Files;
21+
import java.time.Duration;
22+
import java.time.Instant;
23+
import java.util.concurrent.CountDownLatch;
24+
25+
import org.junit.jupiter.api.Test;
26+
import org.phoebus.framework.jobs.BasicJobMonitor;
27+
28+
/** Verify the download cancellation behavior of {@link Update}.
29+
*
30+
* <p>The download watcher used to force-cancel via the now-removed
31+
* {@code Thread.stop()}. It now closes the source stream on 'cancel',
32+
* which must unblock the in-progress {@code Files.copy} on both the
33+
* JDK 21 target (where {@code Thread.stop()} throws at runtime) and
34+
* newer JDKs (where it no longer exists at all).
35+
*
36+
* @author Gianluca Martino
37+
*/
38+
@SuppressWarnings("nls")
39+
public class DownloadCancelTest
40+
{
41+
/** JobMonitor that always reports 'cancelled' */
42+
private static class CancelledMonitor extends BasicJobMonitor
43+
{
44+
@Override
45+
public boolean isCanceled()
46+
{
47+
return true;
48+
}
49+
}
50+
51+
/** InputStream whose read() blocks until the stream is closed,
52+
* emulating a slow network download stuck inside Files.copy.
53+
* Records the name of the thread that first closed it so the
54+
* test can confirm the watcher (not JUnit's timeout interrupt)
55+
* performed the abort.
56+
*/
57+
private static class BlockingStream extends InputStream
58+
{
59+
private final CountDownLatch closed = new CountDownLatch(1);
60+
private volatile String closing_thread = null;
61+
62+
@Override
63+
public int read() throws IOException
64+
{
65+
try
66+
{
67+
closed.await();
68+
}
69+
catch (InterruptedException ex)
70+
{
71+
Thread.currentThread().interrupt();
72+
}
73+
throw new IOException("Stream closed");
74+
}
75+
76+
@Override
77+
public int read(final byte[] b, final int off, final int len) throws IOException
78+
{
79+
return read();
80+
}
81+
82+
@Override
83+
public void close()
84+
{
85+
if (closing_thread == null)
86+
closing_thread = Thread.currentThread().getName();
87+
closed.countDown();
88+
}
89+
90+
String getClosingThread()
91+
{
92+
return closing_thread;
93+
}
94+
}
95+
96+
/** Minimal {@link Update} that streams the given bytes. */
97+
private static Update updateStreaming(final InputStream stream)
98+
{
99+
return new Update()
100+
{
101+
@Override
102+
protected Instant getVersion()
103+
{
104+
return Instant.now();
105+
}
106+
107+
@Override
108+
protected Long getDownloadSize()
109+
{
110+
return 1000L;
111+
}
112+
113+
@Override
114+
protected InputStream getDownloadStream()
115+
{
116+
return stream;
117+
}
118+
};
119+
}
120+
121+
/** A cancelled download must abort instead of hanging,
122+
* and the abort must be performed by the watcher thread.
123+
*/
124+
@Test
125+
public void cancelAbortsDownload()
126+
{
127+
final BlockingStream stream = new BlockingStream();
128+
final Update update = updateStreaming(stream);
129+
130+
// The monitor reports 'cancelled', so the watcher must close the
131+
// stream within its ~1s poll interval, making download() throw
132+
// instead of blocking forever. Generous timeout avoids CI flakiness.
133+
assertTimeoutPreemptively(Duration.ofSeconds(30), () ->
134+
assertThrows(IOException.class, () -> update.download(new CancelledMonitor())));
135+
136+
// The abort must come from the watcher closing the stream, not from
137+
// JUnit's preemptive-timeout interrupt (which would mean it hung).
138+
assertEquals("Watch Download", stream.getClosingThread());
139+
}
140+
141+
/** A normal (non-cancelled) download must complete and return the file. */
142+
@Test
143+
public void normalDownloadCompletes() throws Exception
144+
{
145+
final byte[] payload = "phoebus update payload".getBytes(StandardCharsets.UTF_8);
146+
final Update update = updateStreaming(new ByteArrayInputStream(payload));
147+
148+
final File file = update.download(new BasicJobMonitor());
149+
try
150+
{
151+
assertArrayEquals(payload, Files.readAllBytes(file.toPath()));
152+
}
153+
finally
154+
{
155+
file.delete();
156+
}
157+
}
158+
}

0 commit comments

Comments
 (0)