From e2e708538a08060931cbdf1c4a7063e1a77309a4 Mon Sep 17 00:00:00 2001 From: Krzysztof Rodak Date: Wed, 28 Oct 2026 02:51:37 +1000 Subject: [PATCH] JavaScriptEventLoop: Append a job after the queue tail without a scan --- Sources/JavaScriptEventLoop/JobQueue.swift | 39 +++++++--- .../JavaScriptEventLoopTests.swift | 71 +++++++++++++++++++ 2 files changed, 100 insertions(+), 10 deletions(-) diff --git a/Sources/JavaScriptEventLoop/JobQueue.swift b/Sources/JavaScriptEventLoop/JobQueue.swift index 0dcbcdc17..f26f845da 100644 --- a/Sources/JavaScriptEventLoop/JobQueue.swift +++ b/Sources/JavaScriptEventLoop/JobQueue.swift @@ -10,25 +10,41 @@ import _CJavaScriptEventLoop @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) struct QueueState: Sendable { fileprivate var headJob: UnownedJob? = nil + /// The last job in the queue, which has the lowest priority. `nil` when the queue is empty. + fileprivate var tailJob: UnownedJob? = nil fileprivate var isSpinning: Bool = false } @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) extension JavaScriptEventLoop { + /// Inserts `newJob` after every queued job of the same or a higher priority. + /// + /// The queue is ordered by descending priority, so a job whose priority is not higher than the + /// tail's goes right after the tail, without a scan from the head. func insertJobQueue(job newJob: UnownedJob) { - withUnsafeMutablePointer(to: &queueState.headJob) { headJobPtr in - var position: UnsafeMutablePointer = headJobPtr - while let cur = position.pointee { - if cur.rawPriority < newJob.rawPriority { - newJob.nextInQueue().pointee = cur - position.pointee = newJob - return + if let tailJob = queueState.tailJob, newJob.rawPriority <= tailJob.rawPriority { + newJob.nextInQueue().pointee = nil + tailJob.nextInQueue().pointee = newJob + queueState.tailJob = newJob + } else { + let isLast = withUnsafeMutablePointer(to: &queueState.headJob) { headJobPtr -> Bool in + var position: UnsafeMutablePointer = headJobPtr + while let cur = position.pointee { + if cur.rawPriority < newJob.rawPriority { + newJob.nextInQueue().pointee = cur + position.pointee = newJob + return false + } + position = cur.nextInQueue() } - position = cur.nextInQueue() + newJob.nextInQueue().pointee = nil + position.pointee = newJob + return true + } + if isLast { + queueState.tailJob = newJob } - newJob.nextInQueue().pointee = nil - position.pointee = newJob } // TODO: use CAS when supporting multi-threaded environment @@ -82,6 +98,9 @@ extension JavaScriptEventLoop { func claimNextFromQueue() -> UnownedJob? { if let job = self.queueState.headJob { self.queueState.headJob = job.nextInQueue().pointee + if self.queueState.headJob == nil { + self.queueState.tailJob = nil + } return job } return nil diff --git a/Tests/JavaScriptEventLoopTests/JavaScriptEventLoopTests.swift b/Tests/JavaScriptEventLoopTests/JavaScriptEventLoopTests.swift index 8fbbd817f..e433ad1ca 100644 --- a/Tests/JavaScriptEventLoopTests/JavaScriptEventLoopTests.swift +++ b/Tests/JavaScriptEventLoopTests/JavaScriptEventLoopTests.swift @@ -113,6 +113,77 @@ final class JavaScriptEventLoopTests: XCTestCase { XCTAssertEqual(context.completed, ["t4", "t3", "t5"]) } + func testTaskPriorityOrderWithMixedPriorities() async throws { + final class Context: @unchecked Sendable { + var completed: [String] = [] + var expectedCount = 0 + var continuation: CheckedContinuation? + + func record(_ name: String) { + completed.append(name) + if completed.count == expectedCount { + continuation?.resume() + continuation = nil + } + } + } + /// Starts tasks without awaiting them, so no await raises the priority of a queued task. + func run(expectedCount: Int, _ start: @escaping (Context) -> Void) async -> [String] { + let context = Context() + context.expectedCount = expectedCount + await withCheckedContinuation { continuation in + context.continuation = continuation + start(context) + } + return context.completed + } + + let burst: [(String, TaskPriority)] = [ + ("low1", .low), ("medium1", .medium), ("low2", .low), ("high1", .high), + ("medium2", .medium), ("low3", .low), ("high2", .high), ("medium3", .medium), + ] + let burstOrder = await run(expectedCount: burst.count) { context in + for (name, priority) in burst { + Task(priority: priority) { context.record(name) } + } + } + XCTAssertEqual(burstOrder, ["high1", "high2", "medium1", "medium2", "medium3", "low1", "low2", "low3"]) + + let nestedOrder = await run(expectedCount: 7) { context in + Task(priority: .medium) { + context.record("outer") + let nested: [(String, TaskPriority)] = [ + ("nestedLow1", .low), ("nestedHigh", .high), ("nestedMedium", .medium), ("nestedLow2", .low), + ] + for (name, priority) in nested { + Task(priority: priority) { context.record(name) } + } + } + Task(priority: .medium) { context.record("medium") } + Task(priority: .low) { context.record("low") } + } + XCTAssertEqual( + nestedOrder, + ["outer", "nestedHigh", "medium", "nestedMedium", "low", "nestedLow1", "nestedLow2"] + ) + + let priorities: [TaskPriority] = [.low, .high, .background, .medium, .utility, .userInitiated] + let many = (0..<600).map { index in ("\(index)", priorities[index % priorities.count]) } + let manyOrder = await run(expectedCount: many.count) { context in + for (name, priority) in many { + Task(priority: priority) { context.record(name) } + } + } + let expectedManyOrder = many.enumerated() + .sorted { lhs, rhs in + lhs.element.1.rawValue != rhs.element.1.rawValue + ? lhs.element.1.rawValue > rhs.element.1.rawValue + : lhs.offset < rhs.offset + } + .map(\.element.0) + XCTAssertEqual(manyOrder, expectedManyOrder) + } + // MARK: - Promise Tests func testPromiseResolution() async throws {