fix: harden process termination and retain print task #67
@@ -179,6 +179,14 @@ public actor ProcessManager {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
try process.run()
|
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 {
|
} catch {
|
||||||
preKillHooks.removeValue(forKey: id)
|
preKillHooks.removeValue(forKey: id)
|
||||||
children.removeValue(forKey: id)
|
children.removeValue(forKey: id)
|
||||||
@@ -273,6 +281,14 @@ public actor ProcessManager {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
try process.run()
|
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 {
|
} catch {
|
||||||
_ = box.resume(with: -1)
|
_ = box.resume(with: -1)
|
||||||
captured.removeValue(forKey: id)
|
captured.removeValue(forKey: id)
|
||||||
|
|||||||
@@ -109,6 +109,9 @@ final class TargetWorkflowViewModel {
|
|||||||
var printNotice: String?
|
var printNotice: String?
|
||||||
var printNoticeIsError = false
|
var printNoticeIsError = false
|
||||||
var isPrinting = 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<Void, Never>?
|
||||||
|
|
||||||
// MARK: - Presets
|
// MARK: - Presets
|
||||||
|
|
||||||
@@ -480,7 +483,9 @@ final class TargetWorkflowViewModel {
|
|||||||
func printAllPages() {
|
func printAllPages() {
|
||||||
guard let result = printtargResult, !isPrinting else { return }
|
guard let result = printtargResult, !isPrinting else { return }
|
||||||
isPrinting = true
|
isPrinting = true
|
||||||
Task { @MainActor in
|
let task = Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
defer { self.printTask = nil }
|
||||||
var printed = 0
|
var printed = 0
|
||||||
for page in result.pages {
|
for page in result.pages {
|
||||||
do {
|
do {
|
||||||
@@ -498,13 +503,16 @@ final class TargetWorkflowViewModel {
|
|||||||
printNoticeIsError = false
|
printNoticeIsError = false
|
||||||
isPrinting = false
|
isPrinting = false
|
||||||
}
|
}
|
||||||
|
printTask = task
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `#btnPrintPage-N` — one TIFF.
|
/// `#btnPrintPage-N` — one TIFF.
|
||||||
func printPage(_ page: GalleryPage) {
|
func printPage(_ page: GalleryPage) {
|
||||||
guard !isPrinting else { return }
|
guard !isPrinting else { return }
|
||||||
isPrinting = true
|
isPrinting = true
|
||||||
Task { @MainActor in
|
let task = Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
defer { self.printTask = nil }
|
||||||
do {
|
do {
|
||||||
try await spool(page, index: page.index)
|
try await spool(page, index: page.index)
|
||||||
printNotice = "Sent \(page.page.filename) to \(selectedPrinter)."
|
printNotice = "Sent \(page.page.filename) to \(selectedPrinter)."
|
||||||
@@ -515,6 +523,7 @@ final class TargetWorkflowViewModel {
|
|||||||
}
|
}
|
||||||
isPrinting = false
|
isPrinting = false
|
||||||
}
|
}
|
||||||
|
printTask = task
|
||||||
}
|
}
|
||||||
|
|
||||||
private func spool(_ page: GalleryPage, index: Int) async throws {
|
private func spool(_ page: GalleryPage, index: Int) async throws {
|
||||||
|
|||||||
@@ -170,6 +170,24 @@ struct ProcessManagerTests {
|
|||||||
#expect(result.exitCode == 3)
|
#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 {
|
@Test func capturedRunDoesNotDeadlockOnLargeOutput() async throws {
|
||||||
let pm = ProcessManager()
|
let pm = ProcessManager()
|
||||||
// 5000 lines each stream exceeds the 64 KiB pipe buffer.
|
// 5000 lines each stream exceeds the 64 KiB pipe buffer.
|
||||||
|
|||||||
Reference in New Issue
Block a user