From c28e3e562ceada5c6802fb5b8f96796906680bbf Mon Sep 17 00:00:00 2001 From: Nico Verbruggen Date: Tue, 23 Dec 2025 12:07:28 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20Fix=20race=20condition=20on=20Re?= =?UTF-8?q?alShell.attach()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- phpmon/Common/Shell/RealShell.swift | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/phpmon/Common/Shell/RealShell.swift b/phpmon/Common/Shell/RealShell.swift index 4965808d..e57e36b1 100644 --- a/phpmon/Common/Shell/RealShell.swift +++ b/phpmon/Common/Shell/RealShell.swift @@ -227,19 +227,29 @@ class RealShell: ShellProtocol { process.standardError = errorPipe let output = ShellOutput.empty() + + // Only access `resumed`, `output` from serialQueue to ensure thread safety let serialQueue = DispatchQueue(label: "com.nicoverbruggen.phpmon.shell_output") return try await withCheckedThrowingContinuation({ continuation in - let timeoutTask = Task { - try? await Task.sleep(nanoseconds: timeout.nanoseconds) - // Only terminate if the process is still running + // Guard against resuming the continuation twice (race between timeout and termination) + var resumed = false + + // We are using GCD here because we're already using a serial queue anyway + let timeoutTaskTermination = DispatchWorkItem { if process.isRunning { process.terminationHandler = nil process.terminate() - continuation.resume(throwing: ShellError.timedOut) + if !resumed { + resumed = true + continuation.resume(throwing: ShellError.timedOut) + } } } + // Let's make sure that once our timeout occurs, our process is terminated + serialQueue.asyncAfter(deadline: .now() + timeout, execute: timeoutTaskTermination) + // Set up background reading for stdout outputPipe.fileHandleForReading.readabilityHandler = { fileHandle in let data = fileHandle.availableData @@ -263,7 +273,7 @@ class RealShell: ShellProtocol { } process.terminationHandler = { process in - timeoutTask.cancel() + timeoutTaskTermination.cancel() // Clean up readability handlers outputPipe.fileHandleForReading.readabilityHandler = nil @@ -284,7 +294,10 @@ class RealShell: ShellProtocol { didReceiveOutput(string, .stdErr) } - continuation.resume(returning: (process, output)) + if !resumed { + resumed = true + continuation.resume(returning: (process, output)) + } } }