tests: ModelStoreTest — закрыты дыры покрытия Range и Content-Range

resumesPartialDownload: счётчики Range-запросов и байт; сервер отдаёт 400
без Range для .part — запасной путь «начать заново» закрыт.

failsOnSizeMismatch разделена на два:
  - rejectsWrongTotalFromContentRange — сервер соврал в Content-Range,
    бьёт в проверку actualTotal != expectedTotal (вторая, достижимая);
  - truncatedResponseDoesNotProduceFile — оборванный ответ, по факту ловит
    ветку IOException → «не удалось скачать» (а не first size check).

В mutations.tsv убрана строка про if (declaredBody >= 0 && written !=
declaredBody) — через java.net/http недостижимо (оборванный ответ даёт
IOException, не EOF). Добавлена строка про if (expectedTotal >= 0 &&
actualTotal != expectedTotal) — теперь убивает rejectsWrongTotalFromContentRange.

Все 18 мутаций (4 из ModelStore) убиты прогоном mutation_check.py.
This commit is contained in:
2026-10-02 04:48:35 +03:00
parent 3054705176
commit 69a0ebe0fa
4 changed files with 579 additions and 0 deletions
+104
View File
@@ -0,0 +1,104 @@
Проект: /root/WORK/memo (Kotlin/JVM). Заказ: закрыть ДВЕ дыры в покрытии `ModelStoreTest`,
доказанные мутационной проверкой. Правки только в тестовом файле, боевой код НЕ трогать.
Прогон, который их нашёл:
python3 e2e/mutation_check.py --gradle ./gradlew
Выжившие мутации:
1. `ModelStore.kt`: `if (startAt > 0L) requestBuilder.header("Range", "bytes=$startAt-")`
→ `if (false) ...` | ожидался провал `resumesPartialDownload` — НЕ упал.
2. `ModelStore.kt`: `if (declaredBody >= 0 && written != declaredBody) {`
→ `if (false) {` | ожидался провал `failsOnSizeMismatch` — НЕ упал.
## Почему они выжили (разобрано, не догадка)
**1. `resumesPartialDownload`.** Тестовый сервер (`serveBytes`) отдаёт `206` с хвостом только если
в запросе есть заголовок `Range`; без него он отдаёт `200` и **полное содержимое**. Поэтому при
мутации (Range не отправляется) срабатывает штатный запасной путь «начать заново», итоговый файл
получается правильным — и тест, проверяющий только «файл в итоге верный», проходит.
Тест не проверяет того, ради чего написан: что докачка **действительно** шла хвостом.
**2. `failsOnSizeMismatch`.** Сервер объявляет `Content-Length` больше, чем пишет, соединение
обрывается — у клиента вылетает `IOException`, который `download` превращает в
`IllegalStateException("не удалось скачать ...")`. Тест принимает `msg.contains("не удалось скачать")`,
поэтому проверка размера не проверяется вообще: исключение приходит из другого места.
**Важное наблюдение по бою.** В `ModelStore.download` две проверки размера:
`written != declaredBody` (первая) и `part.length() != expectedTotal` (вторая, из `Content-Range`).
Через `java.net.http` первая на практике недостижима: оборванный ответ всегда даёт `IOException`,
а не чистый EOF. Значит это защитный код, а не дыра в покрытии — в таблице мутаций его надо
заменить на мутацию ВТОРОЙ проверки, которая достижима (см. ниже).
## Что сделать
### Правка теста `resumesPartialDownload`
Сделать так, чтобы «докачка» была доказана, а не предположена:
- сервер записывает в счётчики: сколько запросов пришло, у скольких был заголовок `Range`,
и сколько всего байт тела он отдал;
- **если заголовка `Range` нет для файла, у которого уже есть `.part`** — сервер отвечает кодом
`400` и тела не отдаёт (докачки без `Range` не бывает; тест не должен иметь запасного пути);
- после `ensure` тест обязан утверждать:
- `rangeRequests >= 1` — докачка действительно была запрошена;
- файл в итоге побайтно равен исходному (оставить);
- `.part` переименован (оставить).
При мутации №1 (`Range` не отправляется) сервер ответит `400` → `ensure` бросит исключение → тест упадёт.
### Замена сценария `failsOnSizeMismatch` на два теста
**`rejectsWrongTotalFromContentRange`** (новый, закрывает мутацию №2) — «сервер соврал про общий
размер, файл принимать нельзя»:
- в директории лежит `.part` = первые 4000 байт файла из 8000;
- сервер на запрос с `Range: bytes=4000-` отвечает `206`:
- `Content-Length: 4000`, тело — реальный хвост 4000 байт (то есть транспорт отдаёт ровно
столько, сколько объявил — никакой `IOException`);
- `Content-Range: bytes 4000-7999/999999` — **итог соврал**;
- ожидание: `ModelStore.ensure` бросает `IllegalStateException`, сообщение содержит `размер`,
целевого файла нет, `.part` удалён.
Проверить, что сценарий действительно бьёт в нужную проверку: при `if (false)` на
`part.length() != expectedTotal` тест обязан провалиться (файл будет установлен, исключения не будет).
**`truncatedResponseDoesNotProduceFile`** (переименовать бывший `failsOnSizeMismatch`) — оставить
как проверку поведения «оборванный ответ не оставляет файла», но **убрать из принимаемых сообщение
«не удалось скачать»**, чтобы тест не «зеленел» за счёт сетевой ошибки. Ожидать явно любое
`IllegalStateException` с непустым сообщением и отсутствие целевого файла и `.part`.
Отдельно проверить и записать в отчёте: какую ветку кода реально ловит этот тест (по сообщению) —
то есть является ли он проверкой размера или сетевого обрыва. В отчёте написать прямо.
### Таблица мутаций `e2e/mutations.tsv`
- **Убрать** строку, целящуюся в `if (declaredBody >= 0 && written != declaredBody) {`
(по разбору выше — недостижимо через `java.net.http`; ложная цель).
- **Добавить** мутацию во вторую проверку:
`if (expectedTotal >= 0 && actualTotal != expectedTotal) {` → `if (false) {`,
обязанный уронить `rejectsWrongTotalFromContentRange`.
- Строку про `Range` оставить, но теперь она обязана валить `resumesPartialDownload`.
Правило: каждая строка таблицы — либо убитая мутация, либо честное объяснение в комментарии,
почему цель недостижима (с доказательством прогоном).
## Обязательная проверка (приложить вывод)
```bash
cd /root/WORK/memo
python3 e2e/mutation_check.py --gradle ./gradlew 2>&1 | tail -25
echo "--- ожидается: ВСЕ МУТАЦИИ УБИТЫ"
./gradlew test --rerun-tasks -q 2>&1 | tail -3
```
Плюс отдельно, для каждого из двух новых/изменённых тестов — доказательство, что он валит мутацию:
применить мутацию руками, прогнать только `ModelStoreTest`, показать FAIL, откатить
(`git checkout -- memo-core/src/main/kotlin/memo/core/ModelStore.kt`), убедиться в PASS.
## СТРОГИЕ ЗАПРЕТЫ
- **Не менять боевой код** `ModelStore.kt` (и вообще ничего в `src/main`). Заказ — только тесты
и таблица мутаций.
- Не менять другие тесты.
- Не добавлять зависимости.
- Не выводить план текстом; сразу правь файлы.
- Не удалять `.gitignore`, не коммитить `models/` и `*.db`.
+5
View File
@@ -22,3 +22,8 @@ memo-core/src/main/kotlin/memo/core/Searcher.kt .sortedByDescending { it.value }
memo-mcp/src/main/kotlin/memo/mcp/McpServer.kt ],"isError":true} ],"isError":false} searchWithoutPathIsError memo-mcp/src/main/kotlin/memo/mcp/McpServer.kt ],"isError":true} ],"isError":false} searchWithoutPathIsError
# --- Watcher: debounce сводит правки к одному вызову --- # --- Watcher: debounce сводит правки к одному вызову ---
memo-watch/src/main/kotlin/memo/watch/Watcher.kt _indexCalls.incrementAndGet() _indexCalls.get() modifyTriggersSingleIndexCall memo-watch/src/main/kotlin/memo/watch/Watcher.kt _indexCalls.incrementAndGet() _indexCalls.get() modifyTriggersSingleIndexCall
# --- ModelStore: скачивание модели (ModelStoreTest) ---
memo-core/src/main/kotlin/memo/core/ModelStore.kt if (!force && target.isFile && target.length() > 0L) { if (false) { skipsExistingWithoutNetwork
memo-core/src/main/kotlin/memo/core/ModelStore.kt part.renameTo(target) part.renameTo(java.io.File(dir, "$name.WRONG")) downloadsMissingFiles
memo-core/src/main/kotlin/memo/core/ModelStore.kt if (startAt > 0L) requestBuilder.header("Range", "bytes=$startAt-") if (false) requestBuilder.header("Range", "bytes=$startAt-") resumesPartialDownload
memo-core/src/main/kotlin/memo/core/ModelStore.kt if (expectedTotal >= 0 && actualTotal != expectedTotal) { if (false) { rejectsWrongTotalFromContentRange
Can't render this file because it contains an unexpected character in line 11 and column 54.
@@ -0,0 +1,158 @@
package memo.core
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.time.Duration
object ModelStore {
const val DEFAULT_BASE_URL = "http://static.binom.pw/models/siglip2"
val FILES = listOf("text_model_int8.onnx", "tokenizer.model")
data class Result(val downloaded: List<String>, val skipped: List<String>, val bytes: Long)
/** Пути к модели в том же порядке (modelPath, tokenizerPath). */
fun paths(dir: File): Pair<String, String> =
File(dir, FILES[0]).absolutePath to File(dir, FILES[1]).absolutePath
/** Все файлы модели присутствуют и непустые. */
fun isComplete(dir: File): Boolean = FILES.all { name ->
val f = File(dir, name)
f.isFile && f.length() > 0L
}
/** Гарантирует наличие всех файлов модели в dir. Возвращает имена скачанных/пропущенных. */
fun ensure(
dir: File,
baseUrl: String = DEFAULT_BASE_URL,
force: Boolean = false,
log: (String) -> Unit = {},
timeoutMillis: Long = 60_000,
): Result {
dir.mkdirs()
val base = baseUrl.trimEnd('/')
val client = HttpClient.newBuilder()
.connectTimeout(Duration.ofMillis(timeoutMillis))
.followRedirects(HttpClient.Redirect.NORMAL)
.build()
val downloaded = ArrayList<String>()
val skipped = ArrayList<String>()
var bytes = 0L
for (name in FILES) {
val target = File(dir, name)
if (!force && target.isFile && target.length() > 0L) {
skipped.add(name)
continue
}
bytes += download(client, dir, name, base, log)
downloaded.add(name)
}
return Result(downloaded, skipped, bytes)
}
private fun download(
client: HttpClient,
dir: File,
name: String,
baseUrl: String,
log: (String) -> Unit,
): Long {
val target = File(dir, name)
val part = File(dir, "$name.part")
var startAt = if (part.isFile) part.length() else 0L
if (part.exists() && startAt == 0L) part.delete()
val url = "$baseUrl/$name"
val requestBuilder = HttpRequest.newBuilder(URI.create(url)).GET()
if (startAt > 0L) requestBuilder.header("Range", "bytes=$startAt-")
val response = try {
client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofInputStream())
} catch (e: IOException) {
part.delete()
throw IllegalStateException("не удалось скачать $name с $url: ${e.message}", e)
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
part.delete()
throw IllegalStateException("скачивание $name прервано", e)
}
val status = response.statusCode()
if (status != 200 && status != 206) {
response.body().close()
part.delete()
throw IllegalStateException("не удалось скачать $name: HTTP $status ($url)")
}
val resuming = status == 206 && startAt > 0L
if (!resuming) startAt = 0L
val declaredBody = response.headers().firstValueAsLong("Content-Length").orElse(-1L)
val rangeTotal = response.headers().firstValue("Content-Range").orElse(null)
?.substringAfterLast('/')?.trim()?.toLongOrNull()
val expectedTotal = when {
resuming -> rangeTotal ?: if (declaredBody >= 0) startAt + declaredBody else -1L
declaredBody >= 0 -> declaredBody
else -> -1L
}
var written = 0L
try {
response.body().use { input ->
FileOutputStream(part, resuming).use { out ->
val buf = ByteArray(1 shl 16)
var lastLog = System.currentTimeMillis()
while (true) {
val n = input.read(buf)
if (n < 0) break
out.write(buf, 0, n)
written += n
val now = System.currentTimeMillis()
if (now - lastLog >= 2_000L) {
lastLog = now
log(progress(name, startAt + written, expectedTotal))
}
}
out.flush()
}
}
} catch (e: IOException) {
part.delete()
throw IllegalStateException("не удалось скачать $name с $url: ${e.message}", e)
}
if (declaredBody >= 0 && written != declaredBody) {
val actual = startAt + written
val expected = startAt + declaredBody
part.delete()
throw IllegalStateException(
"размер $name не совпал: ожидалось $expected байт, получено $actual байт",
)
}
val actualTotal = part.length()
if (expectedTotal >= 0 && actualTotal != expectedTotal) {
part.delete()
throw IllegalStateException(
"размер $name не совпал: ожидалось $expectedTotal байт, получено $actualTotal байт",
)
}
if (!part.renameTo(target)) {
part.delete()
throw IllegalStateException("не удалось переименовать $name.part в $name")
}
log("скачано $name: ${megabytes(actualTotal)} МБ")
return actualTotal
}
private fun progress(name: String, done: Long, total: Long): String =
if (total > 0) {
val pct = (done * 100 / total).coerceIn(0L, 100L)
"скачиваю $name: $pct% (${megabytes(done)} МБ / ${megabytes(total)} МБ)"
} else {
"скачиваю $name: ${megabytes(done)} МБ"
}
private fun megabytes(bytes: Long): Long = Math.round(bytes / 1_000_000.0)
}
@@ -0,0 +1,312 @@
package memo.core
import com.sun.net.httpserver.HttpExchange
import com.sun.net.httpserver.HttpHandler
import com.sun.net.httpserver.HttpServer
import java.net.InetSocketAddress
import java.nio.file.Files
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class ModelStoreTest {
@Test
fun downloadsMissingFiles() {
val dir = createTempDir()
val onnx = deterministicBytes(123_456, seed = 1)
val tok = deterministicBytes(45_678, seed = 2)
val server = startServer { ex ->
when (ex.requestURI.path) {
"/text_model_int8.onnx" -> serveBytes(ex, onnx, count = null)
"/tokenizer.model" -> serveBytes(ex, tok, count = null)
else -> ex.sendResponseHeaders(404, -1).also { ex.close() }
}
}
try {
val result = ModelStore.ensure(dir, baseUrl = server.base())
assertEquals(listOf("text_model_int8.onnx", "tokenizer.model"), result.downloaded)
assertEquals(emptyList(), result.skipped)
assertEquals((onnx.size + tok.size).toLong(), result.bytes)
val onnxFile = java.io.File(dir, "text_model_int8.onnx")
val tokFile = java.io.File(dir, "tokenizer.model")
assertTrue(onnxFile.isFile, "onnx должен быть скачан")
assertTrue(tokFile.isFile, "tokenizer должен быть скачан")
assertEquals(onnx.size.toLong(), onnxFile.length())
assertEquals(tok.size.toLong(), tokFile.length())
assertContentEquals(onnx, onnxFile.readBytes())
assertContentEquals(tok, tokFile.readBytes())
assertFalse(java.io.File(dir, "text_model_int8.onnx.part").exists())
assertFalse(java.io.File(dir, "tokenizer.model.part").exists())
} finally {
server.stop(0)
dir.deleteRecursively()
}
}
@Test
fun skipsExistingWithoutNetwork() {
val dir = createTempDir()
val onnx = deterministicBytes(10_000, seed = 3)
val tok = deterministicBytes(8_000, seed = 4)
java.io.File(dir, "text_model_int8.onnx").writeBytes(onnx)
java.io.File(dir, "tokenizer.model").writeBytes(tok)
val count = AtomicInteger(0)
val server = startServer { ex ->
count.incrementAndGet()
ex.sendResponseHeaders(500, -1)
ex.close()
}
try {
val result = ModelStore.ensure(dir, baseUrl = server.base())
assertEquals(0, count.get(), "ensure не должен ходить в сеть, если всё уже на месте")
assertEquals(emptyList(), result.downloaded)
assertEquals(listOf("text_model_int8.onnx", "tokenizer.model"), result.skipped)
assertEquals(0L, result.bytes)
assertContentEquals(onnx, java.io.File(dir, "text_model_int8.onnx").readBytes())
assertContentEquals(tok, java.io.File(dir, "tokenizer.model").readBytes())
} finally {
server.stop(0)
dir.deleteRecursively()
}
}
@Test
fun resumesPartialDownload() {
val dir = createTempDir()
val full = deterministicBytes(20_000, seed = 5)
java.io.File(dir, "text_model_int8.onnx").writeBytes(deterministicBytes(5_000, seed = 55))
val part = java.io.File(dir, "tokenizer.model.part")
val half = full.size / 2
part.writeBytes(full.copyOfRange(0, half))
val totalRequests = AtomicInteger(0)
val rangeRequests = AtomicInteger(0)
val bytesServed = AtomicLong(0L)
val server = startServer { ex ->
when (ex.requestURI.path) {
"/text_model_int8.onnx" -> ex.sendResponseHeaders(500, -1).also { ex.close() }
"/tokenizer.model" -> {
totalRequests.incrementAndGet()
val range = ex.requestHeaders.getFirst("Range")
if (range != null && range.startsWith("bytes=")) {
rangeRequests.incrementAndGet()
val spec = range.removePrefix("bytes=")
val start = spec.substringBefore('-').trim().toLong()
if (start in 0..full.size.toLong()) {
val tail = full.copyOfRange(start.toInt(), full.size)
ex.responseHeaders.set(
"Content-Range",
"bytes $start-${full.size - 1}/${full.size}",
)
ex.sendResponseHeaders(206, tail.size.toLong())
bytesServed.addAndGet(tail.size.toLong())
ex.responseBody.use { it.write(tail) }
return@startServer
}
}
ex.sendResponseHeaders(400, -1)
ex.close()
}
else -> ex.sendResponseHeaders(404, -1).also { ex.close() }
}
}
try {
val result = ModelStore.ensure(dir, baseUrl = server.base())
assertEquals(listOf("tokenizer.model"), result.downloaded)
assertTrue(
rangeRequests.get() >= 1,
"докачка должна была пойти хвостом: Range-запросов=${rangeRequests.get()}, всего=${totalRequests.get()}",
)
assertTrue(
bytesServed.get() < full.size.toLong(),
"докачка хвостом обязана отдать меньше полного файла: " +
"отдано=${bytesServed.get()}, файл=${full.size}",
)
val tokFile = java.io.File(dir, "tokenizer.model")
assertTrue(tokFile.isFile, "tokenizer должен быть собран из .part + хвоста")
assertEquals(full.size.toLong(), tokFile.length())
assertContentEquals(full, tokFile.readBytes())
assertFalse(java.io.File(dir, "tokenizer.model.part").exists(), ".part обязан быть переименован")
} finally {
server.stop(0)
dir.deleteRecursively()
}
}
@Test
fun rejectsWrongTotalFromContentRange() {
val dir = createTempDir()
val full = deterministicBytes(8_000, seed = 11)
val part = java.io.File(dir, "text_model_int8.onnx.part")
part.writeBytes(full.copyOfRange(0, 4_000))
java.io.File(dir, "tokenizer.model").writeBytes(deterministicBytes(1_000, seed = 12))
val server = startServer { ex ->
when (ex.requestURI.path) {
"/text_model_int8.onnx" -> {
val range = ex.requestHeaders.getFirst("Range")
if (range == null || !range.startsWith("bytes=")) {
ex.sendResponseHeaders(400, -1)
ex.close()
return@startServer
}
val start = range.removePrefix("bytes=").substringBefore('-').trim().toLong()
val tail = full.copyOfRange(start.toInt(), full.size)
ex.responseHeaders.set(
"Content-Range",
"bytes $start-${full.size - 1}/999999",
)
ex.sendResponseHeaders(206, tail.size.toLong())
ex.responseBody.use { it.write(tail) }
}
"/tokenizer.model" -> ex.sendResponseHeaders(500, -1).also { ex.close() }
else -> ex.sendResponseHeaders(404, -1).also { ex.close() }
}
}
try {
val ex = assertFailsWith<IllegalStateException>(
"ensure обязан бросить исключение при лжи про общий размер в Content-Range",
) {
ModelStore.ensure(dir, baseUrl = server.base())
}
val msg = ex.message ?: ""
assertTrue(
msg.contains("размер"),
"сообщение должно указывать на проблему с размером: $msg",
)
assertFalse(
java.io.File(dir, "text_model_int8.onnx").exists(),
"целевого файла быть не должно",
)
assertFalse(
java.io.File(dir, "text_model_int8.onnx.part").exists(),
".part обязан быть удалён",
)
} finally {
server.stop(0)
dir.deleteRecursively()
}
}
@Test
fun truncatedResponseDoesNotProduceFile() {
val dir = createTempDir()
val real = deterministicBytes(8_000, seed = 6)
val declaredSize = (real.size + 5_000).toLong()
java.io.File(dir, "tokenizer.model").writeBytes(deterministicBytes(1_000, seed = 13))
val server = startServer { ex ->
when (ex.requestURI.path) {
"/text_model_int8.onnx" -> {
ex.sendResponseHeaders(200, declaredSize)
ex.responseBody.use { it.write(real) }
ex.close()
}
"/tokenizer.model" -> ex.sendResponseHeaders(500, -1).also { ex.close() }
else -> ex.sendResponseHeaders(404, -1).also { ex.close() }
}
}
try {
val ex = assertFailsWith<IllegalStateException>(
"ensure обязан бросить исключение при оборванном ответе",
) {
ModelStore.ensure(dir, baseUrl = server.base())
}
assertTrue(
(ex.message ?: "").isNotEmpty(),
"исключение должно иметь осмысленное сообщение: '${ex.message}'",
)
assertFalse(
java.io.File(dir, "text_model_int8.onnx").exists(),
"целевого файла быть не должно",
)
assertFalse(
java.io.File(dir, "text_model_int8.onnx.part").exists(),
".part обязан быть удалён",
)
} finally {
server.stop(0)
dir.deleteRecursively()
}
}
@Test
fun forceRedownloads() {
val dir = createTempDir()
val garbageOnnx = deterministicBytes(9_999, seed = 7)
val garbageTok = deterministicBytes(3_333, seed = 8)
java.io.File(dir, "text_model_int8.onnx").writeBytes(garbageOnnx)
java.io.File(dir, "tokenizer.model").writeBytes(garbageTok)
val onnx = deterministicBytes(11_111, seed = 9)
val tok = deterministicBytes(4_444, seed = 10)
val server = startServer { ex ->
when (ex.requestURI.path) {
"/text_model_int8.onnx" -> serveBytes(ex, onnx, count = null)
"/tokenizer.model" -> serveBytes(ex, tok, count = null)
else -> ex.sendResponseHeaders(404, -1).also { ex.close() }
}
}
try {
val result = ModelStore.ensure(dir, baseUrl = server.base(), force = true)
assertEquals(listOf("text_model_int8.onnx", "tokenizer.model"), result.downloaded)
assertContentEquals(onnx, java.io.File(dir, "text_model_int8.onnx").readBytes())
assertContentEquals(tok, java.io.File(dir, "tokenizer.model").readBytes())
} finally {
server.stop(0)
dir.deleteRecursively()
}
}
private fun createTempDir(): java.io.File {
val d = Files.createTempDirectory("memo-modelstore-").toFile()
d.deleteOnExit()
return d
}
private fun deterministicBytes(size: Int, seed: Int): ByteArray {
val out = ByteArray(size)
var v = seed * 2_654_435_761 + 1
for (i in out.indices) {
v = v * 1_664_525 + 1_013_904_223
out[i] = (v ushr 16 and 0xFF).toByte()
}
return out
}
private fun startServer(handler: (HttpExchange) -> Unit): HttpServerWrap {
val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
server.createContext("/", HttpHandler { ex -> handler(ex) })
server.executor = null
server.start()
return HttpServerWrap(server, server.address.port)
}
private class HttpServerWrap(private val server: HttpServer, val port: Int) {
fun base(): String = "http://127.0.0.1:$port"
fun stop(delay: Int) = server.stop(delay)
}
private fun serveBytes(ex: HttpExchange, data: ByteArray, count: AtomicInteger?) {
count?.incrementAndGet()
val range = ex.requestHeaders.getFirst("Range")
if (range != null && range.startsWith("bytes=")) {
val spec = range.removePrefix("bytes=")
val start = spec.substringBefore('-').trim().toLong()
if (start in 0..data.size.toLong()) {
val tail = data.copyOfRange(start.toInt(), data.size)
ex.responseHeaders.set("Content-Range", "bytes $start-${data.size - 1}/${data.size}")
ex.sendResponseHeaders(206, tail.size.toLong())
ex.responseBody.use { it.write(tail) }
return
}
}
ex.sendResponseHeaders(200, data.size.toLong())
ex.responseBody.use { it.write(data) }
}
}