loadSvg ran its success path inside the fetch’s try, so render faults and cancellation both became “Failed to load SVG”Kraken’s nightly architectural bug hunt filed krill#958 against composeApp on the
error-handling-propagation axis, hypothesising that Compose screens inherit inconsistent
error semantics from the shared modules and that a malformed KrillApp subtype could crash
the UI. Four of the five pieces of evidence did not survive diagnosis — the as? ServerMetaData
cast in ProjectScreen is null-guarded at its render site, NodeHttp.getQrCode already
returns null on any exception, ClientScreen’s throttledSwarm operates on a StateFlow
that cannot complete exceptionally, and parseAnchors is a hand-rolled index scan whose
Regex.find calls return null rather than throwing. The fifth pointed at something real,
just not by the mechanism proposed: loadSvg in DiagramScreen.kt invoked
onSuccess(content) from inside the same try that wrapped the HTTP fetch, under a single
catch (e: Exception) whose only outcome was onError("Failed to load SVG: …"). That single
seven-line function collapsed three distinct outcomes into one message. A failure while
parsing anchors or applying the result — a render fault — was reported to the user as a
network fault, sending anyone debugging it at the wrong subsystem. Worse, CancellationException
is an Exception, so a composable leaving composition mid-fetch had its cancellation caught
and downgraded into an error-message state write: structured concurrency broken, and a
navigate-away/navigate-back leaving a spurious load error on screen. All four call sites in
DiagramScreen inherited both faults.
composeApp/src/commonMain/kotlin/krill/zone/app/krillapp/project/diagram/DiagramScreen.kt:
split loadSvg into a fetch try and a separate success-path try, so a render fault
surfaces as “Failed to render SVG” and can never be mistaken for a load fault.catch (e: CancellationException) { throw e } ahead of the generic handler
in both blocks, so leaving composition tears the coroutine down instead of writing an error
message.loadSvg is now internal and takes a fetch: suspend (String) -> String
parameter defaulted to the production httpClient.get(it).body() call, so the function is
reachable from tests without touching the httpClient platform expect val.composeApp/src/desktopTest/kotlin/krill/zone/app/krillapp/project/diagram/LoadSvgErrorModelTest.kt:
six cases over the seam covering load fault, render fault, cancellation from either side,
non-SVG payload rejection, and the happy path. Confirmed red on the pre-fix body (3 of 6
failing — exactly the ones targeting the two defects) and green after.try block should span exactly the operation whose failure mode the catch names. When a
callback is invoked from inside a try, the handler silently adopts every fault the callback
can raise and relabels it — the error message stops being evidence.catch (e: Exception) around any suspend call swallows CancellationException, because it
is an Exception. Rethrow it explicitly before the generic handler, or catch something
narrower. This is the default failure mode of coroutine code that uses broad catches, not an
edge case.expect val, add a parameter defaulted to the
production call rather than restructuring the call site. Existing callers stay byte-identical
and the test never touches the platform seam.