An architectural bug hunt flagged the Serializer polymorphic registry as an
efficiency hotspot: “repeated per-request polymorphic resolution without
caching or pre-warming,” blast radius every SSE frame and HTTP payload.
That hypothesis is refuted (see Root cause), but the investigation it
prompted found a genuine defect one layer up, in the /sse serialize lambda —
the single chokepoint every streamed node frame passes through. It was
serializing each node twice per frame and discarding the first result. The
discarded copy was the redacted one, so the frame that actually reached the
wire carried unredacted SMTP tokens and auth-shaped outbound-webhook headers
whenever demo mode was off — a silent revert of krill#917 for every normal
deployment.
Two independent parts.
The hypothesis, refuted. platformSerializerModule is a top-level val
built exactly once at class-init, and kotlinx.serialization resolves
polymorphic subtypes through a HashMap keyed by KClass — an O(1) lookup,
not an O(n) walk over the 60+ registered subtypes. fastJson is likewise a
singleton, and encodeToString<T> resolves the compile-time serializer,
which the library caches per class on JVM. The two other files the lead named,
UniversalAppNodeProcessor.kt and ServerProcessor.kt, contain no
serialization calls at all. There is no per-call registry walk to cache.
The real defect. krill#917 (node-level secret redaction) and the demo-mode
change were developed on branches that both edited the one-line /sse
serialize lambda. The merge kept both edits stacked instead of composing
them:
1
2
3
4
5
sse("/sse", serialize = { _, node ->
fastJson.encodeToString<Node>((node as Node).redactSecrets()) // result discarded
if (DemoModeContainer.enabled) { /* redacting encode */ }
else { fastJson.encodeToString<Node>(node as Node) } // NOT redacted
})
Kotlin’s block-bodied lambda returns its last expression, so the krill#917 line
became a pure dead store: a full node copy plus a full JSON encode, computed and
thrown away on every frame to every connected client. Demo mode masked half of
it — its tree-walking pass blanks any field named token, so only non-demo
deployments leaked. No test covered the lambda, because it was an anonymous
inline block reading a global flag.
Extracted the lambda body into a pure, testable top-level function
serializeSseNode(node, demoMode) in
server/src/jvmMain/kotlin/krill/zone/server/SseNodeSerialization.kt, which
redacts once into a local and feeds that single value to both branches.
Routes.kt now calls it with DemoModeContainer.enabled. Result: one encode
per frame instead of two (two instead of three in demo mode), and krill#917’s
redaction holds on /sse in both modes.
server/src/jvmTest/kotlin/krill/zone/server/SseNodeSerializationTest.kt covers
both branches; the two non-demo cases fail against the pre-fix shape.
Separately, the same “serializer rebuilt per call” pattern turned up for real in
composeApp/.../graph/GraphScreen.kt, which constructed a fresh
Json { prettyPrint = true } inside a per-snapshot loop in a composable —
hoisted to a file-level Json(from = fastJson) singleton.
serialize = { } lambda. Anything that
reads a global flag and makes a security decision needs to be a named
function with the flag as a parameter, so it can be unit-tested. That test
seam is what turns “silent leak on the hot path” into a red test.Node.redactSecrets() in isolation and the /nodes and
/node/{id} handlers, but not /sse — so /sse was the one that regressed.
Test the chokepoint, not just the helper it calls.Json {} inside a loop or composable. fastJson is the
project instance; derive with Json(from = fastJson) { … } at file scope when
a different config is genuinely needed.