From 3179efff90939d3c80d9e203177ce1f7c11770de Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 05:15:02 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=20Bolt:=20Prevent=20main=20thread=20b?= =?UTF-8?q?locking=20during=20I/O=20operations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Offloads synchronous operations (`DiskInfo.current()`, `process.waitUntilExit()`) from the `@MainActor` to background threads using `Task.detached`. This prevents UI stalls during scanning and Docker pruning. Also adds a journal entry to `.jules/bolt.md` documenting this learning. Co-authored-by: acebytes <2820910+acebytes@users.noreply.github.com> --- .jules/bolt.md | 3 +++ .../ViewModels/CacheoutViewModel.swift | 23 +++++++++++-------- 2 files changed, 16 insertions(+), 10 deletions(-) create mode 100644 .jules/bolt.md diff --git a/.jules/bolt.md b/.jules/bolt.md new file mode 100644 index 0000000..29e01b8 --- /dev/null +++ b/.jules/bolt.md @@ -0,0 +1,3 @@ +## 2024-05-28 - Main Thread Blocking in async @MainActor methods +**Learning:** In Swift Concurrency, `async` methods on a `@MainActor` class execute on the main thread. If they perform synchronous blocking operations (like `DiskInfo.current()` which involves `URLResourceValues` I/O, or `Foundation.Process.waitUntilExit()`), they will block the UI and prevent other main actor tasks from running, even though they are inside an `async` function. +**Action:** Always offload synchronous blocking operations from the main thread using `await Task.detached { ... }.value` when executing within a `@MainActor` context. diff --git a/Sources/Cacheout/ViewModels/CacheoutViewModel.swift b/Sources/Cacheout/ViewModels/CacheoutViewModel.swift index 13a9811..a8782f3 100644 --- a/Sources/Cacheout/ViewModels/CacheoutViewModel.swift +++ b/Sources/Cacheout/ViewModels/CacheoutViewModel.swift @@ -147,7 +147,7 @@ class CacheoutViewModel: ObservableObject { func scan() async { isScanning = true isNodeModulesScanning = true - diskInfo = DiskInfo.current() + diskInfo = await Task.detached { DiskInfo.current() }.value // Scan caches and node_modules in parallel async let cacheResults = scanner.scanAll(CacheCategory.allCategories) @@ -241,21 +241,24 @@ class CacheoutViewModel: ObservableObject { ] do { - try process.run() - process.waitUntilExit() - let data = pipe.fileHandleForReading.readDataToEndOfFile() - let output = String(data: data, encoding: .utf8) ?? "" - - if process.terminationStatus == 0 { + let result = try await Task.detached { () -> (Int32, String) in + try process.run() + process.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8) ?? "" + return (process.terminationStatus, output) + }.value + + if result.0 == 0 { // Extract "Total reclaimed space:" line - if let line = output.components(separatedBy: "\n") + if let line = result.1.components(separatedBy: "\n") .first(where: { $0.contains("reclaimed") }) { lastDockerPruneResult = line.trimmingCharacters(in: .whitespaces) } else { lastDockerPruneResult = "Docker pruned successfully" } } else { - let lowerOutput = output.lowercased() + let lowerOutput = result.1.lowercased() if lowerOutput.contains("cannot connect") || lowerOutput.contains("is the docker daemon running") || lowerOutput.contains("connection refused") || @@ -270,7 +273,7 @@ class CacheoutViewModel: ObservableObject { } // Refresh disk info after prune - diskInfo = DiskInfo.current() + diskInfo = await Task.detached { DiskInfo.current() }.value } func clean() async {