From 1d9c6ae33496d45ff842669e8a07f8d6283526c4 Mon Sep 17 00:00:00 2001 From: Gronod Date: Thu, 10 Sep 2026 00:14:22 +0100 Subject: [PATCH] fix: harden process termination and retain print task Add waitUntilExit() fallbacks to ProcessManager.runStreaming and runCaptured so fast child exits cannot race past the terminationHandler. Retain TargetWorkflowViewModel.printTask across printAllPages/printPage to prevent the unstructured Task from being dropped before it resumes. Add two ProcessManager tests covering fast-exit and stderr-only captured runs. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../ICCeryCore/Process/ProcessManager.swift | 16 ++++++++++++++++ Sources/ICCery/TargetWorkflowViewModel.swift | 13 +++++++++++-- .../ICCeryCoreTests/ProcessManagerTests.swift | 18 ++++++++++++++++++ 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessManager.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessManager.swift index 88c7f2d..627bda6 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessManager.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessManager.swift @@ -179,6 +179,14 @@ public actor ProcessManager { do { try process.run() + // Fallback watchdog: very fast child exits can race past the + // terminationHandler delivery on a loaded host. waitUntilExit() + // blocks the detached thread and guarantees didTerminate runs. + Task.detached { [weak self, process] in + process.waitUntilExit() + guard let self else { return } + await self.didTerminate(id: id, code: process.terminationStatus) + } } catch { preKillHooks.removeValue(forKey: id) children.removeValue(forKey: id) @@ -273,6 +281,14 @@ public actor ProcessManager { do { try process.run() + // Fallback watchdog: very fast child exits can race past the + // terminationHandler delivery on a loaded host. waitUntilExit() + // blocks the detached thread and resumes the box if the handler + // did not already do so (#50, #52). + Task.detached { [capturedProcess] in + capturedProcess.waitUntilExit() + _ = box.resume(with: capturedProcess.terminationStatus) + } } catch { _ = box.resume(with: -1) captured.removeValue(forKey: id) diff --git a/Sources/ICCery/TargetWorkflowViewModel.swift b/Sources/ICCery/TargetWorkflowViewModel.swift index 960bf40..689012d 100644 --- a/Sources/ICCery/TargetWorkflowViewModel.swift +++ b/Sources/ICCery/TargetWorkflowViewModel.swift @@ -109,6 +109,9 @@ final class TargetWorkflowViewModel { var printNotice: String? var printNoticeIsError = false var isPrinting = false + /// Strong reference to the active print task so the unstructured + /// `Task` is not dropped before it resumes. + private var printTask: Task? // MARK: - Presets @@ -480,7 +483,9 @@ final class TargetWorkflowViewModel { func printAllPages() { guard let result = printtargResult, !isPrinting else { return } isPrinting = true - Task { @MainActor in + let task = Task { @MainActor [weak self] in + guard let self else { return } + defer { self.printTask = nil } var printed = 0 for page in result.pages { do { @@ -498,13 +503,16 @@ final class TargetWorkflowViewModel { printNoticeIsError = false isPrinting = false } + printTask = task } /// `#btnPrintPage-N` — one TIFF. func printPage(_ page: GalleryPage) { guard !isPrinting else { return } isPrinting = true - Task { @MainActor in + let task = Task { @MainActor [weak self] in + guard let self else { return } + defer { self.printTask = nil } do { try await spool(page, index: page.index) printNotice = "Sent \(page.page.filename) to \(selectedPrinter)." @@ -515,6 +523,7 @@ final class TargetWorkflowViewModel { } isPrinting = false } + printTask = task } private func spool(_ page: GalleryPage, index: Int) async throws { diff --git a/Tests/ICCeryCoreTests/ProcessManagerTests.swift b/Tests/ICCeryCoreTests/ProcessManagerTests.swift index 3473d74..083d5f7 100644 --- a/Tests/ICCeryCoreTests/ProcessManagerTests.swift +++ b/Tests/ICCeryCoreTests/ProcessManagerTests.swift @@ -170,6 +170,24 @@ struct ProcessManagerTests { #expect(result.exitCode == 3) } + @Test func capturedRunFastExit() async throws { + let pm = ProcessManager() + let bin = try script("fast.sh", "#!/bin/sh\nexit 7\n") + let result = try await pm.runCaptured(id: "fast", binary: bin, arguments: []) + #expect(result.exitCode == 7) + #expect(result.stdout == "") + #expect(result.stderr == "") + } + + @Test func capturedRunStderrOnly() async throws { + let pm = ProcessManager() + let bin = try script("stderr-only.sh", "#!/bin/sh\necho 'mock lp failure' 1>&2\nexit 1\n") + let result = try await pm.runCaptured(id: "stderr-only", binary: bin, arguments: []) + #expect(result.exitCode == 1) + #expect(result.stdout == "") + #expect(result.stderr.contains("mock lp failure")) + } + @Test func capturedRunDoesNotDeadlockOnLargeOutput() async throws { let pm = ProcessManager() // 5000 lines each stream exceeds the 64 KiB pipe buffer. -- 2.39.5