Skip to content

Commit ab316b2

Browse files
fryanpanclaude
andcommitted
ADFA-4128: qb 07 review fixes — parseDiagnostics no-throw contract + request bound
Important 1: unguarded asString on diagnostic severity/message threw out of compile() on object/array values -> primitive-guarded, degrading to ERROR / "unknown error"; covered by `a non-primitive severity or message degrades instead of throwing out of compile`. Important 2: line/column asInt threw NumberFormatException on non-numeric string primitives -> runCatching like the protocol-version read, degrading to absent; covered by `a non-numeric line or column string reads as absent instead of throwing`. Important 3: the request write had no bound, so a wedged child holding a full stdin pipe parked the mutex forever and shutdown() deadlocked on the writer monitor -> write runs on the client scope under requestTimeoutMillis with destroyForcibly on expiry, and shutdown()'s EOF close moved off the teardown path; covered by `a request the daemon never reads times out instead of wedging the client` and `shutdown is not deadlocked by a write the daemon never reads`. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kj9YeCDHGp9DU8LPtfWJ7W
1 parent 1f6432d commit ab316b2

2 files changed

Lines changed: 285 additions & 19 deletions

File tree

‎quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt‎

Lines changed: 81 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import kotlinx.coroutines.CancellationException
77
import kotlinx.coroutines.CompletableDeferred
88
import kotlinx.coroutines.CoroutineScope
99
import kotlinx.coroutines.Dispatchers
10+
import kotlinx.coroutines.async
11+
import kotlinx.coroutines.isActive
1012
import kotlinx.coroutines.launch
1113
import kotlinx.coroutines.sync.Mutex
1214
import kotlinx.coroutines.sync.withLock
@@ -26,6 +28,7 @@ import java.io.IOException
2628
import java.util.concurrent.ConcurrentHashMap
2729
import java.util.concurrent.atomic.AtomicBoolean
2830
import java.util.concurrent.atomic.AtomicLong
31+
import kotlin.coroutines.coroutineContext
2932

3033
/**
3134
* Runs the quick-build daemon as a child JVM and speaks its line-delimited JSON protocol.
@@ -301,8 +304,13 @@ class DaemonProcessClient(
301304
configured = false
302305
// Best effort polite stop; the protocol also treats stdin EOF as shutdown.
303306
withTimeoutOrNull(SHUTDOWN_TIMEOUT_MILLIS) { request(DaemonOps.SHUTDOWN) {} }
307+
val out = writer
304308
withContext(Dispatchers.IO) {
305-
runCatching { writer?.close() }
309+
// EOF is the second polite signal, but close() blocks on the BufferedWriter
310+
// monitor while a wedged write holds it - so it runs on [scope] rather than
311+
// inline, and the kill path below (which closes the pipe and thereby frees any
312+
// such writer) is always reached instead of deadlocking teardown.
313+
scope.launch(Dispatchers.IO) { runCatching { out?.close() } }
306314
if (proc.isAlive && !proc.waitFor(2, java.util.concurrent.TimeUnit.SECONDS)) {
307315
proc.destroyForcibly()
308316
}
@@ -313,8 +321,9 @@ class DaemonProcessClient(
313321

314322
/**
315323
* Sends one request and awaits the matching-id response. Failure of the transport
316-
* (dead process, EOF, timeout) is a [DaemonReply.Failed]; a well-formed
317-
* `ok=false` response is a [DaemonReply.BuildFailed] with parsed diagnostics.
324+
* (dead process, EOF, timeout - on the response, or on a write the child never drains)
325+
* is a [DaemonReply.Failed]; a well-formed `ok=false` response is a
326+
* [DaemonReply.BuildFailed] with parsed diagnostics.
318327
*
319328
* @param op protocol op name, sent as `op` and echoed in timeout messages.
320329
* @param fill adds the op's own keys to the request object; `id` and `op` are already set
@@ -339,15 +348,44 @@ class DaemonProcessClient(
339348
fill()
340349
}
341350

342-
try {
343-
withContext(Dispatchers.IO) {
344-
out.write(requestJson.toString())
345-
out.newLine()
346-
out.flush()
351+
// The write runs on [scope], not the caller's context: a blocking pipe write to a
352+
// child that stopped reading stdin cannot be cancelled, only abandoned, and it must
353+
// not park the caller (and [requestMutex]) forever while it blocks.
354+
val writeJob =
355+
scope.async(Dispatchers.IO) {
356+
runCatching {
357+
out.write(requestJson.toString())
358+
out.newLine()
359+
out.flush()
360+
}
347361
}
348-
} catch (e: IOException) {
362+
val writeOutcome =
363+
try {
364+
withTimeoutOrNull(requestTimeoutMillis) { writeJob.await() }
365+
} catch (e: CancellationException) {
366+
pending.remove(id)
367+
// The caller's own cancellation propagates; a dead [scope] (client torn
368+
// down under the caller) degrades to a reply instead.
369+
if (coroutineContext.isActive) {
370+
return DaemonReply.Failed("Daemon is not running", daemonDied = true)
371+
}
372+
throw e
373+
}
374+
if (writeOutcome == null) {
375+
// The child wedged with a full stdin pipe. Only closing the pipe frees the
376+
// blocked thread, so the daemon is killed; its death watcher then fails any
377+
// pending requests and fires the respawn flow.
349378
pending.remove(id)
350-
return DaemonReply.Failed("Daemon write failed: ${e.message}", daemonDied = true)
379+
process?.destroyForcibly()
380+
return DaemonReply.Failed(
381+
"Daemon stopped reading requests ('$op' write timed out)",
382+
daemonDied = true,
383+
)
384+
}
385+
val writeError = writeOutcome.exceptionOrNull()
386+
if (writeError != null) {
387+
pending.remove(id)
388+
return DaemonReply.Failed("Daemon write failed: ${writeError.message}", daemonDied = true)
351389
}
352390

353391
val response =
@@ -374,9 +412,9 @@ class DaemonProcessClient(
374412
// which reports no compile counts - carries none rather than a measured zero.
375413
DaemonReply.BuildFailed(
376414
parseDiagnostics(response),
377-
CompileStats.fromValues { key ->
378-
response.get(key)?.takeIf { it.isJsonPrimitive }?.asLong
379-
},
415+
// longOrNull, not a bare asLong: a malformed stats value degrades to
416+
// absent instead of throwing out of the facade's no-throw contract.
417+
CompileStats.fromValues { key -> response.longOrNull(key) },
380418
)
381419
}
382420
}
@@ -450,24 +488,48 @@ class DaemonProcessClient(
450488
*
451489
* @param response the `ok=false` response object.
452490
* @return one [BuildDiagnostic] per well-formed entry, empty when the key is absent or not an
453-
* array; anything but an explicit `WARNING` reads as an error and a missing message becomes
454-
* "unknown error", so a diagnostic is never dropped for being thin.
491+
* array; anything but an explicit `WARNING` reads as an error, a missing or non-primitive
492+
* message becomes "unknown error", and a non-numeric line or column reads as absent, so a
493+
* diagnostic is never dropped - and never thrown on - for being thin or oddly shaped.
455494
*/
456495
private fun parseDiagnostics(response: JsonObject): List<BuildDiagnostic> {
457496
val array = response.get(ResponseKeys.DIAGNOSTICS) as? JsonArray ?: return emptyList()
458497
return array.mapNotNull { element ->
459498
val obj = element as? JsonObject ?: return@mapNotNull null
460499
BuildDiagnostic(
500+
// Primitive-guarded like every other read in this file: asString on an object
501+
// or array throws, and this facade promises never to throw for a build problem.
461502
severity =
462-
if (obj.get(ResponseKeys.Diagnostics.SEVERITY)?.asString.equals("WARNING", ignoreCase = true)) {
503+
if (obj
504+
.get(ResponseKeys.Diagnostics.SEVERITY)
505+
?.takeIf { it.isJsonPrimitive }
506+
?.asString
507+
.equals("WARNING", ignoreCase = true)
508+
) {
463509
BuildDiagnostic.Severity.WARNING
464510
} else {
465511
BuildDiagnostic.Severity.ERROR
466512
},
467-
message = obj.get(ResponseKeys.Diagnostics.MESSAGE)?.asString ?: "unknown error",
513+
message =
514+
obj
515+
.get(ResponseKeys.Diagnostics.MESSAGE)
516+
?.takeIf { it.isJsonPrimitive }
517+
?.asString ?: "unknown error",
468518
file = obj.get(ResponseKeys.Diagnostics.FILE)?.takeIf { it.isJsonPrimitive }?.asString,
469-
line = obj.get(ResponseKeys.Diagnostics.LINE)?.takeIf { it.isJsonPrimitive }?.asInt,
470-
column = obj.get(ResponseKeys.Diagnostics.COLUMN)?.takeIf { it.isJsonPrimitive }?.asInt,
519+
// The primitive guard alone does not stop asInt throwing NumberFormatException
520+
// on a non-numeric string primitive ("line":"abc"); runCatching does.
521+
line =
522+
obj
523+
.get(ResponseKeys.Diagnostics.LINE)
524+
?.takeIf { it.isJsonPrimitive }
525+
?.runCatching { asInt }
526+
?.getOrNull(),
527+
column =
528+
obj
529+
.get(ResponseKeys.Diagnostics.COLUMN)
530+
?.takeIf { it.isJsonPrimitive }
531+
?.runCatching { asInt }
532+
?.getOrNull(),
471533
)
472534
}
473535
}

‎quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClientEdgeTest.kt‎

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@ import com.google.common.truth.Truth.assertThat
44
import kotlinx.coroutines.CoroutineScope
55
import kotlinx.coroutines.Dispatchers
66
import kotlinx.coroutines.SupervisorJob
7+
import kotlinx.coroutines.async
78
import kotlinx.coroutines.cancel
9+
import kotlinx.coroutines.delay
810
import kotlinx.coroutines.runBlocking
911
import kotlinx.coroutines.withTimeout
12+
import kotlinx.coroutines.withTimeoutOrNull
1013
import org.appdevforall.cotg.quickbuild.domain.reload.BuildDiagnostic
1114
import org.appdevforall.cotg.quickbuild.protocol.ConfigureRequest
1215
import org.junit.jupiter.api.Test
@@ -439,6 +442,207 @@ class DaemonProcessClientEdgeTest {
439442
assertThat(oddShapes.column).isNull()
440443
}
441444

445+
@Test
446+
fun `a non-primitive severity or message degrades instead of throwing out of compile`() {
447+
// asString on an object or array throws UnsupportedOperationException, which unguarded
448+
// escaped parseDiagnostics straight out of compile() - the facade's no-throw contract
449+
// says a malformed diagnostic must degrade (severity -> ERROR, message -> the default).
450+
val diagnostics =
451+
"""[
452+
{"severity":{"level":"WARNING"},"message":"kept"},
453+
{"severity":"ERROR","message":["broken","in","parts"]}
454+
]""".replace(Regex("\\s+"), "")
455+
val paths =
456+
scriptedPaths(
457+
"""
458+
read line
459+
printf '%s\n' '${okConfigure()}'
460+
read line
461+
printf '%s\n' '{"id":2,"ok":false,"diagnostics":$diagnostics}'
462+
read line
463+
printf '%s\n' '{"id":3,"ok":true}'
464+
""".trimIndent(),
465+
)
466+
467+
val reply =
468+
withClient(paths) { client ->
469+
check(client.start(config()) is DaemonReply.Ok)
470+
client.compile(emptyList(), emptyList())
471+
}
472+
473+
val failure = reply as DaemonReply.BuildFailed
474+
assertThat(failure.diagnostics).hasSize(2)
475+
val (objectSeverity, arrayMessage) = failure.diagnostics
476+
assertThat(objectSeverity.severity).isEqualTo(BuildDiagnostic.Severity.ERROR)
477+
assertThat(objectSeverity.message).isEqualTo("kept")
478+
assertThat(arrayMessage.severity).isEqualTo(BuildDiagnostic.Severity.ERROR)
479+
assertThat(arrayMessage.message).isEqualTo("unknown error")
480+
}
481+
482+
@Test
483+
fun `a non-numeric line or column string reads as absent instead of throwing`() {
484+
// "abc" IS a JSON primitive, so the isJsonPrimitive guard passes and asInt throws
485+
// NumberFormatException - the crash path the "odd shapes" test stopped short of
486+
// (its "3" coerces cleanly). The message must still come through untouched.
487+
val diagnostics =
488+
"""[{"severity":"ERROR","message":"bad positions","file":"A.kt","line":"abc","column":"1.5"}]"""
489+
val paths =
490+
scriptedPaths(
491+
"""
492+
read line
493+
printf '%s\n' '${okConfigure()}'
494+
read line
495+
printf '%s\n' '{"id":2,"ok":false,"diagnostics":$diagnostics}'
496+
read line
497+
printf '%s\n' '{"id":3,"ok":true}'
498+
""".trimIndent(),
499+
)
500+
501+
val reply =
502+
withClient(paths) { client ->
503+
check(client.start(config()) is DaemonReply.Ok)
504+
client.compile(emptyList(), emptyList())
505+
}
506+
507+
val failure = reply as DaemonReply.BuildFailed
508+
assertThat(failure.diagnostics).hasSize(1)
509+
val diagnostic = failure.diagnostics.single()
510+
assertThat(diagnostic.message).isEqualTo("bad positions")
511+
assertThat(diagnostic.file).isEqualTo("A.kt")
512+
assertThat(diagnostic.line).isNull()
513+
assertThat(diagnostic.column).isNull()
514+
}
515+
516+
@Test
517+
fun `a malformed stats value on a build failure degrades instead of throwing`() {
518+
// Same crash class as line/column: "slow" IS a JSON primitive, so a primitive guard
519+
// alone lets asLong throw NumberFormatException out of the BuildFailed arm; a
520+
// non-primitive value must degrade too. The readable key still comes through.
521+
val paths =
522+
scriptedPaths(
523+
"""
524+
read line
525+
printf '%s\n' '${okConfigure()}'
526+
read line
527+
printf '%s\n' '{"id":2,"ok":false,"diagnostics":[],"preSnapMillis":"slow","nKotlinToCompile":[3],"compileOrdinal":2}'
528+
read line
529+
printf '%s\n' '{"id":3,"ok":true}'
530+
""".trimIndent(),
531+
)
532+
533+
val reply =
534+
withClient(paths) { client ->
535+
check(client.start(config()) is DaemonReply.Ok)
536+
client.compile(emptyList(), emptyList())
537+
}
538+
539+
val failure = reply as DaemonReply.BuildFailed
540+
val stats = failure.stats
541+
assertThat(stats).isNotNull()
542+
assertThat(stats!!.compileOrdinal).isEqualTo(2)
543+
assertThat(stats.preSnapMillis).isEqualTo(0)
544+
assertThat(stats.kotlinToCompile).isEqualTo(0)
545+
}
546+
547+
@Test
548+
fun `a request the daemon never reads times out instead of wedging the client`() {
549+
// After configure the script stops reading stdin, so a request larger than the pipe
550+
// buffer blocks the write forever while it holds the request mutex - unfixed, every
551+
// later request parks on the mutex and shutdown() deadlocks on the writer's monitor.
552+
// The client must bound the write, kill the wedged child, and report a Failed reply.
553+
val paths =
554+
scriptedPaths(
555+
"""
556+
printf '%s' "${'$'}${'$'}" > '$tmp/daemon.pid'
557+
read line
558+
printf '%s\n' '${okConfigure()}'
559+
exec sleep 120
560+
""".trimIndent(),
561+
)
562+
// ~4MB of source paths: far past any pipe buffer, so the write reliably blocks.
563+
val bigSources = (1..40_000).map { File(tmp, "src/deeply/nested/pkg/SourceFile$it.kt") }
564+
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
565+
val client = DaemonProcessClient(paths, scope, requestTimeoutMillis = 500)
566+
var reply: DaemonReply<CompileOutput>? = null
567+
568+
try {
569+
runBlocking {
570+
check(client.start(config()) is DaemonReply.Ok)
571+
// On [scope], not runBlocking's: unfixed, the call never returns, and a
572+
// structured child would deadlock runBlocking itself on the way out.
573+
val call = scope.async { client.compile(bigSources, emptyList()) }
574+
reply = withTimeoutOrNull(30_000) { call.await() }
575+
}
576+
// null means the client sat on the wedged write - the hang this test pins.
577+
assertThat(reply).isNotNull()
578+
assertThat(reply).isInstanceOf(DaemonReply.Failed::class.java)
579+
val failed = reply as DaemonReply.Failed
580+
assertThat(failed.message).contains("compile")
581+
assertThat(failed.message).contains("write timed out")
582+
assertThat(failed.daemonDied).isTrue()
583+
} finally {
584+
// Unwedge a stuck writer before shutdown: killing the child closes the pipe, so
585+
// a still-blocked write (the unfixed case) throws instead of deadlocking
586+
// writer.close() and hanging the test run in teardown.
587+
runCatching {
588+
val pid = File(tmp, "daemon.pid").readText().trim()
589+
ProcessBuilder("/bin/sh", "-c", "kill -9 $pid 2>/dev/null").start().waitFor()
590+
}
591+
runBlocking { client.shutdown() }
592+
scope.cancel()
593+
}
594+
}
595+
596+
@Test
597+
fun `shutdown is not deadlocked by a write the daemon never reads`() {
598+
// The wedged-write scenario again, but teardown-first: with the write still blocked
599+
// (its own timeout deliberately far off), shutdown() used to park on the
600+
// BufferedWriter monitor in writer.close() and never reach destroyForcibly.
601+
val paths =
602+
scriptedPaths(
603+
"""
604+
printf '%s' "${'$'}${'$'}" > '$tmp/daemon.pid'
605+
read line
606+
printf '%s\n' '${okConfigure()}'
607+
exec sleep 120
608+
""".trimIndent(),
609+
)
610+
val bigSources = (1..40_000).map { File(tmp, "src/deeply/nested/pkg/SourceFile$it.kt") }
611+
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
612+
val client = DaemonProcessClient(paths, scope, requestTimeoutMillis = 120_000)
613+
614+
try {
615+
val completed =
616+
runBlocking {
617+
check(client.start(config()) is DaemonReply.Ok)
618+
scope.async { client.compile(bigSources, emptyList()) }
619+
// Long enough for the compile write to fill the pipe and block; a
620+
// shutdown that won the race to the writer would close it cleanly
621+
// and pass even unfixed.
622+
delay(2_000)
623+
// On [scope]: unfixed, shutdown never returns, and neither a structured
624+
// child nor withTimeoutOrNull could pull the test out of it.
625+
val shutdownJob = scope.async { client.shutdown() }
626+
withTimeoutOrNull(30_000) {
627+
shutdownJob.await()
628+
true
629+
}
630+
}
631+
// null means shutdown deadlocked behind the wedged writer.
632+
assertThat(completed).isNotNull()
633+
assertThat(client.isRunning).isFalse()
634+
} finally {
635+
// Frees the blocked write in the unfixed case so teardown can finish - see the
636+
// wedged-request test above.
637+
runCatching {
638+
val pid = File(tmp, "daemon.pid").readText().trim()
639+
ProcessBuilder("/bin/sh", "-c", "kill -9 $pid 2>/dev/null").start().waitFor()
640+
}
641+
runBlocking { client.shutdown() }
642+
scope.cancel()
643+
}
644+
}
645+
442646
@Test
443647
fun `a build failure without a diagnostics array reports none`() {
444648
val paths =

0 commit comments

Comments
 (0)