The "Downloading from iCloud" stall (sits at ~50-60% then suddenly jumps, sometimes on a fully-downloaded tree) plus a "Canceling" that itself looked stalled, and a request for examinable logs. Stall UI is now far less sensitive and renamed: - "Not progressing" → "Stalled". - Stall threshold 12s → 45s, and the opaque phases now emit a per-tick liveness heartbeat (refresh updatedAt without moving the bar), so a slow-but-working move no longer flickers to the alarm. "Stalled" trips only on genuine silence. - Cancel shows a calm "Canceling…" (MoveProgress.canceling, sticky across late heartbeats) instead of the stall alarm, and the Cancel button disables. iCloud prep hardening (the actual wedge): - downloadPlaceholders gives up on holdouts that make no *net* progress so the loop can't spin to its ceiling: a stale/orphan stub whose real file is missing bails (fast when iCloud reports it errored, after a grace otherwise) and the move proceeds. Progress is now monotonic and never negative even if the iCloud backlog grows/oscillates mid-run. Decision factored into a pure, unit-tested `downloadStep` (low-water-mark bail + monotonic clamp). - resolveConflicts is now async, checks Task.isCancelled every file (Cancel is responsive instead of wedged on a big tree) and heartbeats during the scan. Smart Move logging: - New MoveLog (thread-safe, append-only, gated by a UserDefaults toggle) writes a detailed per-move/convert trace to ~/Library/Application Support/Nucleic/ SmartMove.log (rotates at 2 MB). Threaded through the whole move + convert pipeline (phases, iCloud backlog, copy heartbeat, cancel, errors). - Settings → General → Diagnostics: "Smart Move logging" toggle + Reveal Log in Finder. Tests: MoveLog (enabled/disabled/rotation/close), MoveProgress.canceling, classifyPending, downloadStep invariants (monotonic, oscillation/all-errored bail, no false bail on steady progress), and relocator↔log wiring. Co-Authored-By: Claude Opus 4.8 <[email protected]>
117 lines
5.7 KiB
Swift
117 lines
5.7 KiB
Swift
import Foundation
|
|
|
|
/// User-facing diagnostics switches, persisted in `UserDefaults`. Currently just the
|
|
/// Smart Move log toggle (Settings → General → Diagnostics). Lives in `NucleicCore` so the
|
|
/// move pipeline (`AppStore`, `ProjectRelocator`) and the SwiftUI layer agree on the key.
|
|
public enum MoveDiagnostics {
|
|
/// When on, the safe-move / Convert-to-Control pipeline writes a detailed trace to
|
|
/// `logFileURL` so a slow or stuck move can be examined after the fact. Off by default —
|
|
/// it's a diagnostic aid, not something to pay for on every move. See `MoveLog`.
|
|
public static let loggingEnabledKey = "nucleic.move.loggingEnabled"
|
|
|
|
/// Whether Smart Move logging is enabled app-wide.
|
|
public static var loggingEnabled: Bool {
|
|
UserDefaults.standard.bool(forKey: loggingEnabledKey)
|
|
}
|
|
|
|
/// `~/Library/Application Support/Nucleic/` — the same base the app store persists under
|
|
/// (see `NucleicApp.supportDirectory`), recomputed here so `NucleicCore` needn't be told.
|
|
public static var supportDirectory: URL {
|
|
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
|
return base.appendingPathComponent("Nucleic", isDirectory: true)
|
|
}
|
|
|
|
/// The rolling Smart Move log file, created lazily by `MoveLog` when logging is on.
|
|
public static var logFileURL: URL {
|
|
supportDirectory.appendingPathComponent("SmartMove.log")
|
|
}
|
|
|
|
/// The previous log, kept across one rotation so a just-finished move isn't lost when the
|
|
/// next one rolls a large file over.
|
|
public static var previousLogFileURL: URL {
|
|
supportDirectory.appendingPathComponent("SmartMove.previous.log")
|
|
}
|
|
}
|
|
|
|
/// A thread-safe, append-only text logger for a single safe-move / Convert-to-Control run,
|
|
/// gated by `MoveDiagnostics.loggingEnabled`. When logging is off every method is a cheap
|
|
/// no-op, so the move pipeline can call it unconditionally.
|
|
///
|
|
/// Writes are serialized behind a lock because the pipeline logs from the main actor, a
|
|
/// background liveness `Task`, and detached copy / directory-size walks all at once. One shared
|
|
/// rolling file (`MoveDiagnostics.logFileURL`); each run brackets its lines with a header /
|
|
/// footer and tags every line with its short `label`, so interleaved moves of different projects
|
|
/// stay legible and finished runs remain on disk for comparison.
|
|
public final class MoveLog: @unchecked Sendable {
|
|
private let label: String
|
|
private let start: Date
|
|
private let lock = NSLock()
|
|
/// nil when logging is disabled or after `finish()`; guarded by `lock`.
|
|
private var handle: FileHandle?
|
|
|
|
/// The on-disk log file, or nil when logging is disabled / the file couldn't be opened.
|
|
public let fileURL: URL?
|
|
|
|
/// Whether this logger is actually writing (logging on and the file opened).
|
|
public var isEnabled: Bool { fileURL != nil }
|
|
|
|
/// Open (creating / rotating as needed) the shared move log and write a run header. Reads the
|
|
/// global `MoveDiagnostics.loggingEnabled` toggle once — a move is short-lived, so a mid-move
|
|
/// toggle change needn't take effect. `label` is a short human tag (e.g. the project name).
|
|
public convenience init(label: String) {
|
|
self.init(label: label,
|
|
directory: MoveDiagnostics.supportDirectory,
|
|
enabled: MoveDiagnostics.loggingEnabled)
|
|
}
|
|
|
|
/// Designated initializer with an explicit directory + enabled flag, so tests can write to a
|
|
/// temp dir without touching real Application Support or the global toggle.
|
|
init(label: String, directory: URL, enabled: Bool) {
|
|
self.label = label
|
|
self.start = Date()
|
|
guard enabled else { handle = nil; fileURL = nil; return }
|
|
|
|
let fm = FileManager.default
|
|
let url = directory.appendingPathComponent("SmartMove.log")
|
|
try? fm.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
// Rotate a large log so the file stays openable in a text editor.
|
|
if let size = (try? fm.attributesOfItem(atPath: url.path)[.size]) as? Int, size > 2_000_000 {
|
|
let rolled = directory.appendingPathComponent("SmartMove.previous.log")
|
|
try? fm.removeItem(at: rolled)
|
|
try? fm.moveItem(at: url, to: rolled)
|
|
}
|
|
if !fm.fileExists(atPath: url.path) { fm.createFile(atPath: url.path, contents: nil) }
|
|
let opened = try? FileHandle(forWritingTo: url)
|
|
_ = try? opened?.seekToEnd()
|
|
self.handle = opened
|
|
self.fileURL = opened == nil ? nil : url
|
|
line("════════ move “\(label)” started ════════")
|
|
}
|
|
|
|
/// Append one timestamped, run-tagged line. Thread-safe; a no-op when logging is off.
|
|
public func line(_ message: String) {
|
|
let stamp = Self.stampFormatter.string(from: Date())
|
|
let elapsed = String(format: "%7.1fs", Date().timeIntervalSince(start))
|
|
let text = "[\(stamp)] [+\(elapsed)] [\(label)] \(message)\n"
|
|
guard let data = text.data(using: .utf8) else { return }
|
|
lock.lock(); defer { lock.unlock() }
|
|
guard let handle else { return }
|
|
try? handle.write(contentsOf: data)
|
|
}
|
|
|
|
/// Write a footer and close the file. Safe to call once; later `line` calls become no-ops
|
|
/// (the handle is dropped under the lock, so a late async callback can't write to a closed fd).
|
|
public func finish(_ summary: String) {
|
|
line("──────── move “\(label)” finished: \(summary) ────────")
|
|
lock.lock(); defer { lock.unlock() }
|
|
try? handle?.close()
|
|
handle = nil
|
|
}
|
|
|
|
private static let stampFormatter: DateFormatter = {
|
|
let f = DateFormatter()
|
|
f.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS"
|
|
return f
|
|
}()
|
|
}
|