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
@MainActorfunc syncFolders()asyncthrows{lethandlers=ProcessHandlers(
processTermination:{ output, _ iniflet lines = output {print("✓ Sync completed with \(lines.count) output lines")}},
fileHandler:{ count inprint("📦 Processed \(count) files...")},
rsyncPath:"/usr/bin/rsync",
checkLineForError:{ line in
// Detect rsync errors in real time
if line.contains("rsync error:") || line.contains("failed:"){throwRsyncProcessError.processFailed(exitCode:1, errors:[line])}},
updateProcess:{ process iniflet pid = process?.processIdentifier {print("🚀 Process started with PID: \(pid)")}},
propagateError:{ error inprint("❌ Error: \(error.localizedDescription)")},
checkForErrorInRsyncOutput:true,
environment:nil)letprocess=RsyncProcess(
arguments:["-av","--progress","~/source/","~/destination/"],
handlers: handlers,
useFileHandler:true,
timeout:60)try process.executeProcess()}
// Usage
Task{@MainActorintryawaitsyncFolders()}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:
letqueue=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
letoutputData=Self.drainPipe(outputPipe.fileHandleForReading)leterrorData=Self.drainPipe(errorPipe.fileHandleForReading)
The
drainPipemethod loops until no data remains:whiletrue{letdata= 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{@MainActorinawaitself.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
lethandlers=ProcessHandlers.withOutputCapture(
processTermination:{ output, hiddenID inprint("Completed", hiddenID ??-1)print(output ??[])},
fileHandler:{ count inprint("Lines processed: \(count)")},
rsyncPath:"/usr/bin/rsync",
checkLineForError:{ line inif line.contains("rsync error:"){throwRsyncProcessError.processFailed(exitCode:1, errors:[line])}},
updateProcess:{ _ in},
propagateError:{ error inprint("Error: \(error)")},
logger:{ _, _ in},
checkForErrorInRsyncOutput:true,
rsyncVersion3:true,
environment:nil)letprocess=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