diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Transport/SSHScriptRunner.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Transport/SSHScriptRunner.swift index f8dae75..9f101d8 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Transport/SSHScriptRunner.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Transport/SSHScriptRunner.swift @@ -124,10 +124,22 @@ public enum SSHScriptRunner { let deadline = Date().addingTimeInterval(timeout) while proc.isRunning && Date() < deadline { + if Task.isCancelled { + proc.terminate() + try? stdoutPipe.fileHandleForReading.close() + try? stderrPipe.fileHandleForReading.close() + return .connectFailure("Script cancelled") + } try? await Task.sleep(nanoseconds: 100_000_000) } if proc.isRunning { proc.terminate() + // Pipe fds leak otherwise — closing on the timeout branch + // matches the success-path discipline (see CLAUDE.md + // "Always close both fileHandleForReading and + // fileHandleForWriting on Pipe objects"). + try? stdoutPipe.fileHandleForReading.close() + try? stderrPipe.fileHandleForReading.close() return .connectFailure("Script timed out after \(Int(timeout))s") } let out = (try? stdoutPipe.fileHandleForReading.readToEnd()) ?? Data() @@ -162,10 +174,18 @@ public enum SSHScriptRunner { } let deadline = Date().addingTimeInterval(timeout) while proc.isRunning && Date() < deadline { + if Task.isCancelled { + proc.terminate() + try? stdoutPipe.fileHandleForReading.close() + try? stderrPipe.fileHandleForReading.close() + return .connectFailure("Script cancelled") + } try? await Task.sleep(nanoseconds: 100_000_000) } if proc.isRunning { proc.terminate() + try? stdoutPipe.fileHandleForReading.close() + try? stderrPipe.fileHandleForReading.close() return .connectFailure("Script timed out after \(Int(timeout))s") } let out = (try? stdoutPipe.fileHandleForReading.readToEnd()) ?? Data() diff --git a/scarf/Packages/ScarfIOS/Sources/ScarfIOS/CitadelServerTransport.swift b/scarf/Packages/ScarfIOS/Sources/ScarfIOS/CitadelServerTransport.swift index 5077b9b..eb17acb 100644 --- a/scarf/Packages/ScarfIOS/Sources/ScarfIOS/CitadelServerTransport.swift +++ b/scarf/Packages/ScarfIOS/Sources/ScarfIOS/CitadelServerTransport.swift @@ -199,29 +199,65 @@ public final class CitadelServerTransport: ServerTransport, @unchecked Sendable } catch { throw TransportError.other(message: "Failed to start exec stream: \(error.localizedDescription)") } - var stdout = Data() - var stderr = Data() - var exitCode: Int32 = 0 - do { - for try await chunk in stream { - switch chunk { - case .stdout(var buf): - if let s = buf.readString(length: buf.readableBytes) { - stdout.append(Data(s.utf8)) - } - case .stderr(var buf): - if let s = buf.readString(length: buf.readableBytes) { - stderr.append(Data(s.utf8)) + // Drain in a child task and race against a sleep so a wedged remote + // sqlite3 (or a mid-stream Citadel transport failure) can't hang the + // caller indefinitely. Mirrors the busy-wait deadline that + // SSHScriptRunner enforces on Mac. + return try await withThrowingTaskGroup(of: ProcessResult?.self) { group in + group.addTask { + var stdout = Data() + var stderr = Data() + var exitCode: Int32 = 0 + do { + for try await chunk in stream { + try Task.checkCancellation() + switch chunk { + case .stdout(var buf): + if let s = buf.readString(length: buf.readableBytes) { + stdout.append(Data(s.utf8)) + } + case .stderr(var buf): + if let s = buf.readString(length: buf.readableBytes) { + stderr.append(Data(s.utf8)) + } + } } + } catch let failed as SSHClient.CommandFailed { + // Genuine remote non-zero exit — surface as + // ProcessResult so the caller's existing exit-code + // handling fires (mapped to BackendError.sqlite by + // RemoteSQLiteBackend). + exitCode = Int32(failed.exitCode) + } catch is CancellationError { + throw TransportError.timeout(seconds: timeout, partialStdout: stdout) + } catch { + // Transport-level failure (host unreachable, channel + // dropped, ControlMaster died, NIO read error). Throw + // as a typed TransportError so RemoteSQLiteBackend + // routes it to BackendError.transport rather than + // misclassifying as a sqlite crash via a fake -1 exit. + throw TransportError.other( + message: "SSH stream failed: \(error.localizedDescription)" + ) } + return ProcessResult(exitCode: exitCode, stdout: stdout, stderr: stderr) } - } catch let failed as SSHClient.CommandFailed { - exitCode = Int32(failed.exitCode) - } catch { - stderr.append(Data(error.localizedDescription.utf8)) - exitCode = -1 + group.addTask { + try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + return nil + } + guard let first = try await group.next() else { + group.cancelAll() + throw TransportError.other(message: "SSH stream produced no result") + } + group.cancelAll() + if let result = first { + return result + } + // Timeout fired first — drain task gets cancelled by the + // group cancel above; surface as a typed timeout. + throw TransportError.timeout(seconds: timeout, partialStdout: Data()) } - return ProcessResult(exitCode: exitCode, stdout: stdout, stderr: stderr) } // MARK: - ServerTransport: watching diff --git a/scarf/scarf/Features/Projects/Views/Widgets/CronStatusWidgetView.swift b/scarf/scarf/Features/Projects/Views/Widgets/CronStatusWidgetView.swift index b6af410..063206f 100644 --- a/scarf/scarf/Features/Projects/Views/Widgets/CronStatusWidgetView.swift +++ b/scarf/scarf/Features/Projects/Views/Widgets/CronStatusWidgetView.swift @@ -38,7 +38,7 @@ struct CronStatusWidgetView: View { ) } } - .task(id: fileWatcher.lastChangeDate) { + .task(id: "\(jobId ?? "")|\(lineCount)|\(fileWatcher.lastChangeDate.timeIntervalSince1970)") { await reload() } }