Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ jobs:
- name: Verify updater contract
run: swift run Pesty --verify-updater

- name: Verify release memory performance
run: |
swift build -c release
scripts/test_memory_performance.sh .build/release/Pesty

- name: Build and verify release package
run: |
VERSION=0.0.0 BUILD="${GITHUB_RUN_NUMBER}" ./scripts/release_build.sh
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,11 @@ jobs:
- name: Verify updater contract
run: swift run Pesty --verify-updater

- name: Verify release memory performance
run: |
swift build -c release
scripts/test_memory_performance.sh .build/release/Pesty

- name: Build ad-hoc signed release
env:
VERSION: ${{ steps.version.outputs.value }}
Expand Down
1 change: 0 additions & 1 deletion Sources/Pesty/AppController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -461,7 +461,6 @@ final class AppController: NSObject, NSApplicationDelegate {
barController = BarWindowController()
}
barController?.show()
store.prepareSearchIndexForPanel()
store.reconcileFromDiskInBackground()
}

Expand Down
22 changes: 22 additions & 0 deletions Sources/Pesty/Models/ClipItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,28 @@ struct ClipItem: Identifiable, Codable, Equatable, Sendable {
.lowercased()
}

var maximumSearchableUTF8Count: Int {
let components = [customTitle, text, sourceAppName, colorHex]
.compactMap { $0 }
let rawBytes = components.reduce(into: 0) { total, value in
total += value.utf8.count
} + fileURLs.reduce(into: 0) { total, value in
total += value.utf8.count
}
let (bytesWithSeparators, additionOverflow) = rawBytes
.addingReportingOverflow(max(0, fileURLs.count - 1))
let (sourceBytes, componentOverflow) = bytesWithSeparators
.addingReportingOverflow(components.count)
// Unicode lowercasing can expand a scalar. Reserving four times the
// source byte count keeps the mmap builder bounded without committing
// those untouched virtual pages to physical memory.
let (maximum, multiplicationOverflow) = sourceBytes
.multipliedReportingOverflow(by: 4)
return additionOverflow || componentOverflow || multiplicationOverflow
? Int.max
: maximum
}

func sameContent(as other: ClipItem) -> Bool {
guard type == other.type else { return false }
switch type {
Expand Down
119 changes: 100 additions & 19 deletions Sources/Pesty/Store/ClipboardSearchEngine.swift
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import Foundation
import Darwin

struct ClipboardSearchIndex: Sendable {
let source: BarSource
let contentRevision: UInt64
// Keep the original copy-on-write array instead of duplicating every
// ClipItem struct into the index. Only normalized search bytes allocate.
// The array remains a copy-on-write view of the store. Normalized bytes
// live in an mmap-backed Data so clearing search can return all index pages
// to the OS instead of leaving them in malloc's resident-page cache.
let items: [ClipItem]
let searchableBytes: [Data]
let searchableBytes: Data
let searchableRanges: [Range<Int>]
}

struct ClipboardSearchResult: Sendable {
Expand All @@ -23,18 +26,88 @@ enum ClipboardSearchEngine {
source: BarSource,
contentRevision: UInt64
) -> ClipboardSearchIndex? {
var searchableBytes: [Data] = []
searchableBytes.reserveCapacity(items.count)
for (offset, item) in items.enumerated() {
if offset.isMultiple(of: 8), Task.isCancelled { return nil }
searchableBytes.append(Data(item.searchableText.utf8))
var mappedCapacity = 0
for item in items {
let (nextCapacity, overflow) = mappedCapacity.addingReportingOverflow(
item.maximumSearchableUTF8Count
)
guard !overflow else { return nil }
mappedCapacity = nextCapacity
}
guard !Task.isCancelled else { return nil }

if mappedCapacity == 0 {
return ClipboardSearchIndex(
source: source,
contentRevision: contentRevision,
items: items,
searchableBytes: Data(),
searchableRanges: Array(repeating: 0..<0, count: items.count)
)
}

guard let mappedBytes = mmap(
nil,
mappedCapacity,
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANON,
-1,
0
), mappedBytes != MAP_FAILED else {
return nil
Comment on lines +56 to +57

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reset failed index builds before retrying search

When a large or unlimited history causes this new mmap allocation to return MAP_FAILED, the build task resolves to nil, but scheduleSearchIndexBuild clears searchIndexTask only after a successful build. Consequently, ensureSearchIndex() keeps awaiting the same completed nil-valued task for every subsequent non-empty query, leaving the full unfiltered history visible until the user clears search long enough for eviction or the history changes. Clear the failed task or provide a fallback so later queries can retry.

Useful? React with 👍 / 👎.

}

var ranges: [Range<Int>] = []
ranges.reserveCapacity(items.count)
var offset = 0
for (itemOffset, item) in items.enumerated() {
if itemOffset.isMultiple(of: 8), Task.isCancelled {
munmap(mappedBytes, mappedCapacity)
return nil
}
let searchableText = item.searchableText
let byteCount = searchableText.utf8.count
guard byteCount <= mappedCapacity - offset else {
munmap(mappedBytes, mappedCapacity)
return nil
}
let destination = mappedBytes.advanced(by: offset)
let copiedContiguously = searchableText.utf8
.withContiguousStorageIfAvailable { bytes -> Bool in
if let baseAddress = bytes.baseAddress, !bytes.isEmpty {
memcpy(destination, baseAddress, bytes.count)
}
return true
} ?? false
if !copiedContiguously {
var byteOffset = 0
for byte in searchableText.utf8 {
destination.storeBytes(of: byte, toByteOffset: byteOffset,
as: UInt8.self)
byteOffset += 1
}
}
ranges.append(offset..<(offset + byteCount))
offset += byteCount
}
guard !Task.isCancelled else {
munmap(mappedBytes, mappedCapacity)
return nil
}

let searchableBytes = Data(
bytesNoCopy: mappedBytes,
count: offset,
deallocator: .custom { pointer, _ in
munmap(pointer, mappedCapacity)
}
)
return ClipboardSearchIndex(
source: source,
contentRevision: contentRevision,
items: items,
searchableBytes: searchableBytes
searchableBytes: searchableBytes,
searchableRanges: ranges
)
}

Expand All @@ -52,8 +125,12 @@ enum ClipboardSearchEngine {
for (offset, candidate) in candidates.enumerated() {
if offset.isMultiple(of: 8), Task.isCancelled { return nil }
guard index.items.indices.contains(candidate),
index.searchableBytes.indices.contains(candidate) else { continue }
if contains(needle, in: index.searchableBytes[candidate]) {
index.searchableRanges.indices.contains(candidate) else { continue }
if contains(
needle,
in: index.searchableBytes,
range: index.searchableRanges[candidate]
) {
matchingIndices.append(candidate)
matchingItems.append(index.items[candidate])
}
Expand All @@ -66,19 +143,23 @@ enum ClipboardSearchEngine {
)
}

private static func contains(_ needle: Data, in haystack: Data) -> Bool {
private static func contains(
_ needle: Data,
in haystack: Data,
range: Range<Int>
) -> Bool {
guard !needle.isEmpty else { return true }
guard haystack.count >= needle.count else { return false }
guard haystack.count > cancellableChunkSize else {
return haystack.range(of: needle) != nil
guard range.count >= needle.count else { return false }
guard range.count > cancellableChunkSize else {
return haystack.range(of: needle, in: range) != nil
}

let overlap = max(0, needle.count - 1)
var start = 0
while start < haystack.count {
var start = range.lowerBound
while start < range.upperBound {
if Task.isCancelled { return false }
let upperBound = min(haystack.count, start + cancellableChunkSize)
let lowerBound = max(0, start - overlap)
let upperBound = min(range.upperBound, start + cancellableChunkSize)
let lowerBound = max(range.lowerBound, start - overlap)
if haystack.range(of: needle, in: lowerBound..<upperBound) != nil {
return true
}
Expand Down
87 changes: 67 additions & 20 deletions Sources/Pesty/Store/ClipboardStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ private enum ClipboardStoreReconciler {
var seen = Set<String>()
var merged: [ClipItem] = []
for item in combined
where seen.insert(contentKey(item)).inserted {
where seen.insert(contentDigest(item)).inserted {
merged.append(item)
}

Expand Down Expand Up @@ -373,7 +373,10 @@ final class ClipboardStore {
searchTask = nil
searchGeneration &+= 1
stripContentRevision &+= 1
scheduleSearchIndexEviction()
} else {
searchIndexEvictionTask?.cancel()
searchIndexEvictionTask = nil
scheduleSearchResultsUpdate(debounce: true)
}
}
Expand All @@ -398,7 +401,7 @@ final class ClipboardStore {
@ObservationIgnored private var searchIndexTask: Task<ClipboardSearchIndex?, Never>?
@ObservationIgnored private var searchIndexGeneration: UInt64 = 0
@ObservationIgnored private var searchIndexBuildIsDeferred = false
@ObservationIgnored private var searchIndexPrewarmingEnabled = false
@ObservationIgnored private var searchIndexEvictionTask: Task<Void, Never>?
@ObservationIgnored private var searchCandidateCache: [String: [Int]] = [:]
@ObservationIgnored private var searchCandidateCacheOrder: [String] = []
@ObservationIgnored private var searchTask: Task<Void, Never>?
Expand Down Expand Up @@ -579,6 +582,8 @@ final class ClipboardStore {
private func invalidateSearchIndexAndRefreshResults() {
searchIndexTask?.cancel()
searchIndexTask = nil
searchIndexEvictionTask?.cancel()
searchIndexEvictionTask = nil
searchIndexGeneration &+= 1
searchIndexBuildIsDeferred = false
discardSearchIndexOffMainActor()
Expand All @@ -587,30 +592,44 @@ final class ClipboardStore {
filteredSearchIndices.removeAll(keepingCapacity: true)

if normalizedSearchQuery(searchText).isEmpty {
if searchIndexPrewarmingEnabled {
scheduleSearchIndexBuild(deferred: true)
}
// A capture invalidates the complete normalized byte index. Do not
// immediately rebuild it while persistence is encoding the same
// history. The next real query builds a fresh index on demand.
} else {
scheduleSearchResultsUpdate(debounce: false)
}
}

private func discardSearchIndexOffMainActor() {
let discardedIndex = searchIndex
searchIndex = nil
guard let discardedIndex else { return }
Task.detached(priority: .utility) {
withExtendedLifetime(discardedIndex) {}
}
}

func prepareSearchIndexForPanel() {
searchIndexPrewarmingEnabled = true
let expectedRevision = searchContentRevision(for: source)
guard searchIndex?.source != source
|| searchIndex?.contentRevision != expectedRevision else { return }
guard searchIndexTask == nil else { return }
scheduleSearchIndexBuild(deferred: true)
private func scheduleSearchIndexEviction() {
searchIndexEvictionTask?.cancel()
guard searchIndex != nil || searchIndexTask != nil else {
searchIndexEvictionTask = nil
return
}
searchIndexEvictionTask = Task { [weak self] in
do {
try await Task.sleep(for: .seconds(3))
} catch {
return
}
guard let self,
self.normalizedSearchQuery(self.searchText).isEmpty else {
return
}
self.searchIndexTask?.cancel()
self.searchIndexTask = nil
self.searchIndexGeneration &+= 1
self.discardSearchIndexOffMainActor()
self.searchCandidateCache.removeAll(keepingCapacity: false)
self.searchCandidateCacheOrder.removeAll(keepingCapacity: false)
self.filteredSearchItems.removeAll(keepingCapacity: false)
self.filteredSearchIndices.removeAll(keepingCapacity: false)
self.searchIndexEvictionTask = nil
}
}

private func scheduleSearchResultsUpdate(debounce: Bool) {
Expand Down Expand Up @@ -810,6 +829,13 @@ final class ClipboardStore {
}
}

func waitForSearchIndexForAutomatedTest() async {
guard ClipboardStore.automatedTestBase != nil else { return }
while let task = searchIndexTask {
_ = await task.value
}
}

func searchDiagnosticsForAutomatedTest() -> (
indexCount: Int,
buildCount: Int,
Expand Down Expand Up @@ -1054,6 +1080,21 @@ final class ClipboardStore {
saveNow()
}

func replaceHistoryForAutomatedMemoryTest(_ items: [ClipItem]) {
guard ClipboardStore.automatedTestBase != nil,
ProcessInfo.processInfo.environment["PESTY_AUTOMATED_UI_TEST"]
== "memory-seed" else {
return
}
history = items
pinboards = []
deletionTombstones = [:]
source = .history
searchText = ""
selectedID = items.first?.id
saveNow()
}

func replaceHistoryForAutomatedSettingsCountTest(_ items: [ClipItem]) {
guard ClipboardStore.automatedTestBase != nil,
ProcessInfo.processInfo.environment["PESTY_AUTOMATED_UI_TEST"]
Expand Down Expand Up @@ -1352,9 +1393,7 @@ final class ClipboardStore {
let needsMaterialization = loadsFromMetadataCache
&& storeNeedsMaterializationBeforeReading
let initialSnapshot = usesIncrementalCloudSync
? IncrementalCloudSync.localSnapshot(
in: incrementalLocalDirectory
)
? incrementalCloudSync?.initialSnapshot
: readSnapshot(
at: loadsFromMetadataCache ? metadataCacheURL : storeURL
)
Expand Down Expand Up @@ -2003,6 +2042,14 @@ final class ClipboardStore {
}
}

func requestIncrementalCompactionForAutomatedMemoryTest() {
guard ClipboardStore.automatedTestBase != nil,
ProcessInfo.processInfo.environment["PESTY_AUTOMATED_UI_TEST"]
== "memory-measure" else { return }
incrementalCompactionRequested = true
scheduleIncrementalSync()
}

private var mergeContext: ClipboardMergeContext {
ClipboardMergeContext(
history: history,
Expand Down
Loading
Loading