@@ -10,6 +10,10 @@ import Combine
1010
1111/// Executes rsync commands and provides real-time progress updates
1212class RsyncExecutor : ObservableObject {
13+
14+ /// Maximum bytes of rsync output to retain in memory per execution.
15+ /// Large jobs with --verbose --progress can generate hundreds of MB — cap prevents OOM kills.
16+ private static let maxOutputBytes = 10 * 1024 * 1024 // 10 MB
1317 @Published var progress : RsyncProgress ?
1418 @Published var isRunning = false
1519
@@ -275,31 +279,37 @@ class RsyncExecutor: ObservableObject {
275279 NSLog ( " [RsyncExecutor] Executing script: %@ " , expanded)
276280
277281 let process = Process ( )
278- let env = [
282+ process. executableURL = URL ( fileURLWithPath: expanded)
283+ process. arguments = [ ]
284+ process. environment = [
279285 " JOB_NAME " : jobName,
280286 " JOB_STATUS " : status,
281287 " FILES_TRANSFERRED " : String ( filesTransferred) ,
282288 " PATH " : " /usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin "
283289 ]
284290
285- process. executableURL = URL ( fileURLWithPath: expanded)
286- process. arguments = [ ]
287-
288- process. environment = env
289-
290291 let pipe = Pipe ( )
291292 process. standardOutput = pipe
292293 process. standardError = pipe
293- defer {
294- pipe. fileHandleForReading. closeFile ( )
295- }
296294
297- try process. run ( )
298- process. waitUntilExit ( )
299-
300- if process. terminationStatus != 0 {
301- let output = String ( data: pipe. fileHandleForReading. readDataToEndOfFile ( ) , encoding: . utf8) ?? " "
302- NSLog ( " [RsyncExecutor] Script failed with exit code %d: %@ " , process. terminationStatus, output)
295+ // Use async continuation instead of waitUntilExit() — the blocking call would
296+ // occupy a cooperative thread pool thread for the entire script duration.
297+ try await withCheckedThrowingContinuation { ( continuation: CheckedContinuation < Void , Error > ) in
298+ process. terminationHandler = { proc in
299+ let outputData = pipe. fileHandleForReading. readDataToEndOfFile ( )
300+ pipe. fileHandleForReading. closeFile ( )
301+ if proc. terminationStatus != 0 {
302+ let output = String ( data: outputData, encoding: . utf8) ?? " "
303+ NSLog ( " [RsyncExecutor] Script failed with exit code %d: %@ " , proc. terminationStatus, output)
304+ }
305+ continuation. resume ( )
306+ }
307+ do {
308+ try process. run ( )
309+ } catch {
310+ pipe. fileHandleForReading. closeFile ( )
311+ continuation. resume ( throwing: RsyncError . executionFailed ( error) )
312+ }
303313 }
304314 }
305315
@@ -388,10 +398,10 @@ class RsyncExecutor: ObservableObject {
388398 try FileManager . default. createDirectory ( atPath: expandedPath, withIntermediateDirectories: true , attributes: nil )
389399 print ( " [RsyncExecutor] ✅ Created destination directory: \( expandedPath) " )
390400
391- // For iCloud Drive, wait a moment for iCloud to recognize the new folder
392- if expandedPath . contains ( " com~apple~CloudDocs " ) {
393- Thread . sleep ( forTimeInterval : 0.5 )
394- }
401+ // Note: iCloud's daemon (bird) will pick up the new folder asynchronously.
402+ // A blocking sleep here is both incorrect and unnecessary — rsync handles
403+ // the destination directory being present at the time it runs.
404+ NSLog ( " [RsyncExecutor] ✅ Created iCloud destination directory: %@ " , expandedPath )
395405 } catch {
396406 print ( " [RsyncExecutor] ❌ Failed to create directory: \( error. localizedDescription) " )
397407 print ( " [RsyncExecutor] Path: \( expandedPath) " )
@@ -465,6 +475,14 @@ class RsyncExecutor: ObservableObject {
465475 args. append ( expandedSource)
466476 }
467477
478+ // iCloud: exclude .icloud placeholder stubs.
479+ // Files evicted from local storage appear as ".filename.icloud" — rsync cannot read them
480+ // and will either error out or transfer the stub file, not the real content.
481+ if dest. type == . iCloudDrive {
482+ args. append ( " --exclude=*.icloud " )
483+ NSLog ( " [RsyncExecutor] iCloud destination: added --exclude=*.icloud to skip offloaded file placeholders " )
484+ }
485+
468486 // Local/iCloud destination
469487 var expandedDest = dest. path. replacingOccurrences ( of: " ~ " , with: FileManager . default. homeDirectoryForCurrentUser. path)
470488
@@ -506,6 +524,7 @@ class RsyncExecutor: ObservableObject {
506524 var outputData = Data ( )
507525 var errorData = Data ( )
508526 var isTerminating = false
527+ var outputTruncated = false
509528
510529 // Track active handlers to prevent race conditions
511530 let handlerGroup = DispatchGroup ( )
@@ -530,9 +549,16 @@ class RsyncExecutor: ObservableObject {
530549 // Make a defensive copy of the data for thread safety
531550 let dataCopy = Data ( data)
532551
533- // Thread-safe append
552+ // Thread-safe append with output cap to prevent OOM on large jobs
534553 dataLock. lock ( )
535- outputData. append ( dataCopy)
554+ if outputData. count < RsyncExecutor . maxOutputBytes {
555+ outputData. append ( dataCopy)
556+ } else if !outputTruncated {
557+ outputTruncated = true
558+ let notice = Data ( " \n ⚠️ Output truncated at 10 MB. Full output available in system log (Console.app). \n " . utf8)
559+ outputData. append ( notice)
560+ NSLog ( " [RsyncExecutor] Output cap reached (10 MB) — truncating display output " )
561+ }
536562 dataLock. unlock ( )
537563
538564 if let output = String ( data: dataCopy, encoding: . utf8) {
0 commit comments