@@ -4,9 +4,12 @@ import com.google.common.truth.Truth.assertThat
44import kotlinx.coroutines.CoroutineScope
55import kotlinx.coroutines.Dispatchers
66import kotlinx.coroutines.SupervisorJob
7+ import kotlinx.coroutines.async
78import kotlinx.coroutines.cancel
9+ import kotlinx.coroutines.delay
810import kotlinx.coroutines.runBlocking
911import kotlinx.coroutines.withTimeout
12+ import kotlinx.coroutines.withTimeoutOrNull
1013import org.appdevforall.cotg.quickbuild.domain.reload.BuildDiagnostic
1114import org.appdevforall.cotg.quickbuild.protocol.ConfigureRequest
1215import 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