fix(desktop): kill the whole process tree when a now-playing command times out

On Linux /bin/sh is dash, which forks the command instead of exec-ing it.
The timeout watchdog killed only the shell, the orphaned child kept stdout
open, and runCommand's read waited for it however long it ran, so
OsNowPlayingReaderTest.runCommandGivesUpOnAHungCommand failed on the
ubuntu desktop CI job (30s instead of ~1s). macOS's sh execs the command,
which is why it passed there. Destroy the descendants before the process,
on timeout and in the finally block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017KKAj1seHQLCcVEiMeePEE
This commit is contained in:
Claude
2026-10-01 14:36:33 -04:00
committed by Vitor Pamplona
parent 5b71dc735b
commit b9e0a8ecbc
@@ -71,7 +71,7 @@ internal suspend fun runCommand(
val watchdog =
thread(isDaemon = true, name = "now-playing-command-timeout") {
try {
if (!started.waitFor(timeoutSeconds, TimeUnit.SECONDS)) started.destroyForcibly()
if (!started.waitFor(timeoutSeconds, TimeUnit.SECONDS)) started.destroyTree()
} catch (_: InterruptedException) {
// The command finished first.
}
@@ -87,6 +87,16 @@ internal suspend fun runCommand(
Log.d("OsNowPlayingReader") { "${command.first()} failed: ${e.message}" }
null
} finally {
process?.takeIf { it.isAlive }?.destroyForcibly()
process?.destroyTree()
}
}
/**
* Kills [this] and everything it started. Killing only the process is not enough: a shell that forks
* its command (dash, Ubuntu's `/bin/sh`, does) leaves the child holding stdout open, so the read in
* [runCommand] would wait for that child however long it runs, timeout or not.
*/
private fun Process.destroyTree() {
descendants().forEach { it.destroyForcibly() }
if (isAlive) destroyForcibly()
}