2 Commits
6 changed files with 144 additions and 211 deletions
+74 -46
View File
@@ -55,22 +55,33 @@ enum AllofitService {
// the autosave thread (every 3s) can write partial progress while we // the autosave thread (every 3s) can write partial progress while we
// continue walking. Without this, large filesystems leave the cache // continue walking. Without this, large filesystems leave the cache
// empty for many minutes and the GUI shows nothing. // empty for many minutes and the GUI shows nothing.
//
// Both the per-root and per-batch callback bodies run inside their
// own autoreleasepool so the autoreleased NSURL / NSDate / NSNumber
// from the file enumeration don't pile up until the entire scan
// finishes - on a million-file scan that "pile" was peaking at
// well over a gig of dead allocations before the main thread's
// runloop got a chance to drain.
let vStartId = UInt64(FSEventsGetCurrentEventId()) let vStartId = UInt64(FSEventsGetCurrentEventId())
for vRoot in vRoots { for vRoot in vRoots {
NSLog("[Allofit] scanning %@", vRoot.path) autoreleasepool {
FileIndexer.walkRoot(inRoot: vRoot, inExclusions: vMatcher) { vBatch in NSLog("[Allofit] scanning %@", vRoot.path)
vState.lock.lock() FileIndexer.walkRoot(inRoot: vRoot, inExclusions: vMatcher) { vBatch in
for vRec in vBatch { autoreleasepool {
if vMatcher.isExcluded(inPath: vRec.fullPath) { continue } vState.lock.lock()
if vState.pathIndex[vRec.fullPath] == nil { for vRec in vBatch {
vState.pathIndex[vRec.fullPath] = vState.records.count if vMatcher.isExcluded(inPath: vRec.fullPath) { continue }
vState.records.append(vRec) if vState.pathIndex[vRec.fullPath] == nil {
vState.pathIndex[vRec.fullPath] = vState.records.count
vState.records.append(vRec)
}
}
vState.dirty = true
vState.lock.unlock()
} }
} }
vState.dirty = true NSLog("[Allofit] scanned %@: %d total entries so far", vRoot.path, vState.records.count)
vState.lock.unlock()
} }
NSLog("[Allofit] scanned %@: %d total entries so far", vRoot.path, vState.records.count)
} }
// force one save right after the scan finishes, so the GUI sees a // force one save right after the scan finishes, so the GUI sees a
// stable count even if no FSEvents come in for a while afterwards // stable count even if no FSEvents come in for a while afterwards
@@ -98,27 +109,35 @@ enum AllofitService {
// dominating CPU and turning into the daemon's memory churn // dominating CPU and turning into the daemon's memory churn
var vRemoved: Set<String> = [] var vRemoved: Set<String> = []
// Per-change autoreleasepool: a large FSEvents batch (e.g.
// `rm -rf` of a deep tree) can deliver thousands of paths in
// one callback. Each path's NSURL + reachability check + the
// makeRecord internals autorelease - per-change drain keeps
// peak memory bounded by a single record's worth, not the
// whole batch.
for vChange in vChanges { for vChange in vChanges {
if vMatcher.isExcluded(inPath: vChange.path) { continue } autoreleasepool {
if vChange.mustScanSubDirs { if vMatcher.isExcluded(inPath: vChange.path) { return }
vRescanPrefixes.append(vChange.path) if vChange.mustScanSubDirs {
continue vRescanPrefixes.append(vChange.path)
} return
// skip paths we've already queued for removal in this batch }
if vRemoved.contains(vChange.path) { continue } // skip paths we've already queued for removal in this batch
let vUrl = URL(fileURLWithPath: vChange.path) if vRemoved.contains(vChange.path) { return }
let vExists = (try? vUrl.checkResourceIsReachable()) ?? false let vUrl = URL(fileURLWithPath: vChange.path)
if vExists, let vRec = FileIndexer.makeRecord(inURL: vUrl) { let vExists = (try? vUrl.checkResourceIsReachable()) ?? false
if let vIdx = vState.pathIndex[vRec.fullPath] { if vExists, let vRec = FileIndexer.makeRecord(inURL: vUrl) {
vState.records[vIdx] = vRec if let vIdx = vState.pathIndex[vRec.fullPath] {
vUpdated += 1 vState.records[vIdx] = vRec
} else { vUpdated += 1
vState.pathIndex[vRec.fullPath] = vState.records.count } else {
vState.records.append(vRec) vState.pathIndex[vRec.fullPath] = vState.records.count
vAdded += 1 vState.records.append(vRec)
vAdded += 1
}
} else if vState.pathIndex[vChange.path] != nil {
vRemoved.insert(vChange.path)
} }
} else if vState.pathIndex[vChange.path] != nil {
vRemoved.insert(vChange.path)
} }
} }
@@ -162,27 +181,36 @@ enum AllofitService {
// Array/Dict capacity (which only grows on churn, never auto-shrinks) // Array/Dict capacity (which only grows on churn, never auto-shrinks)
// doesn't drift into multi-GB territory after a day of heavy file // doesn't drift into multi-GB territory after a day of heavy file
// activity. RSS is logged each save so the trajectory is visible. // activity. RSS is logged each save so the trajectory is visible.
//
// CRITICAL: each iteration runs inside its own autoreleasepool. The
// outer GCD block has an autorelease pool that drains when the block
// returns - which for our `while true` never happens. Without an
// inner pool, every save's autoreleased NSData (returned by
// .compressed(using: .lz4) and friends) accumulates forever and the
// daemon's RSS grows by tens of MB per save (500MB+/min in practice).
DispatchQueue.global(qos: .utility).async { DispatchQueue.global(qos: .utility).async {
let kCompactEvery = 20 let kCompactEvery = 20
var vSavesSinceCompact = 0 var vSavesSinceCompact = 0
while true { while true {
sleep(3) sleep(3)
vState.lock.lock() autoreleasepool {
let vShouldSave = vState.dirty vState.lock.lock()
let vSnapshot = vState.records let vShouldSave = vState.dirty
vState.dirty = false let vSnapshot = vState.records
vState.lock.unlock() vState.dirty = false
if !vShouldSave { continue } vState.lock.unlock()
NSLog("[Allofit] autosaving %d records (RSS %.1f MB)", if !vShouldSave { return }
vSnapshot.count, processFootprintMB()) NSLog("[Allofit] autosaving %d records (RSS %.1f MB)",
IndexStore.save( vSnapshot.count, processFootprintMB())
inRecords: vSnapshot, IndexStore.save(
inLastEventId: vWatcher.latestEventId inRecords: vSnapshot,
) inLastEventId: vWatcher.latestEventId
vSavesSinceCompact += 1 )
if vSavesSinceCompact >= kCompactEvery { vSavesSinceCompact += 1
vSavesSinceCompact = 0 if vSavesSinceCompact >= kCompactEvery {
compactContainers(vState) vSavesSinceCompact = 0
compactContainers(vState)
}
} }
} }
} }
+20 -10
View File
@@ -241,13 +241,18 @@ final class AppModel: ObservableObject {
var vAccumulated: [FileRecord] = [] var vAccumulated: [FileRecord] = []
vAccumulated.reserveCapacity(200_000) vAccumulated.reserveCapacity(200_000)
for vRoot in vRoots { for vRoot in vRoots {
let vBaseline = vAccumulated.count // per-root autoreleasepool so the file-enumeration's
let vList = FileIndexer.indexRoot(inRoot: vRoot, inExclusions: vMatcher) { vCount in // autoreleased NSURL/NSDate/NSNumber objects don't pile
DispatchQueue.main.async { // up across roots inside this long-running async block
self?.indexedCount = vBaseline + vCount autoreleasepool {
let vBaseline = vAccumulated.count
let vList = FileIndexer.indexRoot(inRoot: vRoot, inExclusions: vMatcher) { vCount in
DispatchQueue.main.async {
self?.indexedCount = vBaseline + vCount
}
} }
vAccumulated.append(contentsOf: vList)
} }
vAccumulated.append(contentsOf: vList)
} }
let vFinal = vAccumulated let vFinal = vAccumulated
let vLookup = AppModel.buildPathLookup(inRecords: vFinal) let vLookup = AppModel.buildPathLookup(inRecords: vFinal)
@@ -494,11 +499,16 @@ final class AppModel: ObservableObject {
return true return true
} }
for vPath in inPaths { for vPath in inPaths {
let vList = FileIndexer.indexRoot( // per-subtree autoreleasepool keeps the rescan's
inRoot: URL(fileURLWithPath: vPath), // autoreleased URL/stat objects from accumulating across
inExclusions: inExclusions // subtrees inside this long-running async block
) autoreleasepool {
vKept.append(contentsOf: vList) let vList = FileIndexer.indexRoot(
inRoot: URL(fileURLWithPath: vPath),
inExclusions: inExclusions
)
vKept.append(contentsOf: vList)
}
} }
let vFinal = vKept let vFinal = vKept
let vLookup = AppModel.buildPathLookup(inRecords: vFinal) let vLookup = AppModel.buildPathLookup(inRecords: vFinal)
+30 -13
View File
@@ -49,21 +49,29 @@ enum FileIndexer {
vBatch.append(vRootRecord) vBatch.append(vRootRecord)
} }
// Per-iteration autoreleasepool: every URL pulled from the
// enumerator + every resourceValues() read autoreleases an
// NSURL / NSDate / NSNumber. Without this drain a million-file
// walk would let those accumulate into hundreds of MB of dead
// allocations until the enclosing async block exited - and the
// daemon's enclosing block never exits.
for vCase in vEnumerator { for vCase in vEnumerator {
guard let vURL = vCase as? URL else { continue } autoreleasepool {
guard let vURL = vCase as? URL else { return }
// short-circuit excluded entries (and don't descend into them) // short-circuit excluded entries (and don't descend into them)
if let vMatcher = inExclusions, vMatcher.isExcluded(inPath: vURL.path) { if let vMatcher = inExclusions, vMatcher.isExcluded(inPath: vURL.path) {
let vIsDir = (try? vURL.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false let vIsDir = (try? vURL.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
if vIsDir { vEnumerator.skipDescendants() } if vIsDir { vEnumerator.skipDescendants() }
continue return
} }
if let vRecord = makeRecord(inURL: vURL) { if let vRecord = makeRecord(inURL: vURL) {
vBatch.append(vRecord) vBatch.append(vRecord)
if vBatch.count >= inBatchSize { if vBatch.count >= inBatchSize {
inBatch(vBatch) inBatch(vBatch)
vBatch.removeAll(keepingCapacity: true) vBatch.removeAll(keepingCapacity: true)
}
} }
} }
} }
@@ -99,8 +107,17 @@ enum FileIndexer {
// builds a FileRecord from a URL's pre-fetched resource values. // builds a FileRecord from a URL's pre-fetched resource values.
// Clears the URL's resource-value cache first so we always re-stat the // Clears the URL's resource-value cache first so we always re-stat the
// file - a file modified between two FSEvents batches would otherwise // file - a file modified between two FSEvents batches would otherwise
// silently return the cached pre-edit mtime. // silently return the cached pre-edit mtime. The whole body is wrapped
// in autoreleasepool so the autoreleased NSDate / NSNumber / NSURL
// objects returned by resourceValues() are released at function exit
// rather than at the caller's pool drain.
static func makeRecord(inURL: URL) -> FileRecord? { static func makeRecord(inURL: URL) -> FileRecord? {
return autoreleasepool { () -> FileRecord? in
makeRecord_impl(inURL: inURL)
}
}
private static func makeRecord_impl(inURL: URL) -> FileRecord? {
var vUrl = inURL var vUrl = inURL
vUrl.removeAllCachedResourceValues() vUrl.removeAllCachedResourceValues()
guard let vValues = try? vUrl.resourceValues(forKeys: Set(kPrefetchKeys)) else { guard let vValues = try? vUrl.resourceValues(forKeys: Set(kPrefetchKeys)) else {
+20 -2
View File
@@ -91,8 +91,18 @@ enum IndexStore {
save(inRecords: inRecords, inLastEventId: inLastEventId, to: cacheURL) save(inRecords: inRecords, inLastEventId: inLastEventId, to: cacheURL)
} }
// writes the records and event id atomically to a specific URL // writes the records and event id atomically to a specific URL.
// Wrapped in autoreleasepool because every call autoreleases a number
// of Foundation objects (the compressed NSData, NSURLs created by
// FileManager.replaceItem, etc.) - if the caller is a long-running
// block whose own pool never drains, those would accumulate forever.
static func save(inRecords: [FileRecord], inLastEventId: UInt64, to inUrl: URL) { static func save(inRecords: [FileRecord], inLastEventId: UInt64, to inUrl: URL) {
autoreleasepool {
save_impl(inRecords: inRecords, inLastEventId: inLastEventId, to: inUrl)
}
}
private static func save_impl(inRecords: [FileRecord], inLastEventId: UInt64, to inUrl: URL) {
var vPayload = Data() var vPayload = Data()
vPayload.reserveCapacity(16 + inRecords.count * 80) vPayload.reserveCapacity(16 + inRecords.count * 80)
writeU64(into: &vPayload, value: inLastEventId) writeU64(into: &vPayload, value: inLastEventId)
@@ -151,8 +161,16 @@ enum IndexStore {
return load(from: cacheURL) return load(from: cacheURL)
} }
// reads the persisted index back from a specific URL // reads the persisted index back from a specific URL. Wrapped in
// autoreleasepool so the decompressed NSData and the per-record
// String allocations don't linger in the caller's pool.
static func load(from inUrl: URL) -> LoadResult? { static func load(from inUrl: URL) -> LoadResult? {
return autoreleasepool {
load_impl(from: inUrl)
}
}
private static func load_impl(from inUrl: URL) -> LoadResult? {
guard let vData = try? Data(contentsOf: inUrl) else { return nil } guard let vData = try? Data(contentsOf: inUrl) else { return nil }
var vOffset = 0 var vOffset = 0
guard let vMagic = readU32(from: vData, offset: &vOffset), vMagic == kMagic else { return nil } guard let vMagic = readU32(from: vData, offset: &vOffset), vMagic == kMagic else { return nil }
View File
-140
View File
@@ -1,140 +0,0 @@
#!/usr/bin/env swift
//
// Generates a 1024x1024 placeholder app icon at icons/icon.png so build-app.sh
// has something to bake into Allofit.app. Designed to be "Icon Composer ready"
// - the output is a single flat 1024x1024 PNG, which is exactly what Apple's
// Icon Composer (macOS 26+) takes as a base layer when you want to refine
// the icon with Liquid Glass effects.
//
// Run from the project root:
// swift make-placeholder-icon.swift
// ./build-app.sh # picks up icons/icon.png automatically
//
// Tweak the colors / shape constants below and re-run to iterate.
import AppKit
import CoreGraphics
import ImageIO
import Foundation
// ==================
// MARK: Tunables
// ==================
// Final image side length (px). 1024 is the largest slot the macOS iconset
// asks for so it works for every output size after sips downscales.
let kSizePixels: Int = 1024
// Apple's rounded-square corner radius is ~22.37% of side (classic) or
// ~25% (macOS 26 "Tahoe"). 22.37% is a safe middle ground.
let kCornerFactor: CGFloat = 0.2237
// Gradient stops (top-left to bottom-right): blue → teal
let kGradientStart = CGColor(srgbRed: 0.00, green: 0.48, blue: 1.00, alpha: 1)
let kGradientEnd = CGColor(srgbRed: 0.13, green: 0.71, blue: 0.92, alpha: 1)
// Magnifying glass styling (in normalised 0..1 of the canvas)
let kGlassCenterX: CGFloat = 0.42
let kGlassCenterY: CGFloat = 0.58
let kGlassRadius: CGFloat = 0.20
let kHandleLength: CGFloat = 0.22
let kStrokeWidth: CGFloat = 0.06
// Output relative path
let kOutputPath = "icons/icon.png"
// ==================
// MARK: Draw
// ==================
let vSize = CGFloat(kSizePixels)
guard let vColorSpace = CGColorSpace(name: CGColorSpace.sRGB) else {
fputs("could not create sRGB color space\n", stderr); exit(1)
}
guard let ctx = CGContext(
data: nil,
width: kSizePixels,
height: kSizePixels,
bitsPerComponent: 8,
bytesPerRow: 0,
space: vColorSpace,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else {
fputs("could not create CG context\n", stderr); exit(2)
}
// Rounded-square mask matching Apple's app icon silhouette
let vRect = CGRect(x: 0, y: 0, width: vSize, height: vSize)
let vCornerRadius = vSize * kCornerFactor
let vMaskPath = CGPath(roundedRect: vRect,
cornerWidth: vCornerRadius,
cornerHeight: vCornerRadius,
transform: nil)
// Gradient background, clipped to the rounded square
ctx.saveGState()
ctx.addPath(vMaskPath)
ctx.clip()
let vGradient = CGGradient(
colorsSpace: vColorSpace,
colors: [kGradientStart, kGradientEnd] as CFArray,
locations: [0.0, 1.0]
)!
ctx.drawLinearGradient(vGradient,
start: CGPoint(x: 0, y: vSize),
end: CGPoint(x: vSize, y: 0),
options: [])
ctx.restoreGState()
// White magnifying glass on top
ctx.setStrokeColor(CGColor(srgbRed: 1, green: 1, blue: 1, alpha: 1))
ctx.setLineWidth(vSize * kStrokeWidth)
ctx.setLineCap(.round)
let vGlassCenter = CGPoint(x: vSize * kGlassCenterX, y: vSize * kGlassCenterY)
let vGlassR = vSize * kGlassRadius
ctx.strokeEllipse(in: CGRect(
x: vGlassCenter.x - vGlassR,
y: vGlassCenter.y - vGlassR,
width: vGlassR * 2,
height: vGlassR * 2
))
// Handle: from the lower-right edge of the glass, going down-right at -45°
let vAngle: CGFloat = -.pi / 4
let vHandleStart = CGPoint(
x: vGlassCenter.x + vGlassR * cos(vAngle),
y: vGlassCenter.y + vGlassR * sin(vAngle)
)
let vHandleLen = vSize * kHandleLength
let vHandleEnd = CGPoint(
x: vHandleStart.x + vHandleLen * cos(vAngle),
y: vHandleStart.y + vHandleLen * sin(vAngle)
)
ctx.move(to: vHandleStart)
ctx.addLine(to: vHandleEnd)
ctx.strokePath()
// ==================
// MARK: Write PNG
// ==================
guard let vCgImage = ctx.makeImage() else {
fputs("could not create CGImage from context\n", stderr); exit(3)
}
try? FileManager.default.createDirectory(
atPath: (kOutputPath as NSString).deletingLastPathComponent,
withIntermediateDirectories: true
)
let vOutputURL = URL(fileURLWithPath: kOutputPath)
guard let vDest = CGImageDestinationCreateWithURL(
vOutputURL as CFURL,
"public.png" as CFString,
1,
nil
) else {
fputs("could not create image destination\n", stderr); exit(4)
}
CGImageDestinationAddImage(vDest, vCgImage, nil)
guard CGImageDestinationFinalize(vDest) else {
fputs("PNG finalize failed\n", stderr); exit(5)
}
print("Wrote \(kOutputPath) (\(kSizePixels)x\(kSizePixels))")