RsyncProcessStreaming is a Swift package for executing rsync while streaming stdout and stderr in real time. It keeps memory usage low, surfaces errors quickly, and mirrors the handler-oriented API used in RsyncUI.
Provide a robust, handler-driven interface to run rsync with live output streaming, error-aware processing, cancellation, and optional timeout controlβideal for responsive CLIs and UIs.
- Live line-by-line streaming: Streams stdout incrementally and preserves partial lines until complete, enabling responsive UIs and progress indicators.
- Error-aware processing: Captures stderr separately and lets clients enforce custom error detection per line before the process completes.
- Rolling accumulation: Maintains an in-memory rolling buffer of all output without requiring full buffering before callbacks fire.
- Resource-safe lifecycle: Starts, monitors, cancels, and cleans up
Processinstances without leaking pipe handlers. - Configurable environment: Supports custom
rsyncpaths and environment variables to match user installations.
Add the package to your Package.swift dependencies:
dependencies: [
.package(url: "https://github.com/<your-org>/RsyncProcessStreaming.git", from: "0.1.0")
]Then add RsyncProcessStreaming to your target:
targets: [
.target(
name: "YourApp",
dependencies: [
.product(name: "RsyncProcessStreaming", package: "RsyncProcessStreaming")
]
)
]If you have the repo locally, you can add it as a local package in Xcode or reference it via a relative path in a workspace.
- macOS 14+
Here's a minimal example to sync two directories with live progress:
import RsyncProcessStreaming
@MainActor
func syncFolders() async throws {
let handlers = ProcessHandlers(
processTermination: { output, _ in
if let lines = output {
print("β Sync completed with \(lines.count) output lines")
}
},
fileHandler: { count in
print("π¦ Processed \(count) files...")
},
rsyncPath: "/usr/bin/rsync",
checkLineForError: { line in
// Detect rsync errors in real time
if line.contains("rsync error:") || line.contains("failed:") {
throw RsyncProcessError.processFailed(exitCode: 1, errors: [line])
}
},
updateProcess: { process in
if let pid = process?.processIdentifier {
print("π Process started with PID: \(pid)")
}
},
propagateError: { error in
print("β Error: \(error.localizedDescription)")
},
checkForErrorInRsyncOutput: true,
environment: nil
)
let process = RsyncProcess(
arguments: ["-av", "--progress", "~/source/", "~/destination/"],
handlers: handlers,
useFileHandler: true,
timeout: 60
)
try process.executeProcess()
}
// Usage
Task { @MainActor in
try await syncFolders()
}Key Points:
- Set
useFileHandler: trueto get per-line progress callbacks. - Use
checkLineForErrorto abort on custom error patterns. timeoutensures runaway processes terminate automatically.- All output is accumulated and delivered to
processTermination.
-
RsyncProcess(Sources/RsyncProcessStreaming/RsyncProcessStreaming.swift)init(arguments:hiddenID:handlers:useFileHandler:timeout:)wires rsync arguments with a user-provided handler set and optional timeout.executeProcess()validates the executable, spawns the process, wires streaming handlers, and begins collection.cancel()terminates a running process, marking it as cancelled for downstream handling.- State surfaces via
isRunningandisCancelledto simplify UI binding. - Convenience surfaces:
currentState,commandDescription,processIdentifier,terminationStatus.
-
ProcessHandlers(Sources/RsyncProcessStreaming/ProcessHandlers.swift)- Callbacks for termination, per-file counting, process updates, error propagation, and per-line error checks.
- Configuration knobs for rsync path, rsync v3 compatibility, stderr checking, and environment.
withOutputCapture(...)convenience builder for common setups.
-
StreamAccumulator(internal) (Sources/RsyncProcessStreaming/Internal/StreamAccumulator.swift)- Actor that splits incoming text into lines, preserves trailing partials, and counts lines for progress callbacks.
- Retains both stdout and stderr snapshots for post-run inspection.
-
Logging utilities (Sources/RsyncProcessStreaming/Internal/PackageLogger.swift)
Logger.processcategory with debug-only helpers for command and thread diagnostics.
- Setup: Build
ProcessHandlersto define callbacks and configure rsync path/version/environment. - Start: Instantiate
RsyncProcessand callexecuteProcess(); validation guards against missing executables. - Streaming:
AsyncStreamreaders feed stdout/stderr intoStreamAccumulator, emitting lines to callbacks immediately. - Error detection: Custom
checkLineForErrorcan abort early; stderr content is recorded for termination review. - Termination: Final buffers flush,
processTerminationfires with accumulated stdout and optional hidden ID, andupdateProcess(nil)clears state. - Cancellation:
cancel()stops the underlying process and propagatesprocessCancelledto consumers.
The package employs a multi-stage termination strategy to ensure all process output is captured before cleanup:
When the Process terminates, the terminationHandler is called on an arbitrary system thread. Rather than processing directly on this thread, the handler immediately dispatches to a dedicated background queue:
let queue = DispatchQueue(label: "com.rsync.process.termination", qos: .userInitiated)This custom queue with .userInitiated QoS priority:
- Provides a controlled, serial execution context for cleanup operations
- Keeps blocking I/O operations off the main thread
- Ensures prompt processing of termination without interfering with UI responsiveness
- Makes debugging easier with a labeled queue visible in Instruments
The termination handler follows this precise sequence to guarantee complete output capture:
-
Brief delay (50ms): Allows OS-level pipe buffers to flush any remaining data after process termination
Thread.sleep(forTimeInterval: 0.05)
-
Synchronous pipe draining: Exhaustively reads all remaining data from both stdout and stderr pipes
let outputData = Self.drainPipe(outputPipe.fileHandleForReading) let errorData = Self.drainPipe(errorPipe.fileHandleForReading)
The
drainPipemethod loops until no data remains:while true { let data = fileHandle.availableData if data.isEmpty { break } allData.append(data) }
-
Handler cleanup: Only after draining completes are the readability handlers cleared to prevent concurrent access
outputPipe.fileHandleForReading.readabilityHandler = nil errorPipe.fileHandleForReading.readabilityHandler = nil
-
MainActor transition: All captured data is then passed to
@MainActor-isolated methods for final processingTask { @MainActor in await self.processFinalOutput(...) }
-
Final processing: The accumulated output is processed, trailing partial lines are flushed, and termination callbacks fire
-
Deferred cleanup: A
deferblock ensures resources (timers, process references) are released only after all termination logic completes
- No data loss: The sleep + exhaustive draining pattern ensures even late-arriving pipe data is captured
- Thread safety: Background draining prevents blocking the main thread, then safely transitions to MainActor for state updates
- Race prevention: Clearing handlers only after draining prevents concurrent read attempts
- Predictable ordering: Serial queue execution ensures cleanup steps happen in the correct sequence
- Resource safety: Deferred cleanup guarantees proper resource release even if early returns or errors occur
executableNotFound: rsync path fails executability check.processFailed: Non-zero exit combined with collected stderr whencheckForErrorInRsyncOutputis enabled.processCancelled: User-requested cancellation path.timeout: Process terminated after exceeding configuredtimeoutinterval.- Errors propagate through
propagateErrorfor centralized handling.
- Rsync location: Override via
rsyncPathto support custom installs or sandboxed environments. - Environment: Supply
environmentto mirror user shells or inject required variables. - Version flags:
rsyncVersion3allows downstream callers to tailor behavior for legacy rsync output patterns. - File progress: Enable
useFileHandlerto receive per-line counts for UI progress or telemetry.
- Unit and integration tests live in Tests/RsyncProcessStreamingTests using Swift Testing to cover line splitting, termination callbacks, cancellation, and error detection hooks.
- Code quality practices are tracked in CODE_QUALITY.md with concurrency, error handling, and testing notes.
import RsyncProcessStreaming
let handlers = ProcessHandlers.withOutputCapture(
processTermination: { output, hiddenID in
print("Completed", hiddenID ?? -1)
print(output ?? [])
},
fileHandler: { count in print("Lines processed: \(count)") },
rsyncPath: "/usr/bin/rsync",
checkLineForError: { line in
if line.contains("rsync error:") {
throw RsyncProcessError.processFailed(exitCode: 1, errors: [line])
}
},
updateProcess: { _ in },
propagateError: { error in print("Error: \(error)") },
logger: { _, _ in },
checkForErrorInRsyncOutput: true,
rsyncVersion3: true,
environment: nil
)
let process = RsyncProcess(arguments: ["--version"], handlers: handlers, useFileHandler: false, timeout: 5)
try process.executeProcess()- Building Swift clients that need responsive, real-time rsync output (CLI or UI).
- Integrating with RsyncUI-compatible handler signatures.
- Minimizing memory footprint while still retaining full stdout/stderr history.
- Implementing cancellable, error-aware rsync workflows with Swift concurrency.
Use the provided VS Code tasks or xcrun directly:
# Build
xcrun swift build
# Run tests (verbose)
xcrun swift test -v