mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
chore: fd-leak cleanup, os.Logger conversion, status-poll backoff
Pre-release maintenance pass picked up across both targets while
debugging the iOS Browse fix:
- LocalTransport / SSHTransport: close the parent's copy of every
pipe write end after spawn so EOF reaches the reader once the child
exits, and explicitly close read ends after draining. Was leaking
one fd per `runProcess`/`streamLines` invocation under load.
- ProcessACPChannel: also close stdout/stderr write-end fds on
channel teardown — same pattern, ACP sessions can churn on long
reconnect loops.
- HermesDataService / HermesLogService / ProjectDashboardService:
replace remaining `print("[Scarf] ...")` debug statements with
os.Logger calls (subsystem="com.scarf"), matching the global rule
that production code uses Logger and `print()` is reserved for
previews + test helpers.
- ProjectDashboardService / IOSCronViewModel: drop redundant
`fileExists` guards before `createDirectory` — the operation is
already mkdir -p across every transport, so the extra round-trip
was pure latency on remote hosts.
- scarfApp.swift: server-status sidebar probe now uses an exponential
backoff (10s → 30s → 60s → 120s → 300s) when consecutive probes
fail, resetting on the first full success. Previously a registered
remote going unreachable hammered pgrep + gateway_state.json every
10s indefinitely; now offline servers settle to a 5-min cadence
while live servers stay snappy.
- Localizable.xcstrings: routine .strings catalog refresh — stale
entries for removed UI strings, picked up new "Stored under
quick_commands:..." subtitle wording.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -191,6 +191,8 @@ public actor ProcessACPChannel: ACPChannel {
|
||||
stdinPipe.fileHandleForReading.closeFile()
|
||||
stdoutPipe.fileHandleForReading.closeFile()
|
||||
stderrPipe.fileHandleForReading.closeFile()
|
||||
stdoutPipe.fileHandleForWriting.closeFile()
|
||||
stderrPipe.fileHandleForWriting.closeFile()
|
||||
|
||||
readerTask?.cancel()
|
||||
stderrTask?.cancel()
|
||||
|
||||
@@ -705,7 +705,7 @@ public actor HermesDataService {
|
||||
do {
|
||||
return try JSONDecoder().decode([HermesToolCall].self, from: data)
|
||||
} catch {
|
||||
print("[Scarf] Failed to decode tool calls: \(error.localizedDescription)")
|
||||
Self.logger.error("Failed to decode tool calls: \(error.localizedDescription, privacy: .public)")
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import Foundation
|
||||
#if canImport(os)
|
||||
import os
|
||||
#endif
|
||||
|
||||
public struct LogEntry: Identifiable, Sendable {
|
||||
public let id: Int
|
||||
@@ -47,6 +50,10 @@ public struct LogEntry: Identifiable, Sendable {
|
||||
}
|
||||
|
||||
public actor HermesLogService {
|
||||
#if canImport(os)
|
||||
nonisolated private static let logger = Logger(subsystem: "com.scarf", category: "HermesLogService")
|
||||
#endif
|
||||
|
||||
/// Local file handle for local contexts. `nil` when following a remote
|
||||
/// log or when no log is open.
|
||||
private var localHandle: FileHandle?
|
||||
@@ -89,8 +96,8 @@ public actor HermesLogService {
|
||||
} catch {
|
||||
// Transient disconnects / command failures: surface once
|
||||
// and stop. Callers typically re-open the log on retry.
|
||||
#if DEBUG
|
||||
print("[Scarf] remote tail ended: \(error.localizedDescription)")
|
||||
#if canImport(os)
|
||||
Self.logger.warning("remote tail ended: \(error.localizedDescription, privacy: .public)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -103,7 +110,9 @@ public actor HermesLogService {
|
||||
do {
|
||||
try localHandle?.close()
|
||||
} catch {
|
||||
print("[Scarf] Failed to close log handle: \(error.localizedDescription)")
|
||||
#if canImport(os)
|
||||
Self.logger.warning("Failed to close log handle: \(error.localizedDescription, privacy: .public)")
|
||||
#endif
|
||||
}
|
||||
localHandle = nil
|
||||
currentPath = nil
|
||||
|
||||
@@ -38,9 +38,10 @@ public struct ProjectDashboardService: Sendable {
|
||||
/// choice is now theirs.
|
||||
public func saveRegistry(_ registry: ProjectRegistry) throws {
|
||||
let dir = context.paths.scarfDir
|
||||
if !transport.fileExists(dir) {
|
||||
try transport.createDirectory(dir)
|
||||
}
|
||||
// `createDirectory` is mkdir -p across every transport (Local
|
||||
// uses withIntermediateDirectories, SSH/Citadel both ignore
|
||||
// "already exists"), so we don't need to fileExists-guard it.
|
||||
try transport.createDirectory(dir)
|
||||
let data = try JSONEncoder().encode(registry)
|
||||
// Pretty-print for readability (agents may read this file).
|
||||
let writeData: Data
|
||||
@@ -62,7 +63,7 @@ public struct ProjectDashboardService: Sendable {
|
||||
do {
|
||||
return try JSONDecoder().decode(ProjectDashboard.self, from: data)
|
||||
} catch {
|
||||
print("[Scarf] Failed to decode dashboard for \(project.name): \(error.localizedDescription)")
|
||||
Self.logger.error("Failed to decode dashboard for \(project.name, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +127,17 @@ public struct LocalTransport: ServerTransport {
|
||||
} catch {
|
||||
throw TransportError.other(message: "Failed to launch \(executable): \(error.localizedDescription)")
|
||||
}
|
||||
// Parent has its own copy of every pipe end after fork. The child
|
||||
// inherits and uses the writing ends of stdout/stderr and the
|
||||
// reading end of stdin; the parent must close its own copies of
|
||||
// those so EOF reaches the parent's reader once the child exits
|
||||
// (otherwise the kernel keeps each fd open as long as any process
|
||||
// holds a reference, and we leak fds).
|
||||
try? stdoutPipe.fileHandleForWriting.close()
|
||||
try? stderrPipe.fileHandleForWriting.close()
|
||||
if stdin != nil {
|
||||
try? stdinPipe.fileHandleForReading.close()
|
||||
}
|
||||
if let stdin {
|
||||
try? stdinPipe.fileHandleForWriting.write(contentsOf: stdin)
|
||||
try? stdinPipe.fileHandleForWriting.close()
|
||||
@@ -189,6 +200,10 @@ public struct LocalTransport: ServerTransport {
|
||||
continuation.finish(throwing: error)
|
||||
return
|
||||
}
|
||||
// Parent's copy of the writing ends — the child has its
|
||||
// own; close ours so EOF reaches the reader after exit.
|
||||
try? outPipe.fileHandleForWriting.close()
|
||||
try? errPipe.fileHandleForWriting.close()
|
||||
let handle = outPipe.fileHandleForReading
|
||||
var buffer = Data()
|
||||
while true {
|
||||
@@ -204,11 +219,18 @@ public struct LocalTransport: ServerTransport {
|
||||
}
|
||||
}
|
||||
proc.waitUntilExit()
|
||||
let stderrTail: String
|
||||
if proc.terminationStatus != 0 {
|
||||
let stderr = (try? errPipe.fileHandleForReading.readToEnd())
|
||||
stderrTail = (try? errPipe.fileHandleForReading.readToEnd())
|
||||
.flatMap { String(data: $0 ?? Data(), encoding: .utf8) } ?? ""
|
||||
} else {
|
||||
stderrTail = ""
|
||||
}
|
||||
try? outPipe.fileHandleForReading.close()
|
||||
try? errPipe.fileHandleForReading.close()
|
||||
if proc.terminationStatus != 0 {
|
||||
continuation.finish(throwing: TransportError.commandFailed(
|
||||
exitCode: proc.terminationStatus, stderr: stderr
|
||||
exitCode: proc.terminationStatus, stderr: stderrTail
|
||||
))
|
||||
} else {
|
||||
continuation.finish()
|
||||
|
||||
@@ -474,6 +474,10 @@ public struct SSHTransport: ServerTransport {
|
||||
continuation.finish(throwing: error)
|
||||
return
|
||||
}
|
||||
// Parent's copy of the writing ends — close ours so EOF
|
||||
// reaches the reader after the child exits.
|
||||
try? outPipe.fileHandleForWriting.close()
|
||||
try? errPipe.fileHandleForWriting.close()
|
||||
let handle = outPipe.fileHandleForReading
|
||||
var buffer = Data()
|
||||
while true {
|
||||
@@ -489,11 +493,18 @@ public struct SSHTransport: ServerTransport {
|
||||
}
|
||||
}
|
||||
proc.waitUntilExit()
|
||||
let stderrTail: String
|
||||
if proc.terminationStatus != 0 {
|
||||
let stderr = (try? errPipe.fileHandleForReading.readToEnd())
|
||||
stderrTail = (try? errPipe.fileHandleForReading.readToEnd())
|
||||
.flatMap { String(data: $0 ?? Data(), encoding: .utf8) } ?? ""
|
||||
} else {
|
||||
stderrTail = ""
|
||||
}
|
||||
try? outPipe.fileHandleForReading.close()
|
||||
try? errPipe.fileHandleForReading.close()
|
||||
if proc.terminationStatus != 0 {
|
||||
continuation.finish(throwing: TransportError.classifySSHFailure(
|
||||
host: config.host, exitCode: proc.terminationStatus, stderr: stderr
|
||||
host: config.host, exitCode: proc.terminationStatus, stderr: stderrTail
|
||||
))
|
||||
} else {
|
||||
continuation.finish()
|
||||
@@ -662,6 +673,13 @@ public struct SSHTransport: ServerTransport {
|
||||
} catch {
|
||||
throw TransportError.other(message: "Failed to launch \(executable): \(error.localizedDescription)")
|
||||
}
|
||||
// Parent's copy of the inherited ends — close so EOF lands when
|
||||
// the child exits and we don't leak fds.
|
||||
try? stdoutPipe.fileHandleForWriting.close()
|
||||
try? stderrPipe.fileHandleForWriting.close()
|
||||
if stdin != nil {
|
||||
try? stdinPipe.fileHandleForReading.close()
|
||||
}
|
||||
if let stdin {
|
||||
try? stdinPipe.fileHandleForWriting.write(contentsOf: stdin)
|
||||
try? stdinPipe.fileHandleForWriting.close()
|
||||
|
||||
@@ -119,10 +119,11 @@ public final class IOSCronViewModel {
|
||||
let transport = ctx.makeTransport()
|
||||
// Ensure the cron/ directory exists — on a fresh
|
||||
// Hermes install this file won't be present.
|
||||
// `createDirectory` is mkdir -p across all transports;
|
||||
// call unconditionally and let writeFile surface any
|
||||
// real failure.
|
||||
let parent = (path as NSString).deletingLastPathComponent
|
||||
if !transport.fileExists(parent) {
|
||||
try? transport.createDirectory(parent)
|
||||
}
|
||||
try? transport.createDirectory(parent)
|
||||
try transport.writeFile(path, data: data)
|
||||
return true
|
||||
} catch {
|
||||
|
||||
Reference in New Issue
Block a user