Skip to content

Commit 0059a0d

Browse files
committed
Executor Docs, Add Test Timeouts, Fix Flaky Test
1 parent e94c568 commit 0059a0d

File tree

2 files changed

+51
-9
lines changed

2 files changed

+51
-9
lines changed

Sources/CodeEditSourceEditor/TreeSitter/TreeSitterExecutor.swift

+47-5
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,35 @@
77

88
import Foundation
99

10-
/// A class for managing
10+
/// This class provides a thread-safe API for executing `tree-sitter` operations synchronously or asynchronously.
11+
///
12+
/// `tree-sitter` can take a potentially long time to parse a document. Long enough that we may decide to free up the
13+
/// main thread and do syntax highlighting when the parse is complete. To accomplish this, the ``TreeSitterClient``
14+
/// uses a ``TreeSitterExecutor`` to perform both sync and async operations.
15+
///
16+
/// Sync operations occur when the ``TreeSitterClient`` _both_ a) estimates that a query or parse will not take too
17+
/// long on the main thread to gum up interactions, and b) there are no async operations already in progress. If either
18+
/// condition is false, the operation must be performed asynchronously or is cancelled. Finally, async operations may
19+
/// need to be cancelled, and should cancel quickly based on the level of access required for the operation
20+
/// (see ``TreeSitterExecutor/Priority``).
21+
///
22+
/// The ``TreeSitterExecutor`` facilitates this requirement by providing a simple API that ``TreeSitterClient`` can use
23+
/// to attempt sync operations, queue async operations, and cancel async operations. It does this by managing a queue
24+
/// of tasks to execute in order. Each task is given a priority when queued and all queue operations are made thread
25+
/// safe using a lock.
26+
///
27+
/// To check if a sync operation can occur, the queue is checked. If empty or the lock could not be acquired, the sync
28+
/// operation is queued without a swift `Task` and executed. This forces parallel sync attempts to be made async and
29+
/// will run after the original sync operation is finished.
30+
///
31+
/// Async operations are added to the queue in a detached `Task`. Before they execute their operation callback, they
32+
/// first ensure they are next in the queue. This is done by acquiring the queue lock and checking the queue contents.
33+
/// To avoid lock contention (and essentially implementing a spinlock), the task sleeps for a few milliseconds
34+
/// (defined by ``TreeSitterClient/Constants/taskSleepDuration``) after failing to be next in the queue. Once up for
35+
/// running, the operation is executed. Finally, the lock is acquired again and the task is removed from the queue.
36+
///
1137
final package class TreeSitterExecutor {
38+
/// The priority of an operation. These are used to conditionally cancel operations. See ``TreeSitterExecutor/cancelAll(below:)``
1239
enum Priority: Comparable {
1340
case access
1441
case edit
@@ -29,13 +56,17 @@ final package class TreeSitterExecutor {
2956
case syncUnavailable
3057
}
3158

59+
/// Attempt to execute a synchronous operation. Thread safe.
60+
/// - Parameter operation: The callback to execute.
61+
/// - Returns: Returns a `.failure` with a ``TreeSitterExecutor/Error/syncUnavailable`` error if the operation
62+
/// cannot be safely performed synchronously.
3263
@discardableResult
3364
func execSync<T>(_ operation: () -> T) -> Result<T, Error> {
3465
guard let queueItemID = addSyncTask() else {
3566
return .failure(Error.syncUnavailable)
3667
}
3768
let returnVal = operation() // Execute outside critical area.
38-
// Critical area, modifying the queue.
69+
// Critical section, modifying the queue.
3970
lock.withLock {
4071
queuedTasks.removeAll(where: { $0.id == queueItemID })
4172
}
@@ -53,12 +84,17 @@ final package class TreeSitterExecutor {
5384
return id
5485
}
5586

87+
/// Execute an operation asynchronously. Thread safe.
88+
/// - Parameters:
89+
/// - priority: The priority given to the operation. Defaults to ``TreeSitterExecutor/Priority/access``.
90+
/// - operation: The operation to execute. It is up to the caller to exit _ASAP_ if the task is cancelled.
91+
/// - onCancel: A callback called if the operation was cancelled.
5692
func execAsync(priority: Priority = .access, operation: @escaping () -> Void, onCancel: @escaping () -> Void) {
57-
// Critical area, modifying the queue
93+
// Critical section, modifying the queue
5894
lock.lock()
5995
defer { lock.unlock() }
6096
let id = UUID()
61-
let task = Task(priority: .userInitiated) { // This executes outside the lock's control.
97+
let task = Task(priority: .userInitiated) { // __This executes outside the outer lock's control__
6298
while self.lock.withLock({ !canTaskExec(id: id, priority: priority) }) {
6399
// Instead of yielding, sleeping frees up the CPU due to time off the CPU and less lock contention
64100
try? await Task.sleep(for: TreeSitterClient.Constants.taskSleepDuration)
@@ -82,6 +118,7 @@ final package class TreeSitterExecutor {
82118
}
83119

84120
removeTask(id)
121+
// __Back to outer lock control__
85122
}
86123
queuedTasks.append(QueueItem(task: task, id: id, priority: priority))
87124
}
@@ -92,7 +129,7 @@ final package class TreeSitterExecutor {
92129
}
93130
}
94131

95-
/// Allow concurrent ``TreeSitterExecutor/Priority/access`` operations to run.
132+
/// Allow concurrent ``TreeSitterExecutor/Priority/access`` operations to run. Thread safe.
96133
private func canTaskExec(id: UUID, priority: Priority) -> Bool {
97134
if priority != .access {
98135
return queuedTasks.first?.id == id
@@ -109,6 +146,11 @@ final package class TreeSitterExecutor {
109146
return false
110147
}
111148

149+
/// Cancels all queued or running tasks below the given priority. Thread safe.
150+
/// - Note: Does not guarantee work stops immediately. It is up to the caller to provide callbacks that exit
151+
/// ASAP when a task is cancelled.
152+
/// - Parameter priority: The priority to cancel below. Eg: if given `reset`, will cancel all `edit` and `access`
153+
/// operations.
112154
func cancelAll(below priority: Priority) {
113155
lock.withLock {
114156
queuedTasks.forEach { item in

Tests/CodeEditSourceEditorTests/TreeSitterClientTests.swift

+4-4
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ final class TreeSitterClientTests: XCTestCase {
4545
expectation.fulfill()
4646
}
4747

48-
await fulfillment(of: [expectation])
48+
await fulfillment(of: [expectation], timeout: 5.0)
4949

5050
let primaryLanguage = client.state?.primaryLayer.id
5151
let layerCount = client.state?.layers.count
@@ -100,7 +100,7 @@ final class TreeSitterClientTests: XCTestCase {
100100
}
101101
}
102102

103-
wait(for: [cancelledQuery, cancelledEdit, successEdit])
103+
wait(for: [cancelledQuery, cancelledEdit, successEdit], timeout: 5.0)
104104
}
105105

106106
@MainActor
@@ -165,7 +165,7 @@ final class TreeSitterClientTests: XCTestCase {
165165
let expectation = XCTestExpectation(description: "Edit \(index) should be cancelled.")
166166
let isDeletion = Int.random(in: 0..<10) < 4
167167
let editText = isDeletion ? "" : "\(index)"
168-
let editLocation = Int.random(in: 0...textView.string.count)
168+
let editLocation = Int.random(in: 0..<textView.string.count)
169169
let editRange = if isDeletion {
170170
NSRange(location: editLocation, length: 1)
171171
} else {
@@ -191,7 +191,7 @@ final class TreeSitterClientTests: XCTestCase {
191191
}
192192
}
193193

194-
wait(for: editExpectations + [finalEditExpectation])
194+
wait(for: editExpectations + [finalEditExpectation], timeout: 5.0)
195195
}
196196
}
197197
// swiftlint:enable all

0 commit comments

Comments
 (0)