Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
## 2024-05-14 - Unblock MainActor by offloading blocking I/O calls
**Learning:** In Swift, synchronous blocking operations like `Foundation.Process.waitUntilExit()`, `readDataToEndOfFile()`, and `DiskInfo.current()` execute on the same thread they are called from. If called directly from an `@MainActor` method or actor, they freeze the UI thread until completion, preventing user interaction and other UI updates.
**Action:** When working in `@MainActor` contexts, always identify blocking I/O or shell operations and explicitly offload them to background concurrency pool threads using `await Task.detached { ... }.value` to maintain a responsive UI.
83 changes: 45 additions & 38 deletions Sources/Cacheout/ViewModels/CacheoutViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,8 @@ class CacheoutViewModel: ObservableObject {
func scan() async {
isScanning = true
isNodeModulesScanning = true
diskInfo = DiskInfo.current()
// ⚑ Bolt: Offload blocking I/O (filesystem stats) from the @MainActor to keep UI responsive
diskInfo = await Task.detached { DiskInfo.current() }.value

// Scan caches and node_modules in parallel
async let cacheResults = scanner.scanAll(CacheCategory.allCategories)
Expand Down Expand Up @@ -229,48 +230,54 @@ class CacheoutViewModel: ObservableObject {
isDockerPruning = true
defer { isDockerPruning = false }

let process = Process()
let pipe = Pipe()
process.executableURL = URL(fileURLWithPath: "/bin/bash")
process.arguments = ["-c", "docker system prune -f 2>&1"]
process.standardOutput = pipe
process.standardError = pipe
process.environment = [
"PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin",
"HOME": FileManager.default.homeDirectoryForCurrentUser.path
]

do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8) ?? ""

if process.terminationStatus == 0 {
// Extract "Total reclaimed space:" line
if let line = output.components(separatedBy: "\n")
.first(where: { $0.contains("reclaimed") }) {
lastDockerPruneResult = line.trimmingCharacters(in: .whitespaces)
// ⚑ Bolt: Offload blocking Process execution and Pipe reading from the @MainActor
// to prevent the UI from freezing while Docker prunes.
// Initialize Process inside the detached task to ensure Sendable safety.
lastDockerPruneResult = await Task.detached {
let process = Process()
let pipe = Pipe()
process.executableURL = URL(fileURLWithPath: "/bin/bash")
process.arguments = ["-c", "docker system prune -f 2>&1"]
process.standardOutput = pipe
process.standardError = pipe
process.environment = [
"PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin",
"HOME": FileManager.default.homeDirectoryForCurrentUser.path
]

do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8) ?? ""

if process.terminationStatus == 0 {
// Extract "Total reclaimed space:" line
if let line = output.components(separatedBy: "\n")
.first(where: { $0.contains("reclaimed") }) {
return line.trimmingCharacters(in: .whitespaces)
} else {
return "Docker pruned successfully"
}
} else {
lastDockerPruneResult = "Docker pruned successfully"
}
} else {
let lowerOutput = output.lowercased()
if lowerOutput.contains("cannot connect") ||
lowerOutput.contains("is the docker daemon running") ||
lowerOutput.contains("connection refused") ||
lowerOutput.contains("no such file or directory") {
lastDockerPruneResult = "Docker must be running to prune"
} else {
lastDockerPruneResult = "Docker prune failed β€” is Docker running?"
let lowerOutput = output.lowercased()
if lowerOutput.contains("cannot connect") ||
lowerOutput.contains("is the docker daemon running") ||
lowerOutput.contains("connection refused") ||
lowerOutput.contains("no such file or directory") {
return "Docker must be running to prune"
} else {
return "Docker prune failed β€” is Docker running?"
}
}
} catch {
return "Docker not found"
}
} catch {
lastDockerPruneResult = "Docker not found"
}
}.value

// Refresh disk info after prune
diskInfo = DiskInfo.current()
// ⚑ Bolt: Offload blocking I/O from the @MainActor
diskInfo = await Task.detached { DiskInfo.current() }.value
}

func clean() async {
Expand Down