Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions .github/workflows/image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -169,18 +169,31 @@ jobs:
# half that runs its steps in the opposite order from the JVM. A transcript with the stages in
# the specified order is what says the sequence ran at all — without it, a process that was
# simply killed at the end of the grace period looks identical from the outside.
#
# And it has to leave BY ITSELF. `docker stop` sends SIGKILL at the end of its timeout, and a
# process killed there leaves no EXIT line — but the exit code says so too (137), and the time
# says how close it came. The whole transcript is printed, every stage, so the log of a green
# run is the evidence rather than a summary of it.
- name: It stops in order, and says so
run: |
started=$(date +%s)
docker stop --timeout 40 katcher-ci
took=$(( $(date +%s) - started ))
code=$(docker inspect katcher-ci --format '{{.State.ExitCode}}')
log=$(docker logs katcher-ci 2>&1)
printf '%s\n' "$log" | grep -E 'ANNOUNCE|DRAIN|RELEASE_POOLS|EXIT' || true
for stage in SIGNAL ANNOUNCE DRAIN RELEASE_CONSUMERS RELEASE_POOLS EXIT; do
echo "--- the shutdown: exit code $code after ${took}s of a 40s timeout ---"
printf '%s\n' "$log" | grep -E 'SIGNAL|ANNOUNCE|DRAIN|RELEASE_|EXIT|participant' || true
for stage in SIGNAL ANNOUNCE DRAIN RELEASE_CONSUMERS RELEASE_POOLS RELEASE_TELEMETRY EXIT; do
if ! printf '%s\n' "$log" | grep -q "$stage COMPLETED"; then
echo "the shutdown transcript has no completed $stage stage" >&2
printf '%s\n' "$log" | tail -40 >&2
exit 1
fi
done
if [ "$code" = 137 ] || [ "$took" -ge 40 ]; then
echo "the process did not leave by itself: exit code $code after ${took}s" >&2
exit 1
fi

# The container has no shell, so this is the only account of what went wrong.
- name: What it said, if it did not
Expand Down
53 changes: 35 additions & 18 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ required in production). Client is a KMP library apps embed to capture and uploa
Feature packages under `io.github.youndie.katcher.feature.<name>/data` (auth, app, user, report, error,
symbolication) each hold their own Exposed/sqlx4k data access.
- `server/src/commonMain/kotlin/Main.kt` — the process, not the application. It opens the database
(migrations included) **before** the engine exists, starts the server with `wait = false`, and then
spends the rest of its life inside kore's `runUntilSignal`: announce, drain, release consumers,
release pools, release telemetry, exit. The database is opened here rather than in the module so
the release stage has a handle to close *after* the drain — wired to `ApplicationStopping` instead,
a close runs **before** the drain on Kotlin/Native and after it on the JVM, from identical source,
which is the defect kore exists to remove and the reason katcher takes it.
(migrations included) **before** the engine exists, starts the server with kore's `startForKore()`,
and then spends the rest of its life inside kore's `runUntilSignal`: announce, drain, release
consumers, release pools, release telemetry, exit. The database is opened here rather than in the
module so the release stage has a handle to close *after* the drain — wired to `ApplicationStopping`
instead, a close runs **before** the drain on Kotlin/Native and after it on the JVM, from identical
source, which is the defect kore exists to remove and the reason katcher takes it.
- `shared/` — DTOs shared between client and server (`CreateReportParams`, `Breadcrumb`,
`ReportResource`, `ErrorGroupSort`).
- `client/` — the crash-reporting library consumers embed (`io.github.youndie.katcher:client`).
Expand All @@ -42,10 +42,27 @@ required in production). Client is a KMP library apps embed to capture and uploa

## The server's lifecycle is kore's, and three numbers live in two places

`io.github.youndie:kore-core` and `kore-ktor` (version in `gradle/libs.versions.toml`) own the stretch
from `SIGTERM` to exit, the three probes, and `/version`. What katcher supplies is the participants —
they are the part no library can write.

`io.github.youndie:kore-core` and `kore-ktor` (one `kore` version in `gradle/libs.versions.toml`, shared
with kore-mcp and the `kore.build` plugin) own the stretch from `SIGTERM` to exit, the three probes, and
`/version`. What katcher supplies is the participants — they are the part no library can write.

- **`startForKore()`, never `start()`.** On the JVM `start` leaves Ktor's own shutdown hook on and it
stops the engine at the signal, mid-announce (kore#90); on Kotlin/Native a `SIGTERM` between Ktor's
handler and kore's hangs the process (kore B-63). `EngineDrain` refuses to be built beside the hook.
- **One `DrainGate`, two readers.** `main` creates it and hands the same instance to the module's
`installShutdownRefusal` and to `EngineDrain`, which opens it as its first act. A refusal gated on
readiness answers `503` through the whole announce, to the requests the announce exists to serve
(kore B-61); two instances compile and never refuse.
- **`REUSE_ADDRESS` is one value in two places** (`HttpListener.kt`): the engine's `reuseAddress` and
the bind `listenRefusal` makes before anything starts. A busy port is then one line and exit 1, not
CIO's `SIGABRT` (kore B-59), and a restart in place binds over its predecessor's `TIME_WAIT` (B-62).
The check goes through a one-key kore schema fed the port alone — never the process environment:
the kubelet puts `KATCHER_PORT`, `KATCHER_SERVICE_HOST` and friends into the pod, and kore's schema
refuses undeclared variables under its prefix.
- **Stages are ordered, participants inside a stage are not.** The health-check loop stops in
RELEASE_CONSUMERS, beside the queue, because its `SELECT 1` goes through the pool RELEASE_POOLS
closes; `KatcherProbes.stop()` is `stopAndJoin()`, so the check in flight has finished when the
stage reports `COMPLETED` (kore#79). RELEASE_TELEMETRY has no participant and still reports.
- **The probes are three questions, not one route.** `/health/startup` is a latch; `/health/ready`
reads a `SELECT 1` against SQLite *and* the shutdown latch; `/health/live` and the `/health` alias
are liveness. Pointing readiness at `/health` gives a probe that cannot fail while the process is
Expand All @@ -67,14 +84,14 @@ they are the part no library can write.

## The MCP endpoint is kore-mcp's; the tools and the screen are katcher's

`io.github.youndie:kore-mcp` (its own version ref, `koreMcp`, ahead of the rest of kore until the
lifecycle migration lands) installs `/mcp`: nothing at all without `MCP_TOKEN`, the bearer check on
the transport's own route, the `Host` allowlist (empty = not checked), and the responses in MCP's
JSON. `installMcp` in `Application.kt` calls it after `common()` — kore-mcp installs the SDK's
ContentNegotiation when it finds none, and katcher's would then be a duplicate. What stays here is
`KatcherMcpServer.register` (the five tools, `link_fix` the only one that writes), `CrashTrust`'s
domain rules and `CrashAssessment`; only the hidden-character set comes from kore
(`HiddenCharacters`). `McpEndpointTest` drives the whole module through Ktor's test engine.
`io.github.youndie:kore-mcp` (the same `kore` version as the rest of kore) installs `/mcp`: nothing at
all without `MCP_TOKEN`, the bearer check on the transport's own route, the `Host` allowlist (empty =
not checked), and the responses in MCP's JSON. `installMcp` in `Application.kt` calls it after
`common()` — kore-mcp installs the SDK's ContentNegotiation when it finds none, and katcher's would
then be a duplicate. What stays here is `KatcherMcpServer.register` (the five tools, `link_fix` the
only one that writes), `CrashTrust`'s domain rules and `CrashAssessment`; only the hidden-character
set comes from kore (`HiddenCharacters`). `McpEndpointTest` drives the whole module through Ktor's
test engine.

## Client crash-capture model (important, non-obvious)

Expand Down
4 changes: 2 additions & 2 deletions charts/katcher/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ apiVersion: v2
name: katcher
description: Katcher - tiny self-hosted crash tracker
type: application
version: 0.7.5
appVersion: "0.8.5"
version: 0.7.6
appVersion: "0.8.6"

# The provider that signs people in, for installations that have no SSO of their own.
#
Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ kotlinx.atomicfu.enableJvmIrTransformation=true
# native` greps THIS FILE for it — four places, one number: they move together or a release goes out
# under a head nobody chose. The grep is by key name, so renaming the key breaks the image tag and
# nothing else; that is what happened to `VERSION` and why the workflow now spells `version`.
version=0.8.5
version=0.8.6

# WHAT THIS REPOSITORY TELLS THE SHARED CONVENTIONS.
sborka.group=io.github.youndie.katcher
Expand Down
24 changes: 12 additions & 12 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,17 @@ sborka = "0.4.0.111"
# 9.3.2) — the catalog was raised to match rather than this repository moved back.
atomicfu = "0.33.0"
# kore: the process lifecycle of a server binary — the ordered shutdown, the three probes and
# /version. One number for the two libraries and the Gradle plugin that generates the identity;
# they are published together and a plugin from one version with a library from another would
# compile and then disagree about the object it generates.
kore = "0.1.4"
# kore-mcp: the guarded MCP endpoint, under its own number ON PURPOSE. It depends on neither
# kore-core nor kore-ktor, so it can move without them; the rest of kore moving to 0.1.14 is the
# lifecycle migration — `startForKore()` in place of `start()`, the shutdown refusal gated on a
# `DrainGate` rather than on readiness (the predicate form is deprecated there, and warnings are
# errors here) — and that is its own change, not a rider on this one. Fold the two refs back into
# one when that change lands.
koreMcp = "0.1.14"
# /version — and the guarded MCP endpoint (kore-mcp). One number for every kore module and the
# Gradle plugin that generates the identity; they are published together and a plugin from one
# version with a library from another would compile and then disagree about the object it generates.
#
# kore-ktor hands `ktor-server-core` on through `api`, so a kore bump can raise ONE Ktor module past
# the rest: the build is green and the runtime throws `IrLinkageError`. After a bump no `io.ktor:` in
# `./gradlew :server:dependencies --configuration nativeCompileKlibraries` (or `jvmRuntimeClasspath`)
# may resolve past the Ktor of the `ktorLibs` catalog in settings.gradle.kts. An arrow UP TO it is
# normal — kore and the MCP SDK are built on an older Ktor, and the SDK's `ktor-server-websockets`
# stays on its own 3.5.1 because nothing here declares it — an arrow past it is the trap.
kore = "0.1.14"
# youndie/kotlin-native-rt's `iconv` for the Unicode charsets, so the static server needs no gconv
# (#86). linuxX64 only; `server/build.gradle.kts` adds it where it links statically.
kotlinNativeRtIconv = "0.1.0"
Expand All @@ -33,7 +33,7 @@ okio = "3.18.2"
kore-core = { module = "io.github.youndie:kore-core", version.ref = "kore" }
kore-ktor = { module = "io.github.youndie:kore-ktor", version.ref = "kore" }
# Brings the MCP SDK (`kotlin-sdk-server`) through `api`: the SDK's version is kore-mcp's.
kore-mcp = { module = "io.github.youndie:kore-mcp", version.ref = "koreMcp" }
kore-mcp = { module = "io.github.youndie:kore-mcp", version.ref = "kore" }

kotlinNativeRt-iconvUnicode = { module = "io.github.youndie.kotlin-native-rt:iconv-unicode", version.ref = "kotlinNativeRtIconv" }

Expand Down
104 changes: 44 additions & 60 deletions server/src/commonMain/kotlin/Main.kt
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
import io.github.smyrgeorge.sqlx4k.sqlite.ISQLite
import io.github.youndie.katcher.HTTP_HOST
import io.github.youndie.katcher.HTTP_PORT
import io.github.youndie.katcher.KatcherProbes
import io.github.youndie.katcher.REUSE_ADDRESS
import io.github.youndie.katcher.endProcess
import io.github.youndie.katcher.feature.error.ReportsQueueService
import io.github.youndie.katcher.getServerConfig
import io.github.youndie.katcher.initDb
import io.github.youndie.katcher.listenRefusal
import io.github.youndie.katcher.module
import io.github.youndie.kore.ktor.EngineDrain
import io.github.youndie.kore.ktor.startForKore
import io.github.youndie.kore.lifecycle.AnnounceNotReady
import io.github.youndie.kore.lifecycle.DrainGate
import io.github.youndie.kore.lifecycle.ShutdownDeadlines
import io.github.youndie.kore.lifecycle.ShutdownParticipant
import io.github.youndie.kore.lifecycle.runUntilSignal
import io.ktor.network.selector.SelectorManager
import io.ktor.network.sockets.InetSocketAddress
import io.ktor.network.sockets.aSocket
import io.ktor.server.cio.CIO
import io.ktor.server.engine.EngineConnectorBuilder
import io.ktor.server.engine.embeddedServer
Expand All @@ -21,17 +25,9 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlin.time.Duration.Companion.seconds

private const val PORT = 8080
private const val HOST = "0.0.0.0"

/** Two minutes: longer than TIME_WAIT, shorter than anyone's patience. */
private const val BIND_ATTEMPTS = 60
private const val BIND_RETRY_MILLIS = 2000L

/**
* How the process stops, as numbers.
*
Expand All @@ -55,7 +51,13 @@ private val DEADLINES =
)

fun main() {
runBlocking { awaitPort() }
// A port something already listens on is one line and exit 1, not the engine's `SIGABRT` (kore
// B-59). There is no wait for `TIME_WAIT` here any more: the engine binds with `SO_REUSEADDR`,
// so a process restarted in place binds over its predecessor's closed connections at once.
listenRefusal(HTTP_PORT)?.let { refusal ->
println(refusal)
endProcess(1)
}

val config = getServerConfig()

Expand All @@ -68,27 +70,41 @@ fun main() {

val probes = KatcherProbes(db)

// ONE latch for the refusal and for the drain. The refusal used to read readiness, which falls
// at the start of the announce — so it answered `503` to exactly the requests the announce waits
// for (kore B-61). `EngineDrain` opens this as its first act; the module's refusal reads it.
val draining = DrainGate()

val server =
embeddedServer(
CIO,
configure = {
connectors.add(
EngineConnectorBuilder().apply {
port = PORT
host = HOST
port = HTTP_PORT
host = HTTP_HOST
},
)
// The engine is given the same numbers the drain stage uses. Ktor's own default is
// one second, which is shorter than a great many real requests.
shutdownGracePeriod = DEADLINES.drain.inWholeMilliseconds
shutdownTimeout = (DEADLINES.drain + 5.seconds).inWholeMilliseconds
// The same value the check above bound with — see `REUSE_ADDRESS` (kore B-62).
reuseAddress = REUSE_ADDRESS
},
module = { module(db, config, probes) },
module = { module(db, config, probes, draining) },
)

// NOT `wait = true`. The main thread has to reach the await below, or the signal arrives at a
// process that has no sequence to run and is killed at the end of the grace period instead.
server.start(wait = false)
//
// And not `start(wait = false)` either. On the JVM that leaves Ktor's own shutdown hook on, the
// JVM runs hooks concurrently, and Ktor's stops the engine at the signal, in the middle of the
// announce (kore#90). On Kotlin/Native Ktor's signal handler is armed between `start` and kore's,
// and a SIGTERM in that window hangs the process (kore B-63). `startForKore` starts without the
// first and takes the signal for kore before the second; `EngineDrain` refuses to be built beside
// a hook that is still on.
server.startForKore()

val checksScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
probes.start(checksScope)
Expand All @@ -106,22 +122,29 @@ fun main() {
onFinished = { run -> println(run.transcript) },
) {
announce(AnnounceNotReady(probes.readiness))
drain(EngineDrain(server, DEADLINES.drain, DEADLINES.drain + 5.seconds))
drain(EngineDrain(server, DEADLINES.drain, DEADLINES.drain + 5.seconds, draining))

// After the drain: the queue holds reports that were accepted with `202` and not yet
// written. Draining it here is the difference between a crash tracker that loses
// crashes during a deploy and one that does not.
consumer(queueParticipant(queue))

// Last, because everything above it writes through this pool.
pool(databaseParticipant(db))

telemetry(
// HERE, a stage BEFORE the pool, and not in `telemetry` where it used to be. The check is
// a `SELECT 1` through the pool released in the next stage, so stopping it after that
// stage let a pass run against a closed pool. kore orders stages, not the participants
// inside one — this and the queue run concurrently, which is fine: neither needs the
// other, both need the pool. Nobody reads readiness by now; the engine has drained.
consumer(
participant("health checks") {
probes.stop()
checksScope.cancel()
},
)

// Last of what holds resources, because everything above it goes through this pool.
// RELEASE_TELEMETRY stays empty and still reports: the metrik agent flushes itself on
// `ApplicationStopping`, inside the drain, and nothing else here reports on the shutdown.
pool(databaseParticipant(db))
}
}
}
Expand All @@ -148,42 +171,3 @@ private fun participant(
block()
}
}

/**
* Waits until the port can actually be bound, and only then starts the server.
*
* When a container restarts it keeps the pod's network namespace, and the connections left by
* the dead process sit in TIME_WAIT holding the port. Ktor's CIO socket does not ask for
* SO_REUSEADDR, so its bind fails for as long as they last — and it fails inside the acceptor
* coroutine, where nothing can catch it: the process dies, kubelet restarts it, the port is
* still held, and one crash becomes minutes of CrashLoopBackOff.
*
* Binding here first turns that into a wait. It is not a fix for whatever killed the previous
* process; it is the difference between a blip and an outage.
*/
@Suppress(
"ktlint:kapkan:cancellation-swallowed",
"the broad catch rethrows everything except AddressAlreadyInUse, a cancellation with it",
)
private suspend fun awaitPort() {
// Default, not IO: on native IO is internal, and this selector lives for one bind test.
val selector = SelectorManager(kotlinx.coroutines.Dispatchers.Default)

repeat(BIND_ATTEMPTS) { attempt ->
try {
aSocket(selector).tcp().bind(InetSocketAddress(HOST, PORT)).close()
if (attempt > 0) println("port $PORT free after ${attempt + 1} attempts")
selector.close()
return
} catch (cause: Throwable) {
// Everything that is not AddressAlreadyInUse leaves on the next line, a cancellation
// included -- the rule reads the shape of the catch and cannot see a conditional rethrow.
if (cause::class.simpleName != "AddressAlreadyInUseException") throw cause
println("port $PORT still held, waiting (${attempt + 1}/$BIND_ATTEMPTS)")
delay(BIND_RETRY_MILLIS)
}
}

selector.close()
error("port $PORT was still in use after $BIND_ATTEMPTS attempts")
}
Loading
Loading