From 5b7cffc72d0d4bc40696256612e5a2afd87f3438 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 1 Oct 2026 10:38:28 -0400 Subject: [PATCH] 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 --- .../desktop/nowPlaying/OsNowPlayingReader.kt | 24 +++++++++++++------ .../nowPlaying/OsNowPlayingReaderTest.kt | 22 +++++++++++++++++ 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/OsNowPlayingReader.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/OsNowPlayingReader.kt index 7a8cb58c6d..f8aad95b00 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/OsNowPlayingReader.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/OsNowPlayingReader.kt @@ -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) { diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/OsNowPlayingReaderTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/OsNowPlayingReaderTest.kt index 02e0bc97c2..36b29a93cc 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/OsNowPlayingReaderTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/nowPlaying/OsNowPlayingReaderTest.kt @@ -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") + } }