From 5846d20ee9368059fe7f104c285665e2e8f869ef Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 16:44:41 +0000 Subject: [PATCH 01/13] feat(downloader): report release download progress via listener Stream the release asset in chunks and notify a DownloadProgressListener with bytes read and total size, so clients can render a real progress bar instead of an indefinite spinner. The existing downloadIfNeeded overload keeps working via a no-op listener. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CXTAaR4opi34idMG7ZZ8BV --- .../BslLanguageServerDownloader.java | 51 ++++++++++++++++--- .../downloader/DownloadProgressListener.java | 49 ++++++++++++++++++ .../BslLanguageServerDownloaderTest.java | 38 ++++++++++++++ 3 files changed, 132 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/github/_1c_syntax/utils/downloader/DownloadProgressListener.java diff --git a/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java b/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java index 24dc6ef..86f3faa 100644 --- a/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java +++ b/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java @@ -148,6 +148,24 @@ public Optional installedVersion() { * @throws IOException если сервер не установлен и его не удалось скачать */ public Path downloadIfNeeded(BslLanguageServerReleaseChannel channel) throws IOException { + return downloadIfNeeded(channel, DownloadProgressListener.NONE); + } + + /** + * Скачивает сервер при необходимости и возвращает путь к его исполняемому файлу, сообщая о + * прогрессе скачивания ассета. + * + *

Поведение совпадает с {@link #downloadIfNeeded(BslLanguageServerReleaseChannel)}; отличие — + * в передаче {@code progressListener}, который вызывается по мере вычитывания тела ответа + * (только когда действительно происходит скачивание новой версии). + * + * @param channel канал релизов (стабильный / pre-release) + * @param progressListener слушатель прогресса скачивания ассета + * @return путь к исполняемому файлу сервера + * @throws IOException если сервер не установлен и его не удалось скачать + */ + public Path downloadIfNeeded(BslLanguageServerReleaseChannel channel, + DownloadProgressListener progressListener) throws IOException { var installed = installedVersion().orElse(null); if (installed != null && !checkIntervalElapsed()) { @@ -173,7 +191,7 @@ public Path downloadIfNeeded(BslLanguageServerReleaseChannel channel) throws IOE if (installed == null || compareVersions(latestVersion, installed) > 0) { LOGGER.info("Downloading BSL Language Server {}", latestVersion); try { - downloadAndExtract(release, latestVersion); + downloadAndExtract(release, latestVersion, progressListener); cleanupOtherVersions(latestVersion); installed = latestVersion; } catch (IOException e) { @@ -234,7 +252,8 @@ private GHRelease latestRelease(BslLanguageServerReleaseChannel channel) throws return release; } - private void downloadAndExtract(GHRelease release, String version) throws IOException { + private void downloadAndExtract(GHRelease release, String version, + DownloadProgressListener progressListener) throws IOException { var assetName = assetName(); var downloadUrl = release.listAssets().toList().stream() .filter(asset -> asset.getName().equals(assetName)) @@ -248,7 +267,7 @@ private void downloadAndExtract(GHRelease release, String version) throws IOExce var versionDir = installDir.resolve(version); try { - download(downloadUrl, archive); + download(downloadUrl, archive, progressListener); deleteRecursively(versionDir); extract(archive, versionDir); } finally { @@ -256,7 +275,8 @@ private void downloadAndExtract(GHRelease release, String version) throws IOExce } } - private void download(String url, Path destination) throws IOException { + private void download(String url, Path destination, DownloadProgressListener progressListener) + throws IOException { var client = HttpClient.newBuilder() .followRedirects(HttpClient.Redirect.NORMAL) .connectTimeout(CONNECT_TIMEOUT) @@ -267,16 +287,35 @@ private void download(String url, Path destination) throws IOException { .GET() .build(); try { - var response = client.send(request, HttpResponse.BodyHandlers.ofFile(destination)); + var response = client.send(request, HttpResponse.BodyHandlers.ofInputStream()); if (response.statusCode() != 200) { - throw new IOException("Failed to download " + url + ": HTTP " + response.statusCode()); + try (var ignored = response.body()) { + throw new IOException("Failed to download " + url + ": HTTP " + response.statusCode()); + } } + var totalBytes = response.headers().firstValueAsLong("Content-Length").orElse(-1L); + copyToFile(response.body(), destination, totalBytes, progressListener); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IOException("BSL Language Server download was interrupted", e); } } + static void copyToFile(InputStream source, Path destination, long totalBytes, + DownloadProgressListener progressListener) throws IOException { + var buffer = new byte[16 * 1024]; + long readTotal = 0; + progressListener.onProgress(0, totalBytes); + try (var input = source; var output = Files.newOutputStream(destination)) { + int read; + while ((read = input.read(buffer)) != -1) { + output.write(buffer, 0, read); + readTotal += read; + progressListener.onProgress(readTotal, totalBytes); + } + } + } + private static void extract(Path archive, Path targetDir) throws IOException { Files.createDirectories(targetDir); try (var zip = ZipFile.builder().setPath(archive).get()) { diff --git a/src/main/java/com/github/_1c_syntax/utils/downloader/DownloadProgressListener.java b/src/main/java/com/github/_1c_syntax/utils/downloader/DownloadProgressListener.java new file mode 100644 index 0000000..a4fdf3c --- /dev/null +++ b/src/main/java/com/github/_1c_syntax/utils/downloader/DownloadProgressListener.java @@ -0,0 +1,49 @@ +/* + * This file is a part of 1c-syntax utils. + * + * Copyright (c) 2018-2026 + * Alexey Sosnoviy , Nikita Fedkin and contributors + * + * SPDX-License-Identifier: LGPL-3.0-or-later + * + * 1c-syntax utils is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3.0 of the License, or (at your option) any later version. + * + * 1c-syntax utils is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with 1c-syntax utils. + */ +package com.github._1c_syntax.utils.downloader; + +/** + * Слушатель прогресса скачивания ассета релиза. + * + *

Вызывается загрузчиком по мере вычитывания тела ответа: сначала один раз с нулём вычитанных + * байт, затем после каждого прочитанного блока. Позволяет клиенту (например, IntelliJ-плагину) + * рисовать прогресс-бар вместо неопределённого «крутящегося» индикатора. + * + *

Реализация может бросить {@link RuntimeException} (в частности, для отмены операции) — + * исключение пробрасывается наружу через вызов загрузки, а частично скачанный файл удаляется. + */ +@FunctionalInterface +public interface DownloadProgressListener { + + /** + * Слушатель, ничего не делающий — значение по умолчанию, когда прогресс не нужен. + */ + DownloadProgressListener NONE = (bytesRead, totalBytes) -> { + // no-op + }; + + /** + * @param bytesRead сколько байт ассета уже скачано + * @param totalBytes полный размер ассета в байтах или {@code -1}, если сервер его не сообщил + */ + void onProgress(long bytesRead, long totalBytes); +} diff --git a/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java b/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java index 29dd0a4..7009695 100644 --- a/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java +++ b/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java @@ -26,9 +26,13 @@ import org.junit.jupiter.api.condition.OS; import org.junit.jupiter.api.io.TempDir; +import java.io.ByteArrayInputStream; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -82,6 +86,40 @@ void installedBinaryResolvesLinuxLauncher(@TempDir Path installDir) throws IOExc assertThat(downloader.installedBinary()).contains(binary); } + @Test + void copyToFileWritesContentAndReportsProgress(@TempDir Path installDir) throws IOException { + var payload = "bsl-language-server".getBytes(StandardCharsets.UTF_8); + var destination = installDir.resolve("asset.bin"); + var progress = new ArrayList(); + + BslLanguageServerDownloader.copyToFile( + new ByteArrayInputStream(payload), + destination, + payload.length, + (bytesRead, totalBytes) -> progress.add(new long[]{bytesRead, totalBytes})); + + assertThat(Files.readAllBytes(destination)).isEqualTo(payload); + assertThat(progress).isNotEmpty(); + assertThat(progress.get(0)).containsExactly(0, payload.length); + var last = progress.get(progress.size() - 1); + assertThat(last).containsExactly(payload.length, payload.length); + assertThat(last[0]).isLessThanOrEqualTo(last[1]); + } + + @Test + void copyToFilePropagatesUnknownTotalSize(@TempDir Path installDir) throws IOException { + var destination = installDir.resolve("asset.bin"); + List totals = new ArrayList<>(); + + BslLanguageServerDownloader.copyToFile( + new ByteArrayInputStream(new byte[]{1, 2, 3}), + destination, + -1, + (bytesRead, totalBytes) -> totals.add(totalBytes)); + + assertThat(totals).isNotEmpty().allMatch(total -> total == -1); + } + private static void writeServerInfo(Path installDir, String version) throws IOException { Files.createDirectories(installDir); Files.writeString(installDir.resolve("SERVER-INFO"), From f1e6f127d3917a76f9bd3f5b8edba89358fb4c74 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 17:42:50 +0000 Subject: [PATCH 02/13] fix(downloader): close body stream safely and bound body read time Address review feedback on the streaming download: - move the initial onProgress call inside the try-with-resources so a throwing listener (e.g. cancellation) cannot leak the response body stream - bound the lazily-read body with DOWNLOAD_TIMEOUT via a watchdog that closes the stream on deadline, restoring the timeout coverage lost when switching from ofFile to ofInputStream - add a test covering multi-chunk copying with monotonically increasing progress Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CXTAaR4opi34idMG7ZZ8BV --- .../BslLanguageServerDownloader.java | 34 +++++++++++++++++-- .../BslLanguageServerDownloaderTest.java | 22 ++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java b/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java index 86f3faa..f9fe010 100644 --- a/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java +++ b/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java @@ -50,6 +50,8 @@ import java.util.Optional; import java.util.Properties; import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; import java.util.stream.Stream; import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; @@ -294,19 +296,39 @@ private void download(String url, Path destination, DownloadProgressListener pro } } var totalBytes = response.headers().firstValueAsLong("Content-Length").orElse(-1L); - copyToFile(response.body(), destination, totalBytes, progressListener); + copyBodyWithDeadline(response.body(), destination, totalBytes, progressListener); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IOException("BSL Language Server download was interrupted", e); } } + /** + * Копирует тело ответа в файл, ограничивая общую длительность загрузки {@link #DOWNLOAD_TIMEOUT}. + * + *

С {@code BodyHandlers.ofInputStream()} таймаут запроса покрывает лишь ожидание заголовков, + * а тело читается лениво в {@link #copyToFile}. Чтобы «зависшее» на середине соединение не + * блокировало загрузку навсегда, сторож по истечении дедлайна закрывает поток — тогда читающий + * {@code read()} прерывается исключением. При успешном завершении сторож отменяется. + */ + private static void copyBodyWithDeadline(InputStream body, Path destination, long totalBytes, + DownloadProgressListener progressListener) throws IOException { + var watchdog = CompletableFuture.runAsync( + () -> closeQuietly(body), + CompletableFuture.delayedExecutor(DOWNLOAD_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)); + try { + copyToFile(body, destination, totalBytes, progressListener); + } finally { + watchdog.cancel(false); + } + } + static void copyToFile(InputStream source, Path destination, long totalBytes, DownloadProgressListener progressListener) throws IOException { var buffer = new byte[16 * 1024]; long readTotal = 0; - progressListener.onProgress(0, totalBytes); try (var input = source; var output = Files.newOutputStream(destination)) { + progressListener.onProgress(0, totalBytes); int read; while ((read = input.read(buffer)) != -1) { output.write(buffer, 0, read); @@ -316,6 +338,14 @@ static void copyToFile(InputStream source, Path destination, long totalBytes, } } + private static void closeQuietly(InputStream stream) { + try { + stream.close(); + } catch (IOException e) { + LOGGER.debug("Failed to close BSL Language Server download stream", e); + } + } + private static void extract(Path archive, Path targetDir) throws IOException { Files.createDirectories(targetDir); try (var zip = ZipFile.builder().setPath(archive).get()) { diff --git a/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java b/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java index 7009695..27e1060 100644 --- a/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java +++ b/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java @@ -106,6 +106,28 @@ void copyToFileWritesContentAndReportsProgress(@TempDir Path installDir) throws assertThat(last[0]).isLessThanOrEqualTo(last[1]); } + @Test + void copyToFileReportsProgressAcrossMultipleChunks(@TempDir Path installDir) throws IOException { + // Больше буфера copyToFile (16 КБ), чтобы цикл чтения выполнился несколько раз. + var payload = new byte[40 * 1024]; + for (int i = 0; i < payload.length; i++) { + payload[i] = (byte) i; + } + var destination = installDir.resolve("asset.bin"); + var progress = new ArrayList(); + + BslLanguageServerDownloader.copyToFile( + new ByteArrayInputStream(payload), + destination, + payload.length, + (bytesRead, totalBytes) -> progress.add(new long[]{bytesRead, totalBytes})); + + assertThat(Files.readAllBytes(destination)).isEqualTo(payload); + assertThat(progress.size()).isGreaterThan(2); + assertThat(progress).map(it -> it[0]).isSorted(); + assertThat(progress.get(progress.size() - 1)).containsExactly(payload.length, payload.length); + } + @Test void copyToFilePropagatesUnknownTotalSize(@TempDir Path installDir) throws IOException { var destination = installDir.resolve("asset.bin"); From 3003afd562f739a3fd391192e1e49cedba7590c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 17:44:20 +0000 Subject: [PATCH 03/13] test(downloader): use hasSizeGreaterThan for progress assertion Addresses SonarCloud java:S5838 on the multi-chunk progress test. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CXTAaR4opi34idMG7ZZ8BV --- .../utils/downloader/BslLanguageServerDownloaderTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java b/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java index 27e1060..8377bc7 100644 --- a/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java +++ b/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java @@ -123,7 +123,7 @@ void copyToFileReportsProgressAcrossMultipleChunks(@TempDir Path installDir) thr (bytesRead, totalBytes) -> progress.add(new long[]{bytesRead, totalBytes})); assertThat(Files.readAllBytes(destination)).isEqualTo(payload); - assertThat(progress.size()).isGreaterThan(2); + assertThat(progress).hasSizeGreaterThan(2); assertThat(progress).map(it -> it[0]).isSorted(); assertThat(progress.get(progress.size() - 1)).containsExactly(payload.length, payload.length); } From cd6a8944c72d39442fb3579f59ed51bc3149b393 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 20:01:05 +0000 Subject: [PATCH 04/13] perf(downloader): use a 256 KiB copy buffer for large archives The server archives are tens to hundreds of MB; the JDK-default 16 KiB buffer means thousands of read/write syscalls and progress callbacks per download. Enlarge the copy buffer to cut syscalls and callback frequency proportionally. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CXTAaR4opi34idMG7ZZ8BV --- .../utils/downloader/BslLanguageServerDownloader.java | 5 ++++- .../utils/downloader/BslLanguageServerDownloaderTest.java | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java b/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java index f9fe010..11813e8 100644 --- a/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java +++ b/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java @@ -80,6 +80,9 @@ public class BslLanguageServerDownloader { private static final Duration DEFAULT_CHECK_INTERVAL = Duration.ofMinutes(8); private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(30); private static final Duration DOWNLOAD_TIMEOUT = Duration.ofMinutes(10); + // Крупнее дефолта JDK (16 КБ у InputStream.transferTo): архивы сервера — десятки-сотни МБ, + // так меньше syscall'ов на чтение/запись и реже дёргается прогресс-колбэк. + private static final int DOWNLOAD_BUFFER_SIZE = 256 * 1024; private static final boolean POSIX = FileSystems.getDefault().supportedFileAttributeViews().contains("posix"); @@ -325,7 +328,7 @@ private static void copyBodyWithDeadline(InputStream body, Path destination, lon static void copyToFile(InputStream source, Path destination, long totalBytes, DownloadProgressListener progressListener) throws IOException { - var buffer = new byte[16 * 1024]; + var buffer = new byte[DOWNLOAD_BUFFER_SIZE]; long readTotal = 0; try (var input = source; var output = Files.newOutputStream(destination)) { progressListener.onProgress(0, totalBytes); diff --git a/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java b/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java index 8377bc7..fc65c3f 100644 --- a/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java +++ b/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java @@ -108,8 +108,8 @@ void copyToFileWritesContentAndReportsProgress(@TempDir Path installDir) throws @Test void copyToFileReportsProgressAcrossMultipleChunks(@TempDir Path installDir) throws IOException { - // Больше буфера copyToFile (16 КБ), чтобы цикл чтения выполнился несколько раз. - var payload = new byte[40 * 1024]; + // Больше буфера copyToFile, чтобы цикл чтения выполнился несколько раз. + var payload = new byte[1024 * 1024]; for (int i = 0; i < payload.length; i++) { payload[i] = (byte) i; } From 7df66fdd2ab2cbd93693a238304a3a2706855c3f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 20:07:17 +0000 Subject: [PATCH 05/13] refactor(downloader): give the body stream a single owner copyToFile no longer closes the stream it is handed; copyBodyWithDeadline owns it via try-with-resources. The timeout watchdog stays a close-to-interrupt only and is cancelled on the normal path, so the stream is closed exactly once unless the deadline fires (where the second, idempotent close is the interrupt itself). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CXTAaR4opi34idMG7ZZ8BV --- .../downloader/BslLanguageServerDownloader.java | 12 +++++++++--- .../BslLanguageServerDownloaderTest.java | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java b/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java index 11813e8..1fb524e 100644 --- a/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java +++ b/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java @@ -316,24 +316,30 @@ private void download(String url, Path destination, DownloadProgressListener pro */ private static void copyBodyWithDeadline(InputStream body, Path destination, long totalBytes, DownloadProgressListener progressListener) throws IOException { + // Единственный владелец потока — этот try (body). Вотчдог по таймауту лишь закрывает поток, + // чтобы прервать зависшее чтение; при штатном завершении он отменяется и не выполняется. var watchdog = CompletableFuture.runAsync( () -> closeQuietly(body), CompletableFuture.delayedExecutor(DOWNLOAD_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)); - try { + try (body) { copyToFile(body, destination, totalBytes, progressListener); } finally { watchdog.cancel(false); } } + /** + * Копирует {@code source} в файл, сообщая о прогрессе. Поток {@code source} не закрывает — + * этим владеет вызывающий код. + */ static void copyToFile(InputStream source, Path destination, long totalBytes, DownloadProgressListener progressListener) throws IOException { var buffer = new byte[DOWNLOAD_BUFFER_SIZE]; long readTotal = 0; - try (var input = source; var output = Files.newOutputStream(destination)) { + try (var output = Files.newOutputStream(destination)) { progressListener.onProgress(0, totalBytes); int read; - while ((read = input.read(buffer)) != -1) { + while ((read = source.read(buffer)) != -1) { output.write(buffer, 0, read); readTotal += read; progressListener.onProgress(readTotal, totalBytes); diff --git a/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java b/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java index fc65c3f..4221751 100644 --- a/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java +++ b/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java @@ -128,6 +128,22 @@ void copyToFileReportsProgressAcrossMultipleChunks(@TempDir Path installDir) thr assertThat(progress.get(progress.size() - 1)).containsExactly(payload.length, payload.length); } + @Test + void copyToFileLeavesSourceOpenForCaller(@TempDir Path installDir) throws IOException { + var closed = new java.util.concurrent.atomic.AtomicInteger(); + var source = new ByteArrayInputStream(new byte[]{1, 2, 3}) { + @Override + public void close() { + closed.incrementAndGet(); + } + }; + + BslLanguageServerDownloader.copyToFile( + source, installDir.resolve("asset.bin"), 3, DownloadProgressListener.NONE); + + assertThat(closed.get()).isZero(); + } + @Test void copyToFilePropagatesUnknownTotalSize(@TempDir Path installDir) throws IOException { var destination = installDir.resolve("asset.bin"); From e9e955cf8cfc6a3b96add09d176a703811603bbb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 20:30:29 +0000 Subject: [PATCH 06/13] refactor(downloader): close the response stream in its owning method Move stream ownership to download(), where response.body() is obtained: it opens the stream in a try-with-resources and hosts the timeout watchdog, so nothing closes a stream received as a parameter. copyToFile only reads. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CXTAaR4opi34idMG7ZZ8BV --- .../BslLanguageServerDownloader.java | 37 +++++++------------ 1 file changed, 14 insertions(+), 23 deletions(-) diff --git a/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java b/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java index 1fb524e..d8ccb1e 100644 --- a/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java +++ b/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java @@ -299,35 +299,26 @@ private void download(String url, Path destination, DownloadProgressListener pro } } var totalBytes = response.headers().firstValueAsLong("Content-Length").orElse(-1L); - copyBodyWithDeadline(response.body(), destination, totalBytes, progressListener); + // Владелец потока — этот блок (здесь взят response.body()), он же его и закрывает. + // Тело читается лениво, поэтому request.timeout его не покрывает: по дедлайну сторож + // закрывает поток — единственный способ разбудить заблокированный read(). При штатном + // завершении сторож отменяется. copyToFile поток не закрывает. + try (var body = response.body()) { + var watchdog = CompletableFuture.runAsync( + () -> closeQuietly(body), + CompletableFuture.delayedExecutor(DOWNLOAD_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)); + try { + copyToFile(body, destination, totalBytes, progressListener); + } finally { + watchdog.cancel(false); + } + } } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IOException("BSL Language Server download was interrupted", e); } } - /** - * Копирует тело ответа в файл, ограничивая общую длительность загрузки {@link #DOWNLOAD_TIMEOUT}. - * - *

С {@code BodyHandlers.ofInputStream()} таймаут запроса покрывает лишь ожидание заголовков, - * а тело читается лениво в {@link #copyToFile}. Чтобы «зависшее» на середине соединение не - * блокировало загрузку навсегда, сторож по истечении дедлайна закрывает поток — тогда читающий - * {@code read()} прерывается исключением. При успешном завершении сторож отменяется. - */ - private static void copyBodyWithDeadline(InputStream body, Path destination, long totalBytes, - DownloadProgressListener progressListener) throws IOException { - // Единственный владелец потока — этот try (body). Вотчдог по таймауту лишь закрывает поток, - // чтобы прервать зависшее чтение; при штатном завершении он отменяется и не выполняется. - var watchdog = CompletableFuture.runAsync( - () -> closeQuietly(body), - CompletableFuture.delayedExecutor(DOWNLOAD_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)); - try (body) { - copyToFile(body, destination, totalBytes, progressListener); - } finally { - watchdog.cancel(false); - } - } - /** * Копирует {@code source} в файл, сообщая о прогрессе. Поток {@code source} не закрывает — * этим владеет вызывающий код. From e570f7db5f51886a88dab1255d14c55d9f3bbe1b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 21:00:54 +0000 Subject: [PATCH 07/13] refactor(downloader): inject release catalog and http client for testability The HttpClient and GitHub client were built inside methods, so the downloadIfNeeded flow could not be exercised without hitting the network. Extract release lookup behind a ReleaseCatalog interface (GitHubReleaseCatalog is the default impl) and inject the HttpClient via constructor. Public constructors keep their signatures and wire the GitHub-backed defaults. Add end-to-end tests for the full flow using a fake catalog and a loopback HTTP server serving a real zip: download+extract+record version, progress reporting, interval skip, offline fallback to installed, failure with nothing installed, and channel pass-through. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CXTAaR4opi34idMG7ZZ8BV --- .../BslLanguageServerDownloader.java | 91 ++++---- .../downloader/GitHubReleaseCatalog.java | 89 ++++++++ .../utils/downloader/ReleaseCatalog.java | 51 +++++ .../BslLanguageServerDownloaderTest.java | 198 +++++++++++++++++- 4 files changed, 376 insertions(+), 53 deletions(-) create mode 100644 src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseCatalog.java create mode 100644 src/main/java/com/github/_1c_syntax/utils/downloader/ReleaseCatalog.java diff --git a/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java b/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java index d8ccb1e..993cb00 100644 --- a/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java +++ b/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java @@ -25,11 +25,6 @@ import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; import org.apache.commons.compress.archivers.zip.ZipFile; import org.jspecify.annotations.Nullable; -import org.kohsuke.github.GHRelease; -import org.kohsuke.github.GHRepository; -import org.kohsuke.github.GitHub; -import org.kohsuke.github.GitHubBuilder; -import org.kohsuke.github.extras.HttpClientGitHubConnector; import org.semver4j.Semver; import java.io.IOException; @@ -73,7 +68,6 @@ @Slf4j public class BslLanguageServerDownloader { - private static final String REPOSITORY = "1c-syntax/bsl-language-server"; private static final String SERVER_INFO_FILE = "SERVER-INFO"; private static final String INFO_VERSION = "version"; private static final String INFO_LAST_UPDATE = "lastUpdate"; @@ -88,8 +82,9 @@ public class BslLanguageServerDownloader { FileSystems.getDefault().supportedFileAttributeViews().contains("posix"); private final Path installDir; - private final @Nullable String token; private final Duration checkInterval; + private final ReleaseCatalog releaseCatalog; + private final HttpClient httpClient; /** * @param installDir каталог установки сервера; в нём создаются подпапки с версиями @@ -113,9 +108,36 @@ public BslLanguageServerDownloader(Path installDir, @Nullable String token) { * @param checkInterval минимальный интервал между обращениями к GitHub API за новой версией */ public BslLanguageServerDownloader(Path installDir, @Nullable String token, Duration checkInterval) { + this(installDir, checkInterval, token, defaultHttpClient()); + } + + private BslLanguageServerDownloader(Path installDir, Duration checkInterval, + @Nullable String token, HttpClient httpClient) { + this(installDir, checkInterval, new GitHubReleaseCatalog(httpClient, token), httpClient); + } + + /** + * Конструктор с явными зависимостями — для тестов и нестандартных сценариев: позволяет + * подменить источник релизов и HTTP-клиент, чтобы прогонять весь поток скачивания без сети. + * + * @param installDir каталог установки сервера + * @param checkInterval минимальный интервал между обращениями за новой версией + * @param releaseCatalog источник сведений о релизах + * @param httpClient HTTP-клиент для скачивания ассета + */ + BslLanguageServerDownloader(Path installDir, Duration checkInterval, + ReleaseCatalog releaseCatalog, HttpClient httpClient) { this.installDir = installDir.toAbsolutePath(); - this.token = token; this.checkInterval = checkInterval; + this.releaseCatalog = releaseCatalog; + this.httpClient = httpClient; + } + + private static HttpClient defaultHttpClient() { + return HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NORMAL) + .connectTimeout(CONNECT_TIMEOUT) + .build(); } /** @@ -178,9 +200,9 @@ public Path downloadIfNeeded(BslLanguageServerReleaseChannel channel, return binaryPath(installed); } - GHRelease release; + ReleaseCatalog.ReleaseInfo release; try { - release = latestRelease(channel); + release = releaseCatalog.latestRelease(channel); } catch (IOException e) { if (installed != null) { LOGGER.warn("Failed to fetch BSL Language Server releases, using installed version {}", @@ -191,7 +213,7 @@ public Path downloadIfNeeded(BslLanguageServerReleaseChannel channel, throw new IOException("Failed to fetch BSL Language Server releases", e); } - var latestVersion = normalizeVersion(release.getTagName()); + var latestVersion = normalizeVersion(release.version()); if (installed == null || compareVersions(latestVersion, installed) > 0) { LOGGER.info("Downloading BSL Language Server {}", latestVersion); @@ -228,44 +250,13 @@ private boolean checkIntervalElapsed() { } } - private GHRelease latestRelease(BslLanguageServerReleaseChannel channel) throws IOException { - var httpClient = HttpClient.newBuilder() - .connectTimeout(CONNECT_TIMEOUT) - .build(); - var builder = new GitHubBuilder().withConnector(new HttpClientGitHubConnector(httpClient)); - if (token != null && !token.isBlank()) { - builder.withOAuthToken(token); - } - GitHub github = builder.build(); - - GHRepository repository = github.getRepository(REPOSITORY); - - GHRelease release; - if (channel == BslLanguageServerReleaseChannel.PRERELEASE) { - // GitHub отдаёт релизы newest-first — берём первый не-draft. - release = repository.listReleases().toList().stream() - .filter(it -> !it.isDraft()) - .findFirst() - .orElse(null); - } else { - release = repository.getLatestRelease(); - } - - if (release == null) { - throw new IOException("Repository " + REPOSITORY + " has no suitable releases for channel " + channel); - } - return release; - } - - private void downloadAndExtract(GHRelease release, String version, + private void downloadAndExtract(ReleaseCatalog.ReleaseInfo release, String version, DownloadProgressListener progressListener) throws IOException { var assetName = assetName(); - var downloadUrl = release.listAssets().toList().stream() - .filter(asset -> asset.getName().equals(assetName)) - .map(asset -> asset.getBrowserDownloadUrl()) - .findFirst() - .orElseThrow(() -> new IOException( - "BSL Language Server release " + version + " has no asset " + assetName)); + var downloadUrl = release.assetDownloadUrls().get(assetName); + if (downloadUrl == null) { + throw new IOException("BSL Language Server release " + version + " has no asset " + assetName); + } Files.createDirectories(installDir); var archive = installDir.resolve(version + "-download-" + assetName); @@ -282,17 +273,13 @@ private void downloadAndExtract(GHRelease release, String version, private void download(String url, Path destination, DownloadProgressListener progressListener) throws IOException { - var client = HttpClient.newBuilder() - .followRedirects(HttpClient.Redirect.NORMAL) - .connectTimeout(CONNECT_TIMEOUT) - .build(); var request = HttpRequest.newBuilder(URI.create(url)) .header("User-Agent", "1c-syntax-utils") .timeout(DOWNLOAD_TIMEOUT) .GET() .build(); try { - var response = client.send(request, HttpResponse.BodyHandlers.ofInputStream()); + var response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); if (response.statusCode() != 200) { try (var ignored = response.body()) { throw new IOException("Failed to download " + url + ": HTTP " + response.statusCode()); diff --git a/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseCatalog.java b/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseCatalog.java new file mode 100644 index 0000000..7fa3e31 --- /dev/null +++ b/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseCatalog.java @@ -0,0 +1,89 @@ +/* + * This file is a part of 1c-syntax utils. + * + * Copyright (c) 2018-2026 + * Alexey Sosnoviy , Nikita Fedkin and contributors + * + * SPDX-License-Identifier: LGPL-3.0-or-later + * + * 1c-syntax utils is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3.0 of the License, or (at your option) any later version. + * + * 1c-syntax utils is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with 1c-syntax utils. + */ +package com.github._1c_syntax.utils.downloader; + +import org.jspecify.annotations.Nullable; +import org.kohsuke.github.GHAsset; +import org.kohsuke.github.GHRelease; +import org.kohsuke.github.GHRepository; +import org.kohsuke.github.GitHub; +import org.kohsuke.github.GitHubBuilder; +import org.kohsuke.github.extras.HttpClientGitHubConnector; + +import java.io.IOException; +import java.net.http.HttpClient; +import java.util.HashMap; +import java.util.Map; + +/** + * {@link ReleaseCatalog} поверх GitHub API. Ходит в репозиторий {@value #REPOSITORY} за последним + * релизом канала через переданный {@link HttpClient}. + */ +final class GitHubReleaseCatalog implements ReleaseCatalog { + + private static final String REPOSITORY = "1c-syntax/bsl-language-server"; + + private final HttpClient httpClient; + private final @Nullable String token; + + /** + * @param httpClient HTTP-клиент, через который github-api обращается к GitHub + * @param token GitHub OAuth-токен для обхода лимитов анонимного API; может быть {@code null} + */ + GitHubReleaseCatalog(HttpClient httpClient, @Nullable String token) { + this.httpClient = httpClient; + this.token = token; + } + + @Override + public ReleaseInfo latestRelease(BslLanguageServerReleaseChannel channel) throws IOException { + var builder = new GitHubBuilder().withConnector(new HttpClientGitHubConnector(httpClient)); + if (token != null && !token.isBlank()) { + builder.withOAuthToken(token); + } + GitHub github = builder.build(); + + GHRepository repository = github.getRepository(REPOSITORY); + + GHRelease release; + if (channel == BslLanguageServerReleaseChannel.PRERELEASE) { + // GitHub отдаёт релизы newest-first — берём первый не-draft. + release = repository.listReleases().toList().stream() + .filter(it -> !it.isDraft()) + .findFirst() + .orElse(null); + } else { + release = repository.getLatestRelease(); + } + + if (release == null) { + throw new IOException( + "Repository " + REPOSITORY + " has no suitable releases for channel " + channel); + } + + var assetUrls = new HashMap(); + for (GHAsset asset : release.listAssets().toList()) { + assetUrls.putIfAbsent(asset.getName(), asset.getBrowserDownloadUrl()); + } + return new ReleaseInfo(release.getTagName(), Map.copyOf(assetUrls)); + } +} diff --git a/src/main/java/com/github/_1c_syntax/utils/downloader/ReleaseCatalog.java b/src/main/java/com/github/_1c_syntax/utils/downloader/ReleaseCatalog.java new file mode 100644 index 0000000..858b163 --- /dev/null +++ b/src/main/java/com/github/_1c_syntax/utils/downloader/ReleaseCatalog.java @@ -0,0 +1,51 @@ +/* + * This file is a part of 1c-syntax utils. + * + * Copyright (c) 2018-2026 + * Alexey Sosnoviy , Nikita Fedkin and contributors + * + * SPDX-License-Identifier: LGPL-3.0-or-later + * + * 1c-syntax utils is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3.0 of the License, or (at your option) any later version. + * + * 1c-syntax utils is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with 1c-syntax utils. + */ +package com.github._1c_syntax.utils.downloader; + +import java.io.IOException; +import java.util.Map; + +/** + * Источник сведений о релизах BSL Language Server. Абстрагирует получение последнего релиза от + * конкретного транспорта (GitHub API), чтобы {@link BslLanguageServerDownloader} можно было + * тестировать без обращения к сети. + */ +interface ReleaseCatalog { + + /** + * Возвращает сведения о последнем релизе выбранного канала. + * + * @param channel канал релизов (стабильный / pre-release) + * @return версия релиза и ссылки на его ассеты + * @throws IOException если релизы недоступны или подходящего релиза нет + */ + ReleaseInfo latestRelease(BslLanguageServerReleaseChannel channel) throws IOException; + + /** + * Сведения о релизе, нужные загрузчику. + * + * @param version тег/версия релиза (может начинаться с {@code v}) + * @param assetDownloadUrls карта «имя ассета → URL для скачивания» + */ + record ReleaseInfo(String version, Map assetDownloadUrls) { + } +} diff --git a/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java b/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java index 4221751..8ae8cc4 100644 --- a/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java +++ b/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java @@ -21,20 +21,30 @@ */ package com.github._1c_syntax.utils.downloader; +import com.sun.net.httpserver.HttpServer; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.DisabledOnOs; import org.junit.jupiter.api.condition.OS; import org.junit.jupiter.api.io.TempDir; import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.http.HttpClient; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.time.Duration; import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; class BslLanguageServerDownloaderTest { @@ -158,9 +168,195 @@ void copyToFilePropagatesUnknownTotalSize(@TempDir Path installDir) throws IOExc assertThat(totals).isNotEmpty().allMatch(total -> total == -1); } + @Test + void downloadIfNeededDownloadsExtractsAndRecordsVersion(@TempDir Path installDir) throws IOException { + var archive = zipWithLaunchers(300 * 1024); + var server = startServer(archive); + try { + var catalog = new FakeCatalog( + new ReleaseCatalog.ReleaseInfo("v1.2.3", allOsAssets(assetUrl(server)))); + var downloader = new BslLanguageServerDownloader( + installDir, Duration.ZERO, catalog, testHttpClient()); + + var binary = downloader.downloadIfNeeded(BslLanguageServerReleaseChannel.STABLE); + + assertThat(binary).exists(); + assertThat(binary).startsWith(installDir.resolve("1.2.3")); + assertThat(downloader.installedVersion()).contains("1.2.3"); + } finally { + server.stop(0); + } + } + + @Test + void downloadIfNeededReportsProgressForTheAsset(@TempDir Path installDir) throws IOException { + var archive = zipWithLaunchers(300 * 1024); + var server = startServer(archive); + try { + var catalog = new FakeCatalog( + new ReleaseCatalog.ReleaseInfo("1.0.0", allOsAssets(assetUrl(server)))); + var downloader = new BslLanguageServerDownloader( + installDir, Duration.ZERO, catalog, testHttpClient()); + var progress = new ArrayList(); + + downloader.downloadIfNeeded(BslLanguageServerReleaseChannel.STABLE, + (bytesRead, totalBytes) -> progress.add(new long[]{bytesRead, totalBytes})); + + assertThat(progress).isNotEmpty(); + var last = progress.get(progress.size() - 1); + assertThat(last[0]).isEqualTo(archive.length); + assertThat(last[1]).isEqualTo(archive.length); + } finally { + server.stop(0); + } + } + + @Test + void downloadIfNeededSkipsCatalogWhenIntervalNotElapsed(@TempDir Path installDir) throws IOException { + writeServerInfoAt(installDir, "1.0.0", System.currentTimeMillis()); + var catalog = new ThrowingCatalog(); + var downloader = new BslLanguageServerDownloader( + installDir, Duration.ofMinutes(8), catalog, testHttpClient()); + + var binary = downloader.downloadIfNeeded(BslLanguageServerReleaseChannel.STABLE); + + assertThat(binary.toString()).contains("1.0.0"); + assertThat(catalog.called).isFalse(); + } + + @Test + void downloadIfNeededFallsBackToInstalledWhenCatalogFails(@TempDir Path installDir) throws IOException { + writeServerInfo(installDir, "1.0.0"); + var catalog = new ThrowingCatalog(); + var downloader = new BslLanguageServerDownloader( + installDir, Duration.ZERO, catalog, testHttpClient()); + + var binary = downloader.downloadIfNeeded(BslLanguageServerReleaseChannel.STABLE); + + assertThat(binary.toString()).contains("1.0.0"); + assertThat(catalog.called).isTrue(); + } + + @Test + void downloadIfNeededThrowsWhenNothingInstalledAndCatalogFails(@TempDir Path installDir) { + var catalog = new ThrowingCatalog(); + var downloader = new BslLanguageServerDownloader( + installDir, Duration.ZERO, catalog, testHttpClient()); + + assertThatThrownBy(() -> downloader.downloadIfNeeded(BslLanguageServerReleaseChannel.STABLE)) + .isInstanceOf(IOException.class); + } + + @Test + void downloadIfNeededPassesRequestedChannelToCatalog(@TempDir Path installDir) throws IOException { + writeServerInfo(installDir, "1.0.0"); + var catalog = new RecordingCatalog(new ReleaseCatalog.ReleaseInfo("1.0.0", Map.of())); + var downloader = new BslLanguageServerDownloader( + installDir, Duration.ZERO, catalog, testHttpClient()); + + downloader.downloadIfNeeded(BslLanguageServerReleaseChannel.PRERELEASE); + + assertThat(catalog.requestedChannel).isEqualTo(BslLanguageServerReleaseChannel.PRERELEASE); + } + + private static HttpClient testHttpClient() { + // newBuilder() без .proxy() ходит напрямую — то, что нужно для локального сервера. + return HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(30)).build(); + } + + private static HttpServer startServer(byte[] body) throws IOException { + var server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/asset.zip", exchange -> { + exchange.sendResponseHeaders(200, body.length); + try (var output = exchange.getResponseBody()) { + output.write(body); + } + }); + server.start(); + return server; + } + + private static String assetUrl(HttpServer server) { + return "http://127.0.0.1:" + server.getAddress().getPort() + "/asset.zip"; + } + + private static Map allOsAssets(String url) { + return Map.of( + "bsl-language-server_win.zip", url, + "bsl-language-server_mac.zip", url, + "bsl-language-server_nix.zip", url); + } + + /** + * Zip с раскладкой лаунчеров для всех трёх ОС (тест OS-агностичен) плюс крупный «полезный» + * файл, чтобы скачивание шло несколькими блоками. + */ + private static byte[] zipWithLaunchers(int payloadSize) throws IOException { + var payload = new byte[payloadSize]; + new Random(1).nextBytes(payload); + var bytes = new ByteArrayOutputStream(); + try (var zip = new ZipOutputStream(bytes)) { + putEntry(zip, "bsl-language-server/bsl-language-server.exe", new byte[]{1}); + putEntry(zip, "bsl-language-server/bin/bsl-language-server", new byte[]{1}); + putEntry(zip, "bsl-language-server.app/Contents/MacOS/bsl-language-server", new byte[]{1}); + putEntry(zip, "payload.bin", payload); + } + return bytes.toByteArray(); + } + + private static void putEntry(ZipOutputStream zip, String name, byte[] data) throws IOException { + zip.putNextEntry(new ZipEntry(name)); + zip.write(data); + zip.closeEntry(); + } + + private static final class FakeCatalog implements ReleaseCatalog { + private final ReleaseInfo info; + + FakeCatalog(ReleaseInfo info) { + this.info = info; + } + + @Override + public ReleaseInfo latestRelease(BslLanguageServerReleaseChannel channel) { + return info; + } + } + + private static final class ThrowingCatalog implements ReleaseCatalog { + private boolean called; + + @Override + public ReleaseInfo latestRelease(BslLanguageServerReleaseChannel channel) throws IOException { + called = true; + throw new IOException("no network"); + } + } + + private static final class RecordingCatalog implements ReleaseCatalog { + private final ReleaseInfo info; + private BslLanguageServerReleaseChannel requestedChannel; + + RecordingCatalog(ReleaseInfo info) { + this.info = info; + } + + @Override + public ReleaseInfo latestRelease(BslLanguageServerReleaseChannel channel) { + requestedChannel = channel; + return info; + } + } + private static void writeServerInfo(Path installDir, String version) throws IOException { + writeServerInfoAt(installDir, version, 0); + } + + private static void writeServerInfoAt(Path installDir, String version, long lastUpdate) + throws IOException { Files.createDirectories(installDir); Files.writeString(installDir.resolve("SERVER-INFO"), - "version=" + version + System.lineSeparator() + "lastUpdate=0" + System.lineSeparator()); + "version=" + version + System.lineSeparator() + + "lastUpdate=" + lastUpdate + System.lineSeparator()); } } From 0b9064d2c7808c9bb928caee40e3e03fa4032a7e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 21:09:35 +0000 Subject: [PATCH 08/13] test(downloader): mock HttpClient and drop the legacy constructors Replace the loopback HTTP server in the download tests with a mocked HttpClient (Mockito), so the full downloadIfNeeded flow runs with no real network at all. Collapse the telescoping constructors: keep one public GitHub convenience ctor (installDir, token) and one public dependency-injecting ctor (installDir, checkInterval, ReleaseCatalog, HttpClient); ReleaseCatalog is now public. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CXTAaR4opi34idMG7ZZ8BV --- build.gradle.kts | 1 + .../BslLanguageServerDownloader.java | 31 ++--- .../utils/downloader/ReleaseCatalog.java | 2 +- .../BslLanguageServerDownloaderTest.java | 119 +++++++++--------- 4 files changed, 70 insertions(+), 83 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 873ea75..5ebab45 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -54,6 +54,7 @@ dependencies { implementation("org.semver4j:semver4j:5.8.0") testImplementation("org.assertj:assertj-core:3.27.7") + testImplementation("org.mockito:mockito-core:5.14.2") testImplementation(platform("org.junit:junit-bom:6.1.1")) testImplementation("org.junit.jupiter:junit-jupiter-api") diff --git a/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java b/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java index 993cb00..ef9e40f 100644 --- a/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java +++ b/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java @@ -87,46 +87,33 @@ public class BslLanguageServerDownloader { private final HttpClient httpClient; /** + * Создаёт загрузчик, берущий релизы с GitHub через общий HTTP-клиент. + * * @param installDir каталог установки сервера; в нём создаются подпапки с версиями * и файл {@code SERVER-INFO} - */ - public BslLanguageServerDownloader(Path installDir) { - this(installDir, null); - } - - /** - * @param installDir каталог установки сервера * @param token GitHub OAuth-токен для обхода лимитов анонимного API; может быть {@code null} */ public BslLanguageServerDownloader(Path installDir, @Nullable String token) { - this(installDir, token, DEFAULT_CHECK_INTERVAL); - } - - /** - * @param installDir каталог установки сервера - * @param token GitHub OAuth-токен; может быть {@code null} - * @param checkInterval минимальный интервал между обращениями к GitHub API за новой версией - */ - public BslLanguageServerDownloader(Path installDir, @Nullable String token, Duration checkInterval) { - this(installDir, checkInterval, token, defaultHttpClient()); + this(installDir, DEFAULT_CHECK_INTERVAL, defaultHttpClient(), token); } private BslLanguageServerDownloader(Path installDir, Duration checkInterval, - @Nullable String token, HttpClient httpClient) { + HttpClient httpClient, @Nullable String token) { this(installDir, checkInterval, new GitHubReleaseCatalog(httpClient, token), httpClient); } /** - * Конструктор с явными зависимостями — для тестов и нестандартных сценариев: позволяет - * подменить источник релизов и HTTP-клиент, чтобы прогонять весь поток скачивания без сети. + * Создаёт загрузчик с явными зависимостями: источником сведений о релизах и HTTP-клиентом + * для скачивания ассета. Так их можно подменить (в т.ч. замокать) и прогнать весь поток + * скачивания без обращения к сети. * * @param installDir каталог установки сервера * @param checkInterval минимальный интервал между обращениями за новой версией * @param releaseCatalog источник сведений о релизах * @param httpClient HTTP-клиент для скачивания ассета */ - BslLanguageServerDownloader(Path installDir, Duration checkInterval, - ReleaseCatalog releaseCatalog, HttpClient httpClient) { + public BslLanguageServerDownloader(Path installDir, Duration checkInterval, + ReleaseCatalog releaseCatalog, HttpClient httpClient) { this.installDir = installDir.toAbsolutePath(); this.checkInterval = checkInterval; this.releaseCatalog = releaseCatalog; diff --git a/src/main/java/com/github/_1c_syntax/utils/downloader/ReleaseCatalog.java b/src/main/java/com/github/_1c_syntax/utils/downloader/ReleaseCatalog.java index 858b163..9bb0e9d 100644 --- a/src/main/java/com/github/_1c_syntax/utils/downloader/ReleaseCatalog.java +++ b/src/main/java/com/github/_1c_syntax/utils/downloader/ReleaseCatalog.java @@ -29,7 +29,7 @@ * конкретного транспорта (GitHub API), чтобы {@link BslLanguageServerDownloader} можно было * тестировать без обращения к сети. */ -interface ReleaseCatalog { +public interface ReleaseCatalog { /** * Возвращает сведения о последнем релизе выбранного канала. diff --git a/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java b/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java index 8ae8cc4..39383f6 100644 --- a/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java +++ b/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java @@ -21,7 +21,6 @@ */ package com.github._1c_syntax.utils.downloader; -import com.sun.net.httpserver.HttpServer; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.DisabledOnOs; import org.junit.jupiter.api.condition.OS; @@ -30,8 +29,10 @@ import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.net.InetSocketAddress; +import java.io.InputStream; import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -45,6 +46,10 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; class BslLanguageServerDownloaderTest { @@ -68,7 +73,7 @@ void compareVersionsTreatsReleaseAsNewerThanPreRelease() { @Test void installedVersionIsEmptyWhenNothingInstalled(@TempDir Path installDir) { - var downloader = new BslLanguageServerDownloader(installDir); + var downloader = new BslLanguageServerDownloader(installDir, null); assertThat(downloader.installedVersion()).isEmpty(); assertThat(downloader.installedBinary()).isEmpty(); } @@ -77,7 +82,7 @@ void installedVersionIsEmptyWhenNothingInstalled(@TempDir Path installDir) { void installedVersionIsReadFromServerInfo(@TempDir Path installDir) throws IOException { writeServerInfo(installDir, "1.0.2"); - var downloader = new BslLanguageServerDownloader(installDir); + var downloader = new BslLanguageServerDownloader(installDir, null); assertThat(downloader.installedVersion()).contains("1.0.2"); } @@ -92,7 +97,7 @@ void installedBinaryResolvesLinuxLauncher(@TempDir Path installDir) throws IOExc Files.createDirectories(binary.getParent()); Files.createFile(binary); - var downloader = new BslLanguageServerDownloader(installDir); + var downloader = new BslLanguageServerDownloader(installDir, null); assertThat(downloader.installedBinary()).contains(binary); } @@ -171,44 +176,32 @@ void copyToFilePropagatesUnknownTotalSize(@TempDir Path installDir) throws IOExc @Test void downloadIfNeededDownloadsExtractsAndRecordsVersion(@TempDir Path installDir) throws IOException { var archive = zipWithLaunchers(300 * 1024); - var server = startServer(archive); - try { - var catalog = new FakeCatalog( - new ReleaseCatalog.ReleaseInfo("v1.2.3", allOsAssets(assetUrl(server)))); - var downloader = new BslLanguageServerDownloader( - installDir, Duration.ZERO, catalog, testHttpClient()); - - var binary = downloader.downloadIfNeeded(BslLanguageServerReleaseChannel.STABLE); - - assertThat(binary).exists(); - assertThat(binary).startsWith(installDir.resolve("1.2.3")); - assertThat(downloader.installedVersion()).contains("1.2.3"); - } finally { - server.stop(0); - } + var catalog = new FakeCatalog(new ReleaseCatalog.ReleaseInfo("v1.2.3", allOsAssets())); + var downloader = new BslLanguageServerDownloader( + installDir, Duration.ZERO, catalog, httpClientReturning(archive)); + + var binary = downloader.downloadIfNeeded(BslLanguageServerReleaseChannel.STABLE); + + assertThat(binary).exists(); + assertThat(binary).startsWith(installDir.resolve("1.2.3")); + assertThat(downloader.installedVersion()).contains("1.2.3"); } @Test void downloadIfNeededReportsProgressForTheAsset(@TempDir Path installDir) throws IOException { var archive = zipWithLaunchers(300 * 1024); - var server = startServer(archive); - try { - var catalog = new FakeCatalog( - new ReleaseCatalog.ReleaseInfo("1.0.0", allOsAssets(assetUrl(server)))); - var downloader = new BslLanguageServerDownloader( - installDir, Duration.ZERO, catalog, testHttpClient()); - var progress = new ArrayList(); - - downloader.downloadIfNeeded(BslLanguageServerReleaseChannel.STABLE, - (bytesRead, totalBytes) -> progress.add(new long[]{bytesRead, totalBytes})); - - assertThat(progress).isNotEmpty(); - var last = progress.get(progress.size() - 1); - assertThat(last[0]).isEqualTo(archive.length); - assertThat(last[1]).isEqualTo(archive.length); - } finally { - server.stop(0); - } + var catalog = new FakeCatalog(new ReleaseCatalog.ReleaseInfo("1.0.0", allOsAssets())); + var downloader = new BslLanguageServerDownloader( + installDir, Duration.ZERO, catalog, httpClientReturning(archive)); + var progress = new ArrayList(); + + downloader.downloadIfNeeded(BslLanguageServerReleaseChannel.STABLE, + (bytesRead, totalBytes) -> progress.add(new long[]{bytesRead, totalBytes})); + + assertThat(progress).isNotEmpty(); + var last = progress.get(progress.size() - 1); + assertThat(last[0]).isEqualTo(archive.length); + assertThat(last[1]).isEqualTo(archive.length); } @Test @@ -216,7 +209,7 @@ void downloadIfNeededSkipsCatalogWhenIntervalNotElapsed(@TempDir Path installDir writeServerInfoAt(installDir, "1.0.0", System.currentTimeMillis()); var catalog = new ThrowingCatalog(); var downloader = new BslLanguageServerDownloader( - installDir, Duration.ofMinutes(8), catalog, testHttpClient()); + installDir, Duration.ofMinutes(8), catalog, unusedHttpClient()); var binary = downloader.downloadIfNeeded(BslLanguageServerReleaseChannel.STABLE); @@ -229,7 +222,7 @@ void downloadIfNeededFallsBackToInstalledWhenCatalogFails(@TempDir Path installD writeServerInfo(installDir, "1.0.0"); var catalog = new ThrowingCatalog(); var downloader = new BslLanguageServerDownloader( - installDir, Duration.ZERO, catalog, testHttpClient()); + installDir, Duration.ZERO, catalog, unusedHttpClient()); var binary = downloader.downloadIfNeeded(BslLanguageServerReleaseChannel.STABLE); @@ -241,7 +234,7 @@ void downloadIfNeededFallsBackToInstalledWhenCatalogFails(@TempDir Path installD void downloadIfNeededThrowsWhenNothingInstalledAndCatalogFails(@TempDir Path installDir) { var catalog = new ThrowingCatalog(); var downloader = new BslLanguageServerDownloader( - installDir, Duration.ZERO, catalog, testHttpClient()); + installDir, Duration.ZERO, catalog, unusedHttpClient()); assertThatThrownBy(() -> downloader.downloadIfNeeded(BslLanguageServerReleaseChannel.STABLE)) .isInstanceOf(IOException.class); @@ -252,35 +245,41 @@ void downloadIfNeededPassesRequestedChannelToCatalog(@TempDir Path installDir) t writeServerInfo(installDir, "1.0.0"); var catalog = new RecordingCatalog(new ReleaseCatalog.ReleaseInfo("1.0.0", Map.of())); var downloader = new BslLanguageServerDownloader( - installDir, Duration.ZERO, catalog, testHttpClient()); + installDir, Duration.ZERO, catalog, unusedHttpClient()); downloader.downloadIfNeeded(BslLanguageServerReleaseChannel.PRERELEASE); assertThat(catalog.requestedChannel).isEqualTo(BslLanguageServerReleaseChannel.PRERELEASE); } - private static HttpClient testHttpClient() { - // newBuilder() без .proxy() ходит напрямую — то, что нужно для локального сервера. - return HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(30)).build(); - } - - private static HttpServer startServer(byte[] body) throws IOException { - var server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); - server.createContext("/asset.zip", exchange -> { - exchange.sendResponseHeaders(200, body.length); - try (var output = exchange.getResponseBody()) { - output.write(body); - } - }); - server.start(); - return server; + /** + * Мок {@link HttpClient}, отдающий на любой {@code send} ответ 200 с телом {@code body} и + * заголовком {@code Content-Length}. Заменяет реальную сеть в тестах скачивания. + */ + @SuppressWarnings("unchecked") + private static HttpClient httpClientReturning(byte[] body) throws IOException { + HttpResponse response = mock(HttpResponse.class); + when(response.statusCode()).thenReturn(200); + when(response.headers()).thenReturn(HttpHeaders.of( + Map.of("Content-Length", List.of(String.valueOf(body.length))), (name, value) -> true)); + when(response.body()).thenReturn(new ByteArrayInputStream(body)); + + var client = mock(HttpClient.class); + try { + // doReturn — без дженериков, чтобы не спорить с выводом типа T у send(...). + doReturn(response).when(client).send(any(), any()); + } catch (IOException | InterruptedException e) { + throw new IllegalStateException(e); // не бывает: это настройка мока, а не реальный вызов + } + return client; } - private static String assetUrl(HttpServer server) { - return "http://127.0.0.1:" + server.getAddress().getPort() + "/asset.zip"; + private static HttpClient unusedHttpClient() { + return mock(HttpClient.class); } - private static Map allOsAssets(String url) { + private static Map allOsAssets() { + var url = "https://example.invalid/asset.zip"; // мок игнорирует URL return Map.of( "bsl-language-server_win.zip", url, "bsl-language-server_mac.zip", url, From 50cb1d9faccd325a9cf853a448199103d34ebe9f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 21:23:10 +0000 Subject: [PATCH 09/13] refactor(downloader): drop ReleaseCatalog and keep one injected-client ctor Remove the premature ReleaseCatalog/GitHubReleaseCatalog abstraction: release lookup moves back into the downloader as an overridable latestRelease(channel) returning a small Release record, mirroring the existing subclass-override test seam. Collapse to a single constructor that injects the HttpClient (installDir, httpClient, token); the old (installDir, token) ctor is gone. Tests mock the HttpClient and override latestRelease to run the whole downloadIfNeeded flow with no network and no local server. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CXTAaR4opi34idMG7ZZ8BV --- .../BslLanguageServerDownloader.java | 97 ++++++++------ .../downloader/GitHubReleaseCatalog.java | 89 ------------- .../utils/downloader/ReleaseCatalog.java | 51 -------- .../BslLanguageServerDownloaderTest.java | 119 ++++++++---------- 4 files changed, 112 insertions(+), 244 deletions(-) delete mode 100644 src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseCatalog.java delete mode 100644 src/main/java/com/github/_1c_syntax/utils/downloader/ReleaseCatalog.java diff --git a/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java b/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java index ef9e40f..810af3e 100644 --- a/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java +++ b/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java @@ -25,6 +25,12 @@ import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; import org.apache.commons.compress.archivers.zip.ZipFile; import org.jspecify.annotations.Nullable; +import org.kohsuke.github.GHAsset; +import org.kohsuke.github.GHRelease; +import org.kohsuke.github.GHRepository; +import org.kohsuke.github.GitHub; +import org.kohsuke.github.GitHubBuilder; +import org.kohsuke.github.extras.HttpClientGitHubConnector; import org.semver4j.Semver; import java.io.IOException; @@ -41,7 +47,9 @@ import java.time.Duration; import java.util.Comparator; import java.util.EnumSet; +import java.util.HashMap; import java.util.Locale; +import java.util.Map; import java.util.Optional; import java.util.Properties; import java.util.Set; @@ -68,11 +76,11 @@ @Slf4j public class BslLanguageServerDownloader { + private static final String REPOSITORY = "1c-syntax/bsl-language-server"; private static final String SERVER_INFO_FILE = "SERVER-INFO"; private static final String INFO_VERSION = "version"; private static final String INFO_LAST_UPDATE = "lastUpdate"; private static final Duration DEFAULT_CHECK_INTERVAL = Duration.ofMinutes(8); - private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(30); private static final Duration DOWNLOAD_TIMEOUT = Duration.ofMinutes(10); // Крупнее дефолта JDK (16 КБ у InputStream.transferTo): архивы сервера — десятки-сотни МБ, // так меньше syscall'ов на чтение/запись и реже дёргается прогресс-колбэк. @@ -82,49 +90,29 @@ public class BslLanguageServerDownloader { FileSystems.getDefault().supportedFileAttributeViews().contains("posix"); private final Path installDir; - private final Duration checkInterval; - private final ReleaseCatalog releaseCatalog; private final HttpClient httpClient; + private final @Nullable String token; /** - * Создаёт загрузчик, берущий релизы с GitHub через общий HTTP-клиент. - * * @param installDir каталог установки сервера; в нём создаются подпапки с версиями * и файл {@code SERVER-INFO} + * @param httpClient клиент для GitHub API и скачивания ассета; должен следовать редиректам — + * ассеты GitHub отдаются редиректом на CDN * @param token GitHub OAuth-токен для обхода лимитов анонимного API; может быть {@code null} */ - public BslLanguageServerDownloader(Path installDir, @Nullable String token) { - this(installDir, DEFAULT_CHECK_INTERVAL, defaultHttpClient(), token); - } - - private BslLanguageServerDownloader(Path installDir, Duration checkInterval, - HttpClient httpClient, @Nullable String token) { - this(installDir, checkInterval, new GitHubReleaseCatalog(httpClient, token), httpClient); + public BslLanguageServerDownloader(Path installDir, HttpClient httpClient, @Nullable String token) { + this.installDir = installDir.toAbsolutePath(); + this.httpClient = httpClient; + this.token = token; } /** - * Создаёт загрузчик с явными зависимостями: источником сведений о релизах и HTTP-клиентом - * для скачивания ассета. Так их можно подменить (в т.ч. замокать) и прогнать весь поток - * скачивания без обращения к сети. + * Сведения о релизе, нужные загрузчику. * - * @param installDir каталог установки сервера - * @param checkInterval минимальный интервал между обращениями за новой версией - * @param releaseCatalog источник сведений о релизах - * @param httpClient HTTP-клиент для скачивания ассета + * @param version тег/версия релиза (может начинаться с {@code v}) + * @param assetDownloadUrls карта «имя ассета → URL для скачивания» */ - public BslLanguageServerDownloader(Path installDir, Duration checkInterval, - ReleaseCatalog releaseCatalog, HttpClient httpClient) { - this.installDir = installDir.toAbsolutePath(); - this.checkInterval = checkInterval; - this.releaseCatalog = releaseCatalog; - this.httpClient = httpClient; - } - - private static HttpClient defaultHttpClient() { - return HttpClient.newBuilder() - .followRedirects(HttpClient.Redirect.NORMAL) - .connectTimeout(CONNECT_TIMEOUT) - .build(); + record Release(String version, Map assetDownloadUrls) { } /** @@ -152,7 +140,7 @@ public Optional installedVersion() { /** * Скачивает сервер при необходимости и возвращает путь к его исполняемому файлу. * - *

Если с момента прошлой проверки прошло меньше {@link #checkInterval}, GitHub не + *

Если с момента прошлой проверки прошло меньше {@link #DEFAULT_CHECK_INTERVAL}, GitHub не * опрашивается и возвращается уже установленный сервер. Если сеть недоступна, но сервер * уже установлен, возвращается установленная версия. Если сервер не установлен и скачать * его не удалось — выбрасывается {@link IOException}. @@ -187,9 +175,9 @@ public Path downloadIfNeeded(BslLanguageServerReleaseChannel channel, return binaryPath(installed); } - ReleaseCatalog.ReleaseInfo release; + Release release; try { - release = releaseCatalog.latestRelease(channel); + release = latestRelease(channel); } catch (IOException e) { if (installed != null) { LOGGER.warn("Failed to fetch BSL Language Server releases, using installed version {}", @@ -231,13 +219,48 @@ private boolean checkIntervalElapsed() { } try { var elapsed = System.currentTimeMillis() - Long.parseLong(lastUpdate); - return elapsed >= checkInterval.toMillis(); + return elapsed >= DEFAULT_CHECK_INTERVAL.toMillis(); } catch (NumberFormatException e) { return true; } } - private void downloadAndExtract(ReleaseCatalog.ReleaseInfo release, String version, + /** + * Возвращает последний релиз выбранного канала с GitHub. Выделен отдельным методом, чтобы в + * тестах его можно было переопределить и прогнать поток скачивания без обращения к GitHub API. + */ + Release latestRelease(BslLanguageServerReleaseChannel channel) throws IOException { + var builder = new GitHubBuilder().withConnector(new HttpClientGitHubConnector(httpClient)); + if (token != null && !token.isBlank()) { + builder.withOAuthToken(token); + } + GitHub github = builder.build(); + + GHRepository repository = github.getRepository(REPOSITORY); + + GHRelease release; + if (channel == BslLanguageServerReleaseChannel.PRERELEASE) { + // GitHub отдаёт релизы newest-first — берём первый не-draft. + release = repository.listReleases().toList().stream() + .filter(it -> !it.isDraft()) + .findFirst() + .orElse(null); + } else { + release = repository.getLatestRelease(); + } + + if (release == null) { + throw new IOException("Repository " + REPOSITORY + " has no suitable releases for channel " + channel); + } + + var assetUrls = new HashMap(); + for (GHAsset asset : release.listAssets().toList()) { + assetUrls.putIfAbsent(asset.getName(), asset.getBrowserDownloadUrl()); + } + return new Release(release.getTagName(), Map.copyOf(assetUrls)); + } + + private void downloadAndExtract(Release release, String version, DownloadProgressListener progressListener) throws IOException { var assetName = assetName(); var downloadUrl = release.assetDownloadUrls().get(assetName); diff --git a/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseCatalog.java b/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseCatalog.java deleted file mode 100644 index 7fa3e31..0000000 --- a/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseCatalog.java +++ /dev/null @@ -1,89 +0,0 @@ -/* - * This file is a part of 1c-syntax utils. - * - * Copyright (c) 2018-2026 - * Alexey Sosnoviy , Nikita Fedkin and contributors - * - * SPDX-License-Identifier: LGPL-3.0-or-later - * - * 1c-syntax utils is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3.0 of the License, or (at your option) any later version. - * - * 1c-syntax utils is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public - * License along with 1c-syntax utils. - */ -package com.github._1c_syntax.utils.downloader; - -import org.jspecify.annotations.Nullable; -import org.kohsuke.github.GHAsset; -import org.kohsuke.github.GHRelease; -import org.kohsuke.github.GHRepository; -import org.kohsuke.github.GitHub; -import org.kohsuke.github.GitHubBuilder; -import org.kohsuke.github.extras.HttpClientGitHubConnector; - -import java.io.IOException; -import java.net.http.HttpClient; -import java.util.HashMap; -import java.util.Map; - -/** - * {@link ReleaseCatalog} поверх GitHub API. Ходит в репозиторий {@value #REPOSITORY} за последним - * релизом канала через переданный {@link HttpClient}. - */ -final class GitHubReleaseCatalog implements ReleaseCatalog { - - private static final String REPOSITORY = "1c-syntax/bsl-language-server"; - - private final HttpClient httpClient; - private final @Nullable String token; - - /** - * @param httpClient HTTP-клиент, через который github-api обращается к GitHub - * @param token GitHub OAuth-токен для обхода лимитов анонимного API; может быть {@code null} - */ - GitHubReleaseCatalog(HttpClient httpClient, @Nullable String token) { - this.httpClient = httpClient; - this.token = token; - } - - @Override - public ReleaseInfo latestRelease(BslLanguageServerReleaseChannel channel) throws IOException { - var builder = new GitHubBuilder().withConnector(new HttpClientGitHubConnector(httpClient)); - if (token != null && !token.isBlank()) { - builder.withOAuthToken(token); - } - GitHub github = builder.build(); - - GHRepository repository = github.getRepository(REPOSITORY); - - GHRelease release; - if (channel == BslLanguageServerReleaseChannel.PRERELEASE) { - // GitHub отдаёт релизы newest-first — берём первый не-draft. - release = repository.listReleases().toList().stream() - .filter(it -> !it.isDraft()) - .findFirst() - .orElse(null); - } else { - release = repository.getLatestRelease(); - } - - if (release == null) { - throw new IOException( - "Repository " + REPOSITORY + " has no suitable releases for channel " + channel); - } - - var assetUrls = new HashMap(); - for (GHAsset asset : release.listAssets().toList()) { - assetUrls.putIfAbsent(asset.getName(), asset.getBrowserDownloadUrl()); - } - return new ReleaseInfo(release.getTagName(), Map.copyOf(assetUrls)); - } -} diff --git a/src/main/java/com/github/_1c_syntax/utils/downloader/ReleaseCatalog.java b/src/main/java/com/github/_1c_syntax/utils/downloader/ReleaseCatalog.java deleted file mode 100644 index 9bb0e9d..0000000 --- a/src/main/java/com/github/_1c_syntax/utils/downloader/ReleaseCatalog.java +++ /dev/null @@ -1,51 +0,0 @@ -/* - * This file is a part of 1c-syntax utils. - * - * Copyright (c) 2018-2026 - * Alexey Sosnoviy , Nikita Fedkin and contributors - * - * SPDX-License-Identifier: LGPL-3.0-or-later - * - * 1c-syntax utils is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3.0 of the License, or (at your option) any later version. - * - * 1c-syntax utils is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public - * License along with 1c-syntax utils. - */ -package com.github._1c_syntax.utils.downloader; - -import java.io.IOException; -import java.util.Map; - -/** - * Источник сведений о релизах BSL Language Server. Абстрагирует получение последнего релиза от - * конкретного транспорта (GitHub API), чтобы {@link BslLanguageServerDownloader} можно было - * тестировать без обращения к сети. - */ -public interface ReleaseCatalog { - - /** - * Возвращает сведения о последнем релизе выбранного канала. - * - * @param channel канал релизов (стабильный / pre-release) - * @return версия релиза и ссылки на его ассеты - * @throws IOException если релизы недоступны или подходящего релиза нет - */ - ReleaseInfo latestRelease(BslLanguageServerReleaseChannel channel) throws IOException; - - /** - * Сведения о релизе, нужные загрузчику. - * - * @param version тег/версия релиза (может начинаться с {@code v}) - * @param assetDownloadUrls карта «имя ассета → URL для скачивания» - */ - record ReleaseInfo(String version, Map assetDownloadUrls) { - } -} diff --git a/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java b/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java index 39383f6..ab753f8 100644 --- a/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java +++ b/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java @@ -36,7 +36,6 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.time.Duration; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -73,7 +72,7 @@ void compareVersionsTreatsReleaseAsNewerThanPreRelease() { @Test void installedVersionIsEmptyWhenNothingInstalled(@TempDir Path installDir) { - var downloader = new BslLanguageServerDownloader(installDir, null); + var downloader = new BslLanguageServerDownloader(installDir, unusedHttpClient(), null); assertThat(downloader.installedVersion()).isEmpty(); assertThat(downloader.installedBinary()).isEmpty(); } @@ -82,7 +81,7 @@ void installedVersionIsEmptyWhenNothingInstalled(@TempDir Path installDir) { void installedVersionIsReadFromServerInfo(@TempDir Path installDir) throws IOException { writeServerInfo(installDir, "1.0.2"); - var downloader = new BslLanguageServerDownloader(installDir, null); + var downloader = new BslLanguageServerDownloader(installDir, unusedHttpClient(), null); assertThat(downloader.installedVersion()).contains("1.0.2"); } @@ -97,7 +96,7 @@ void installedBinaryResolvesLinuxLauncher(@TempDir Path installDir) throws IOExc Files.createDirectories(binary.getParent()); Files.createFile(binary); - var downloader = new BslLanguageServerDownloader(installDir, null); + var downloader = new BslLanguageServerDownloader(installDir, unusedHttpClient(), null); assertThat(downloader.installedBinary()).contains(binary); } @@ -176,9 +175,8 @@ void copyToFilePropagatesUnknownTotalSize(@TempDir Path installDir) throws IOExc @Test void downloadIfNeededDownloadsExtractsAndRecordsVersion(@TempDir Path installDir) throws IOException { var archive = zipWithLaunchers(300 * 1024); - var catalog = new FakeCatalog(new ReleaseCatalog.ReleaseInfo("v1.2.3", allOsAssets())); - var downloader = new BslLanguageServerDownloader( - installDir, Duration.ZERO, catalog, httpClientReturning(archive)); + var downloader = downloaderReturning(installDir, httpClientReturning(archive), + new BslLanguageServerDownloader.Release("v1.2.3", allOsAssets())); var binary = downloader.downloadIfNeeded(BslLanguageServerReleaseChannel.STABLE); @@ -190,9 +188,8 @@ void downloadIfNeededDownloadsExtractsAndRecordsVersion(@TempDir Path installDir @Test void downloadIfNeededReportsProgressForTheAsset(@TempDir Path installDir) throws IOException { var archive = zipWithLaunchers(300 * 1024); - var catalog = new FakeCatalog(new ReleaseCatalog.ReleaseInfo("1.0.0", allOsAssets())); - var downloader = new BslLanguageServerDownloader( - installDir, Duration.ZERO, catalog, httpClientReturning(archive)); + var downloader = downloaderReturning(installDir, httpClientReturning(archive), + new BslLanguageServerDownloader.Release("1.0.0", allOsAssets())); var progress = new ArrayList(); downloader.downloadIfNeeded(BslLanguageServerReleaseChannel.STABLE, @@ -205,51 +202,77 @@ void downloadIfNeededReportsProgressForTheAsset(@TempDir Path installDir) throws } @Test - void downloadIfNeededSkipsCatalogWhenIntervalNotElapsed(@TempDir Path installDir) throws IOException { + void downloadIfNeededSkipsReleaseLookupWhenIntervalNotElapsed(@TempDir Path installDir) throws IOException { writeServerInfoAt(installDir, "1.0.0", System.currentTimeMillis()); - var catalog = new ThrowingCatalog(); - var downloader = new BslLanguageServerDownloader( - installDir, Duration.ofMinutes(8), catalog, unusedHttpClient()); + var downloader = new BslLanguageServerDownloader(installDir, unusedHttpClient(), null) { + @Override + Release latestRelease(BslLanguageServerReleaseChannel channel) { + throw new AssertionError("release lookup must not run when the interval has not elapsed"); + } + }; var binary = downloader.downloadIfNeeded(BslLanguageServerReleaseChannel.STABLE); assertThat(binary.toString()).contains("1.0.0"); - assertThat(catalog.called).isFalse(); } @Test - void downloadIfNeededFallsBackToInstalledWhenCatalogFails(@TempDir Path installDir) throws IOException { + void downloadIfNeededFallsBackToInstalledWhenLookupFails(@TempDir Path installDir) throws IOException { writeServerInfo(installDir, "1.0.0"); - var catalog = new ThrowingCatalog(); - var downloader = new BslLanguageServerDownloader( - installDir, Duration.ZERO, catalog, unusedHttpClient()); + var downloader = new BslLanguageServerDownloader(installDir, unusedHttpClient(), null) { + @Override + Release latestRelease(BslLanguageServerReleaseChannel channel) throws IOException { + throw new IOException("no network"); + } + }; var binary = downloader.downloadIfNeeded(BslLanguageServerReleaseChannel.STABLE); assertThat(binary.toString()).contains("1.0.0"); - assertThat(catalog.called).isTrue(); } @Test - void downloadIfNeededThrowsWhenNothingInstalledAndCatalogFails(@TempDir Path installDir) { - var catalog = new ThrowingCatalog(); - var downloader = new BslLanguageServerDownloader( - installDir, Duration.ZERO, catalog, unusedHttpClient()); + void downloadIfNeededThrowsWhenNothingInstalledAndLookupFails(@TempDir Path installDir) { + var downloader = new BslLanguageServerDownloader(installDir, unusedHttpClient(), null) { + @Override + Release latestRelease(BslLanguageServerReleaseChannel channel) throws IOException { + throw new IOException("no network"); + } + }; assertThatThrownBy(() -> downloader.downloadIfNeeded(BslLanguageServerReleaseChannel.STABLE)) .isInstanceOf(IOException.class); } @Test - void downloadIfNeededPassesRequestedChannelToCatalog(@TempDir Path installDir) throws IOException { + void downloadIfNeededPassesRequestedChannelToLookup(@TempDir Path installDir) throws IOException { writeServerInfo(installDir, "1.0.0"); - var catalog = new RecordingCatalog(new ReleaseCatalog.ReleaseInfo("1.0.0", Map.of())); - var downloader = new BslLanguageServerDownloader( - installDir, Duration.ZERO, catalog, unusedHttpClient()); + var requested = new BslLanguageServerReleaseChannel[1]; + var downloader = new BslLanguageServerDownloader(installDir, unusedHttpClient(), null) { + @Override + Release latestRelease(BslLanguageServerReleaseChannel channel) { + requested[0] = channel; + return new Release("1.0.0", Map.of()); // та же версия — скачивания не будет + } + }; downloader.downloadIfNeeded(BslLanguageServerReleaseChannel.PRERELEASE); - assertThat(catalog.requestedChannel).isEqualTo(BslLanguageServerReleaseChannel.PRERELEASE); + assertThat(requested[0]).isEqualTo(BslLanguageServerReleaseChannel.PRERELEASE); + } + + /** + * Загрузчик, у которого получение релиза подменено на {@code release} (сеть в GitHub не идёт), + * а скачивание ассета обслуживает переданный {@code httpClient}. + */ + private static BslLanguageServerDownloader downloaderReturning( + Path installDir, HttpClient httpClient, BslLanguageServerDownloader.Release release) { + return new BslLanguageServerDownloader(installDir, httpClient, null) { + @Override + Release latestRelease(BslLanguageServerReleaseChannel channel) { + return release; + } + }; } /** @@ -309,44 +332,6 @@ private static void putEntry(ZipOutputStream zip, String name, byte[] data) thro zip.closeEntry(); } - private static final class FakeCatalog implements ReleaseCatalog { - private final ReleaseInfo info; - - FakeCatalog(ReleaseInfo info) { - this.info = info; - } - - @Override - public ReleaseInfo latestRelease(BslLanguageServerReleaseChannel channel) { - return info; - } - } - - private static final class ThrowingCatalog implements ReleaseCatalog { - private boolean called; - - @Override - public ReleaseInfo latestRelease(BslLanguageServerReleaseChannel channel) throws IOException { - called = true; - throw new IOException("no network"); - } - } - - private static final class RecordingCatalog implements ReleaseCatalog { - private final ReleaseInfo info; - private BslLanguageServerReleaseChannel requestedChannel; - - RecordingCatalog(ReleaseInfo info) { - this.info = info; - } - - @Override - public ReleaseInfo latestRelease(BslLanguageServerReleaseChannel channel) { - requestedChannel = channel; - return info; - } - } - private static void writeServerInfo(Path installDir, String version) throws IOException { writeServerInfoAt(installDir, version, 0); } From 87d7d59510b964ae89f8f6ad358af7d8772234a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 21:32:18 +0000 Subject: [PATCH 10/13] refactor(downloader): inject a mockable GitHubReleaseClient instead of an override Release lookup moves to a concrete GitHubReleaseClient (no interface) injected via the constructor, so tests mock it with Mockito rather than subclassing the downloader to override a method. The downloader now takes (installDir, GitHubReleaseClient, HttpClient); both collaborators are mocked in the download-flow tests, which run with no network. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CXTAaR4opi34idMG7ZZ8BV --- .../BslLanguageServerDownloader.java | 76 ++----------- .../utils/downloader/GitHubReleaseClient.java | 106 ++++++++++++++++++ .../BslLanguageServerDownloaderTest.java | 82 ++++++-------- 3 files changed, 150 insertions(+), 114 deletions(-) create mode 100644 src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClient.java diff --git a/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java b/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java index 810af3e..b3392d2 100644 --- a/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java +++ b/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java @@ -25,12 +25,6 @@ import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; import org.apache.commons.compress.archivers.zip.ZipFile; import org.jspecify.annotations.Nullable; -import org.kohsuke.github.GHAsset; -import org.kohsuke.github.GHRelease; -import org.kohsuke.github.GHRepository; -import org.kohsuke.github.GitHub; -import org.kohsuke.github.GitHubBuilder; -import org.kohsuke.github.extras.HttpClientGitHubConnector; import org.semver4j.Semver; import java.io.IOException; @@ -47,9 +41,7 @@ import java.time.Duration; import java.util.Comparator; import java.util.EnumSet; -import java.util.HashMap; import java.util.Locale; -import java.util.Map; import java.util.Optional; import java.util.Properties; import java.util.Set; @@ -76,7 +68,6 @@ @Slf4j public class BslLanguageServerDownloader { - private static final String REPOSITORY = "1c-syntax/bsl-language-server"; private static final String SERVER_INFO_FILE = "SERVER-INFO"; private static final String INFO_VERSION = "version"; private static final String INFO_LAST_UPDATE = "lastUpdate"; @@ -90,29 +81,21 @@ public class BslLanguageServerDownloader { FileSystems.getDefault().supportedFileAttributeViews().contains("posix"); private final Path installDir; + private final GitHubReleaseClient releaseClient; private final HttpClient httpClient; - private final @Nullable String token; /** - * @param installDir каталог установки сервера; в нём создаются подпапки с версиями - * и файл {@code SERVER-INFO} - * @param httpClient клиент для GitHub API и скачивания ассета; должен следовать редиректам — - * ассеты GitHub отдаются редиректом на CDN - * @param token GitHub OAuth-токен для обхода лимитов анонимного API; может быть {@code null} + * @param installDir каталог установки сервера; в нём создаются подпапки с версиями + * и файл {@code SERVER-INFO} + * @param releaseClient источник сведений о последнем релизе + * @param httpClient клиент для скачивания ассета; должен следовать редиректам — + * ассеты GitHub отдаются редиректом на CDN */ - public BslLanguageServerDownloader(Path installDir, HttpClient httpClient, @Nullable String token) { + public BslLanguageServerDownloader(Path installDir, GitHubReleaseClient releaseClient, + HttpClient httpClient) { this.installDir = installDir.toAbsolutePath(); + this.releaseClient = releaseClient; this.httpClient = httpClient; - this.token = token; - } - - /** - * Сведения о релизе, нужные загрузчику. - * - * @param version тег/версия релиза (может начинаться с {@code v}) - * @param assetDownloadUrls карта «имя ассета → URL для скачивания» - */ - record Release(String version, Map assetDownloadUrls) { } /** @@ -175,9 +158,9 @@ public Path downloadIfNeeded(BslLanguageServerReleaseChannel channel, return binaryPath(installed); } - Release release; + GitHubReleaseClient.Release release; try { - release = latestRelease(channel); + release = releaseClient.latestRelease(channel); } catch (IOException e) { if (installed != null) { LOGGER.warn("Failed to fetch BSL Language Server releases, using installed version {}", @@ -225,42 +208,7 @@ private boolean checkIntervalElapsed() { } } - /** - * Возвращает последний релиз выбранного канала с GitHub. Выделен отдельным методом, чтобы в - * тестах его можно было переопределить и прогнать поток скачивания без обращения к GitHub API. - */ - Release latestRelease(BslLanguageServerReleaseChannel channel) throws IOException { - var builder = new GitHubBuilder().withConnector(new HttpClientGitHubConnector(httpClient)); - if (token != null && !token.isBlank()) { - builder.withOAuthToken(token); - } - GitHub github = builder.build(); - - GHRepository repository = github.getRepository(REPOSITORY); - - GHRelease release; - if (channel == BslLanguageServerReleaseChannel.PRERELEASE) { - // GitHub отдаёт релизы newest-first — берём первый не-draft. - release = repository.listReleases().toList().stream() - .filter(it -> !it.isDraft()) - .findFirst() - .orElse(null); - } else { - release = repository.getLatestRelease(); - } - - if (release == null) { - throw new IOException("Repository " + REPOSITORY + " has no suitable releases for channel " + channel); - } - - var assetUrls = new HashMap(); - for (GHAsset asset : release.listAssets().toList()) { - assetUrls.putIfAbsent(asset.getName(), asset.getBrowserDownloadUrl()); - } - return new Release(release.getTagName(), Map.copyOf(assetUrls)); - } - - private void downloadAndExtract(Release release, String version, + private void downloadAndExtract(GitHubReleaseClient.Release release, String version, DownloadProgressListener progressListener) throws IOException { var assetName = assetName(); var downloadUrl = release.assetDownloadUrls().get(assetName); diff --git a/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClient.java b/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClient.java new file mode 100644 index 0000000..596ad3b --- /dev/null +++ b/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClient.java @@ -0,0 +1,106 @@ +/* + * This file is a part of 1c-syntax utils. + * + * Copyright (c) 2018-2026 + * Alexey Sosnoviy , Nikita Fedkin and contributors + * + * SPDX-License-Identifier: LGPL-3.0-or-later + * + * 1c-syntax utils is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3.0 of the License, or (at your option) any later version. + * + * 1c-syntax utils is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with 1c-syntax utils. + */ +package com.github._1c_syntax.utils.downloader; + +import org.jspecify.annotations.Nullable; +import org.kohsuke.github.GHAsset; +import org.kohsuke.github.GHRelease; +import org.kohsuke.github.GHRepository; +import org.kohsuke.github.GitHub; +import org.kohsuke.github.GitHubBuilder; +import org.kohsuke.github.extras.HttpClientGitHubConnector; + +import java.io.IOException; +import java.net.http.HttpClient; +import java.util.HashMap; +import java.util.Map; + +/** + * Клиент GitHub-релизов BSL Language Server: находит последний релиз канала в репозитории + * {@value #REPOSITORY} через переданный {@link HttpClient}. + * + *

Отдельная зависимость загрузчика — чтобы в тестах его можно было замокать и прогнать поток + * скачивания без обращения к GitHub. Класс не {@code final} специально: так его мокает Mockito. + */ +public class GitHubReleaseClient { + + private static final String REPOSITORY = "1c-syntax/bsl-language-server"; + + private final HttpClient httpClient; + private final @Nullable String token; + + /** + * @param httpClient HTTP-клиент, через который github-api обращается к GitHub + * @param token GitHub OAuth-токен для обхода лимитов анонимного API; может быть {@code null} + */ + public GitHubReleaseClient(HttpClient httpClient, @Nullable String token) { + this.httpClient = httpClient; + this.token = token; + } + + /** + * Возвращает последний релиз выбранного канала. + * + * @param channel канал релизов (стабильный / pre-release) + * @return версия релиза и ссылки на его ассеты + * @throws IOException если релизы недоступны или подходящего релиза нет + */ + public Release latestRelease(BslLanguageServerReleaseChannel channel) throws IOException { + var builder = new GitHubBuilder().withConnector(new HttpClientGitHubConnector(httpClient)); + if (token != null && !token.isBlank()) { + builder.withOAuthToken(token); + } + GitHub github = builder.build(); + + GHRepository repository = github.getRepository(REPOSITORY); + + GHRelease release; + if (channel == BslLanguageServerReleaseChannel.PRERELEASE) { + // GitHub отдаёт релизы newest-first — берём первый не-draft. + release = repository.listReleases().toList().stream() + .filter(it -> !it.isDraft()) + .findFirst() + .orElse(null); + } else { + release = repository.getLatestRelease(); + } + + if (release == null) { + throw new IOException("Repository " + REPOSITORY + " has no suitable releases for channel " + channel); + } + + var assetUrls = new HashMap(); + for (GHAsset asset : release.listAssets().toList()) { + assetUrls.putIfAbsent(asset.getName(), asset.getBrowserDownloadUrl()); + } + return new Release(release.getTagName(), Map.copyOf(assetUrls)); + } + + /** + * Сведения о релизе, нужные загрузчику. + * + * @param version тег/версия релиза (может начинаться с {@code v}) + * @param assetDownloadUrls карта «имя ассета → URL для скачивания» + */ + public record Release(String version, Map assetDownloadUrls) { + } +} diff --git a/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java b/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java index ab753f8..1199c58 100644 --- a/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java +++ b/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java @@ -48,6 +48,8 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; class BslLanguageServerDownloaderTest { @@ -72,7 +74,7 @@ void compareVersionsTreatsReleaseAsNewerThanPreRelease() { @Test void installedVersionIsEmptyWhenNothingInstalled(@TempDir Path installDir) { - var downloader = new BslLanguageServerDownloader(installDir, unusedHttpClient(), null); + var downloader = new BslLanguageServerDownloader(installDir, mock(GitHubReleaseClient.class), unusedHttpClient()); assertThat(downloader.installedVersion()).isEmpty(); assertThat(downloader.installedBinary()).isEmpty(); } @@ -81,7 +83,7 @@ void installedVersionIsEmptyWhenNothingInstalled(@TempDir Path installDir) { void installedVersionIsReadFromServerInfo(@TempDir Path installDir) throws IOException { writeServerInfo(installDir, "1.0.2"); - var downloader = new BslLanguageServerDownloader(installDir, unusedHttpClient(), null); + var downloader = new BslLanguageServerDownloader(installDir, mock(GitHubReleaseClient.class), unusedHttpClient()); assertThat(downloader.installedVersion()).contains("1.0.2"); } @@ -96,7 +98,7 @@ void installedBinaryResolvesLinuxLauncher(@TempDir Path installDir) throws IOExc Files.createDirectories(binary.getParent()); Files.createFile(binary); - var downloader = new BslLanguageServerDownloader(installDir, unusedHttpClient(), null); + var downloader = new BslLanguageServerDownloader(installDir, mock(GitHubReleaseClient.class), unusedHttpClient()); assertThat(downloader.installedBinary()).contains(binary); } @@ -175,8 +177,10 @@ void copyToFilePropagatesUnknownTotalSize(@TempDir Path installDir) throws IOExc @Test void downloadIfNeededDownloadsExtractsAndRecordsVersion(@TempDir Path installDir) throws IOException { var archive = zipWithLaunchers(300 * 1024); - var downloader = downloaderReturning(installDir, httpClientReturning(archive), - new BslLanguageServerDownloader.Release("v1.2.3", allOsAssets())); + var releaseClient = mock(GitHubReleaseClient.class); + when(releaseClient.latestRelease(any())) + .thenReturn(new GitHubReleaseClient.Release("v1.2.3", allOsAssets())); + var downloader = new BslLanguageServerDownloader(installDir, releaseClient, httpClientReturning(archive)); var binary = downloader.downloadIfNeeded(BslLanguageServerReleaseChannel.STABLE); @@ -188,8 +192,10 @@ void downloadIfNeededDownloadsExtractsAndRecordsVersion(@TempDir Path installDir @Test void downloadIfNeededReportsProgressForTheAsset(@TempDir Path installDir) throws IOException { var archive = zipWithLaunchers(300 * 1024); - var downloader = downloaderReturning(installDir, httpClientReturning(archive), - new BslLanguageServerDownloader.Release("1.0.0", allOsAssets())); + var releaseClient = mock(GitHubReleaseClient.class); + when(releaseClient.latestRelease(any())) + .thenReturn(new GitHubReleaseClient.Release("1.0.0", allOsAssets())); + var downloader = new BslLanguageServerDownloader(installDir, releaseClient, httpClientReturning(archive)); var progress = new ArrayList(); downloader.downloadIfNeeded(BslLanguageServerReleaseChannel.STABLE, @@ -202,29 +208,24 @@ void downloadIfNeededReportsProgressForTheAsset(@TempDir Path installDir) throws } @Test - void downloadIfNeededSkipsReleaseLookupWhenIntervalNotElapsed(@TempDir Path installDir) throws IOException { + void downloadIfNeededSkipsReleaseLookupWhenIntervalNotElapsed(@TempDir Path installDir) + throws IOException { writeServerInfoAt(installDir, "1.0.0", System.currentTimeMillis()); - var downloader = new BslLanguageServerDownloader(installDir, unusedHttpClient(), null) { - @Override - Release latestRelease(BslLanguageServerReleaseChannel channel) { - throw new AssertionError("release lookup must not run when the interval has not elapsed"); - } - }; + var releaseClient = mock(GitHubReleaseClient.class); + var downloader = new BslLanguageServerDownloader(installDir, releaseClient, unusedHttpClient()); var binary = downloader.downloadIfNeeded(BslLanguageServerReleaseChannel.STABLE); assertThat(binary.toString()).contains("1.0.0"); + verify(releaseClient, never()).latestRelease(any()); } @Test void downloadIfNeededFallsBackToInstalledWhenLookupFails(@TempDir Path installDir) throws IOException { writeServerInfo(installDir, "1.0.0"); - var downloader = new BslLanguageServerDownloader(installDir, unusedHttpClient(), null) { - @Override - Release latestRelease(BslLanguageServerReleaseChannel channel) throws IOException { - throw new IOException("no network"); - } - }; + var releaseClient = mock(GitHubReleaseClient.class); + when(releaseClient.latestRelease(any())).thenThrow(new IOException("no network")); + var downloader = new BslLanguageServerDownloader(installDir, releaseClient, unusedHttpClient()); var binary = downloader.downloadIfNeeded(BslLanguageServerReleaseChannel.STABLE); @@ -232,13 +233,11 @@ Release latestRelease(BslLanguageServerReleaseChannel channel) throws IOExceptio } @Test - void downloadIfNeededThrowsWhenNothingInstalledAndLookupFails(@TempDir Path installDir) { - var downloader = new BslLanguageServerDownloader(installDir, unusedHttpClient(), null) { - @Override - Release latestRelease(BslLanguageServerReleaseChannel channel) throws IOException { - throw new IOException("no network"); - } - }; + void downloadIfNeededThrowsWhenNothingInstalledAndLookupFails(@TempDir Path installDir) + throws IOException { + var releaseClient = mock(GitHubReleaseClient.class); + when(releaseClient.latestRelease(any())).thenThrow(new IOException("no network")); + var downloader = new BslLanguageServerDownloader(installDir, releaseClient, unusedHttpClient()); assertThatThrownBy(() -> downloader.downloadIfNeeded(BslLanguageServerReleaseChannel.STABLE)) .isInstanceOf(IOException.class); @@ -247,32 +246,15 @@ Release latestRelease(BslLanguageServerReleaseChannel channel) throws IOExceptio @Test void downloadIfNeededPassesRequestedChannelToLookup(@TempDir Path installDir) throws IOException { writeServerInfo(installDir, "1.0.0"); - var requested = new BslLanguageServerReleaseChannel[1]; - var downloader = new BslLanguageServerDownloader(installDir, unusedHttpClient(), null) { - @Override - Release latestRelease(BslLanguageServerReleaseChannel channel) { - requested[0] = channel; - return new Release("1.0.0", Map.of()); // та же версия — скачивания не будет - } - }; + var releaseClient = mock(GitHubReleaseClient.class); + // та же версия — скачивания не будет, проверяем только проброс канала + when(releaseClient.latestRelease(any())) + .thenReturn(new GitHubReleaseClient.Release("1.0.0", Map.of())); + var downloader = new BslLanguageServerDownloader(installDir, releaseClient, unusedHttpClient()); downloader.downloadIfNeeded(BslLanguageServerReleaseChannel.PRERELEASE); - assertThat(requested[0]).isEqualTo(BslLanguageServerReleaseChannel.PRERELEASE); - } - - /** - * Загрузчик, у которого получение релиза подменено на {@code release} (сеть в GitHub не идёт), - * а скачивание ассета обслуживает переданный {@code httpClient}. - */ - private static BslLanguageServerDownloader downloaderReturning( - Path installDir, HttpClient httpClient, BslLanguageServerDownloader.Release release) { - return new BslLanguageServerDownloader(installDir, httpClient, null) { - @Override - Release latestRelease(BslLanguageServerReleaseChannel channel) { - return release; - } - }; + verify(releaseClient).latestRelease(BslLanguageServerReleaseChannel.PRERELEASE); } /** From 540f48fc6bb6e5a6249f1f676f4f2a3833a4c6b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 21:38:49 +0000 Subject: [PATCH 11/13] test(downloader): tidy AtomicInteger import and fresh mock body per send Address review nitpicks: import AtomicInteger instead of using its FQN, and have the mocked HttpClient hand out a fresh ByteArrayInputStream on each send() so the mock stays correct if the body is ever read more than once. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CXTAaR4opi34idMG7ZZ8BV --- .../utils/downloader/BslLanguageServerDownloaderTest.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java b/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java index 1199c58..38336ed 100644 --- a/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java +++ b/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java @@ -40,6 +40,7 @@ import java.util.List; import java.util.Map; import java.util.Random; +import java.util.concurrent.atomic.AtomicInteger; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; @@ -146,7 +147,7 @@ void copyToFileReportsProgressAcrossMultipleChunks(@TempDir Path installDir) thr @Test void copyToFileLeavesSourceOpenForCaller(@TempDir Path installDir) throws IOException { - var closed = new java.util.concurrent.atomic.AtomicInteger(); + var closed = new AtomicInteger(); var source = new ByteArrayInputStream(new byte[]{1, 2, 3}) { @Override public void close() { @@ -267,7 +268,9 @@ private static HttpClient httpClientReturning(byte[] body) throws IOException { when(response.statusCode()).thenReturn(200); when(response.headers()).thenReturn(HttpHeaders.of( Map.of("Content-Length", List.of(String.valueOf(body.length))), (name, value) -> true)); - when(response.body()).thenReturn(new ByteArrayInputStream(body)); + // Свежий поток на каждый send() — чтобы мок оставался корректным, если тело когда-нибудь + // прочитают повторно (ретрай/редирект). + when(response.body()).thenAnswer(invocation -> new ByteArrayInputStream(body)); var client = mock(HttpClient.class); try { From 3651a4e13844190e16de8e458d01e63c4971e2a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 04:16:07 +0000 Subject: [PATCH 12/13] refactor(downloader): let github-api own its HTTP client for release lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHubReleaseClient no longer takes an HttpClient: the no-arg HttpClientGitHubConnector lets github-api build and manage its own client for the API calls. The injected HttpClient stays only in the downloader, solely for the asset download — which github-api does not provide (GHAsset exposes just the browser download URL) and which needs per-byte progress anyway. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CXTAaR4opi34idMG7ZZ8BV --- .../downloader/BslLanguageServerDownloader.java | 4 ++-- .../utils/downloader/GitHubReleaseClient.java | 13 +++++-------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java b/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java index b3392d2..07ba13b 100644 --- a/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java +++ b/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java @@ -88,8 +88,8 @@ public class BslLanguageServerDownloader { * @param installDir каталог установки сервера; в нём создаются подпапки с версиями * и файл {@code SERVER-INFO} * @param releaseClient источник сведений о последнем релизе - * @param httpClient клиент для скачивания ассета; должен следовать редиректам — - * ассеты GitHub отдаются редиректом на CDN + * @param httpClient клиент только для скачивания ассета (github-api эту загрузку не умеет); + * должен следовать редиректам — ассеты GitHub отдаются редиректом на CDN */ public BslLanguageServerDownloader(Path installDir, GitHubReleaseClient releaseClient, HttpClient httpClient) { diff --git a/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClient.java b/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClient.java index 596ad3b..e8abc09 100644 --- a/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClient.java +++ b/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClient.java @@ -30,13 +30,13 @@ import org.kohsuke.github.extras.HttpClientGitHubConnector; import java.io.IOException; -import java.net.http.HttpClient; import java.util.HashMap; import java.util.Map; /** * Клиент GitHub-релизов BSL Language Server: находит последний релиз канала в репозитории - * {@value #REPOSITORY} через переданный {@link HttpClient}. + * {@value #REPOSITORY}. HTTP-доступ обеспечивает сама github-api (свой {@link HttpClientGitHubConnector} + * со встроенным клиентом) — отдельный HTTP-клиент здесь не нужен. * *

Отдельная зависимость загрузчика — чтобы в тестах его можно было замокать и прогнать поток * скачивания без обращения к GitHub. Класс не {@code final} специально: так его мокает Mockito. @@ -45,15 +45,12 @@ public class GitHubReleaseClient { private static final String REPOSITORY = "1c-syntax/bsl-language-server"; - private final HttpClient httpClient; private final @Nullable String token; /** - * @param httpClient HTTP-клиент, через который github-api обращается к GitHub - * @param token GitHub OAuth-токен для обхода лимитов анонимного API; может быть {@code null} + * @param token GitHub OAuth-токен для обхода лимитов анонимного API; может быть {@code null} */ - public GitHubReleaseClient(HttpClient httpClient, @Nullable String token) { - this.httpClient = httpClient; + public GitHubReleaseClient(@Nullable String token) { this.token = token; } @@ -65,7 +62,7 @@ public GitHubReleaseClient(HttpClient httpClient, @Nullable String token) { * @throws IOException если релизы недоступны или подходящего релиза нет */ public Release latestRelease(BslLanguageServerReleaseChannel channel) throws IOException { - var builder = new GitHubBuilder().withConnector(new HttpClientGitHubConnector(httpClient)); + var builder = new GitHubBuilder().withConnector(new HttpClientGitHubConnector()); if (token != null && !token.isBlank()) { builder.withOAuthToken(token); } From 47edf7cf8e520d46d9a2242283950fec8ec10dad Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 04:21:39 +0000 Subject: [PATCH 13/13] perf(downloader): stop at the first non-draft release without paging all Iterate repository.listReleases() lazily and break on the first non-draft prerelease instead of materializing every page via toList(), avoiding needless GitHub API requests. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CXTAaR4opi34idMG7ZZ8BV --- .../utils/downloader/GitHubReleaseClient.java | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClient.java b/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClient.java index e8abc09..87eeee9 100644 --- a/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClient.java +++ b/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClient.java @@ -72,11 +72,14 @@ public Release latestRelease(BslLanguageServerReleaseChannel channel) throws IOE GHRelease release; if (channel == BslLanguageServerReleaseChannel.PRERELEASE) { - // GitHub отдаёт релизы newest-first — берём первый не-draft. - release = repository.listReleases().toList().stream() - .filter(it -> !it.isDraft()) - .findFirst() - .orElse(null); + // GitHub отдаёт релизы newest-first — берём первый не-draft, не подгружая все страницы. + release = null; + for (GHRelease candidate : repository.listReleases()) { + if (!candidate.isDraft()) { + release = candidate; + break; + } + } } else { release = repository.getLatestRelease(); }