fix(desktop): read now-playing command output while the command runs

runCommand waited for the process to exit before reading its stdout. On a
busy Mac `ps -A -o comm=` prints ~100 KB, past the pipe buffer, so ps blocked
on the write, waitFor hit the 5 s timeout and the macOS reader never saw
Spotify or Music (and every poll stalled 5 s). Read stdout to EOF while a
watchdog thread kills a command that outlives the timeout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-10-01 10:38:28 -04:00
co-authored by Claude Opus 5.5
parent bc1f970dd9
commit 5b7cffc72d
2 changed files with 39 additions and 7 deletions
@@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import java.util.concurrent.TimeUnit
import kotlin.concurrent.thread
/** Reads what the OS says another app is playing right now. */
interface OsNowPlayingReader {
@@ -63,13 +64,22 @@ internal suspend fun runCommand(
.start()
process = started
started.outputStream.close()
// Wait before reading so a hung command cannot block the read forever. The outputs
// here are a few KB, well under the pipe buffer, so the command never blocks on it.
if (!started.waitFor(timeoutSeconds, TimeUnit.SECONDS) || started.exitValue() != 0) {
null
} else {
started.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
}
// Read while the command runs: `ps -A -o comm=` prints ~100 KB on a busy Mac, past the
// pipe buffer, so waiting for the exit before reading deadlocked until the timeout and
// the reader never saw a player. The watchdog still kills a command that hangs, which
// also ends the read.
val watchdog =
thread(isDaemon = true, name = "now-playing-command-timeout") {
try {
if (!started.waitFor(timeoutSeconds, TimeUnit.SECONDS)) started.destroyForcibly()
} catch (_: InterruptedException) {
// The command finished first.
}
}
val output = started.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
val exited = started.waitFor(timeoutSeconds, TimeUnit.SECONDS)
watchdog.interrupt()
if (!exited || started.exitValue() != 0) null else output
} catch (e: InterruptedException) {
throw e
} catch (e: Exception) {
@@ -22,9 +22,11 @@ package com.vitorpamplona.amethyst.desktop.nowPlaying
import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.nowPlaying.NowPlayingSource
import kotlinx.coroutines.test.runTest
import java.io.File
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class OsNowPlayingReaderTest {
private val listNames =
@@ -215,4 +217,24 @@ class OsNowPlayingReaderTest {
assertEquals("ZuneMusic", WindowsNowPlayingReader.appLabel("Microsoft.ZuneMusic_8wekyb3d8bbwe!Microsoft.ZuneMusic"))
assertEquals("308046B0AF4A39CB", WindowsNowPlayingReader.appLabel("308046B0AF4A39CB"))
}
private val hasShell = File("/bin/sh").canExecute()
/** `ps -A -o comm=` prints ~100 KB on a busy Mac; reading only after the exit deadlocked on the pipe. */
@Test
fun runCommandReturnsOutputLargerThanThePipeBuffer() =
runTest {
if (!hasShell) return@runTest
val output = runCommand(listOf("/bin/sh", "-c", "i=0; while [ \$i -lt 4000 ]; do echo 0123456789012345678901234567890123456789012345678; i=\$((i+1)); done"))
assertEquals(4000 * 50, output?.length)
}
@Test
fun runCommandGivesUpOnAHungCommand() =
runTest {
if (!hasShell) return@runTest
val started = System.currentTimeMillis()
assertNull(runCommand(listOf("/bin/sh", "-c", "sleep 30"), timeoutSeconds = 1))
assertTrue(System.currentTimeMillis() - started < 10_000, "the timeout must end the call")
}
}