diff --git a/CLAUDE.md b/CLAUDE.md index 51278caffb..726e85c89d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,6 +5,3 @@ - In Swift, use it_fatalError and it_assert instead of fatalError and assert, which do not create useful crash logs. - Don't write more than one line of inline javascript, html, or css. Instead create a new file and load it using iTermBrowserTemplateLoader.swift - Don't create dependency cycles. Use delegates or closures instead. -- To run unit tests in ModernTests, use tools/run_tests.expect. It takes an argument naming the test or tests, such as `tools/run_tests.expect ModernTests/iTermScriptFunctionCallTest/testSignature` -- When renaming a file tracked by git (and almost all of them are) use `git mv` instead of `mv` -- To make a debug build run `make Development` diff --git a/Makefile b/Makefile index 33d569fd5c..380701b512 100644 --- a/Makefile +++ b/Makefile @@ -195,9 +195,6 @@ sparkle: force cd submodules/Sparkle && xcodebuild -scheme Sparkle -configuration Release 'CONFIGURATION_BUILD_DIR=$$(SRCROOT)/Build/$$(CONFIGURATION)' mv submodules/Sparkle/Build/Release/Sparkle.framework ThirdParty/Sparkle.framework -paranoid-coreparse: force - /usr/bin/sandbox-exec -f deps.sb $(MAKE) CoreParse - paranoid-swiftymarkdown: force /usr/bin/sandbox-exec -f deps.sb $(MAKE) SwiftyMarkdown diff --git a/ModernTests/FairnessScheduler/FairnessSchedulerTests.swift b/ModernTests/FairnessScheduler/FairnessSchedulerTests.swift new file mode 100644 index 0000000000..a7a540509c --- /dev/null +++ b/ModernTests/FairnessScheduler/FairnessSchedulerTests.swift @@ -0,0 +1,1281 @@ +// +// FairnessSchedulerTests.swift +// ModernTests +// +// Unit tests for FairnessScheduler - the round-robin fair scheduling coordinator. +// See testing.md Phase 1 for test specifications. +// + +import XCTest +@testable import iTerm2SharedARC + +/// Tests for FairnessScheduler session registration and unregistration. +/// (see: testing.md Section 1.1) +final class FairnessSchedulerSessionTests: XCTestCase { + + var scheduler: FairnessScheduler! + var mockExecutorA: MockFairnessSchedulerExecutor! + var mockExecutorB: MockFairnessSchedulerExecutor! + var mockExecutorC: MockFairnessSchedulerExecutor! + + override func setUp() { + super.setUp() + scheduler = FairnessScheduler() + mockExecutorA = MockFairnessSchedulerExecutor() + mockExecutorB = MockFairnessSchedulerExecutor() + mockExecutorC = MockFairnessSchedulerExecutor() + } + + override func tearDown() { + scheduler = nil + mockExecutorA = nil + mockExecutorB = nil + mockExecutorC = nil + super.tearDown() + } + + // MARK: - Registration Tests (1.1) + + func testRegisterReturnsUniqueSessionId() { + let idA = scheduler.register(mockExecutorA) + let idB = scheduler.register(mockExecutorB) + let idC = scheduler.register(mockExecutorC) + + XCTAssertNotEqual(idA, idB, "Session IDs should be unique") + XCTAssertNotEqual(idB, idC, "Session IDs should be unique") + XCTAssertNotEqual(idA, idC, "Session IDs should be unique") + } + + func testRegisterReturnsMonotonicallyIncreasingIds() { + let idA = scheduler.register(mockExecutorA) + let idB = scheduler.register(mockExecutorB) + let idC = scheduler.register(mockExecutorC) + + XCTAssertLessThan(idA, idB, "Session IDs should be monotonically increasing") + XCTAssertLessThan(idB, idC, "Session IDs should be monotonically increasing") + } + + func testRegisterMultipleExecutors() { + let idA = scheduler.register(mockExecutorA) + let idB = scheduler.register(mockExecutorB) + + // Both should be registered with unique IDs + XCTAssertNotEqual(idA, idB, "Multiple executors should get unique IDs") + } + + func testUnregisterRemovesSession() { + // First verify that a registered session DOES get executed + let idA = scheduler.register(mockExecutorA) + + let executedOnce = XCTestExpectation(description: "Executed once while registered") + mockExecutorA.executeTurnHandler = { _, completion in + executedOnce.fulfill() + completion(.completed) + } + + scheduler.sessionDidEnqueueWork(idA) + wait(for: [executedOnce], timeout: 1.0) + + XCTAssertEqual(mockExecutorA.executeTurnCallCount, 1, + "Registered session should execute when work is enqueued") + + // Now unregister and verify no more execution + scheduler.unregister(sessionId: idA) + mockExecutorA.reset() + + // Enqueuing work for unregistered session should be a no-op + scheduler.sessionDidEnqueueWork(idA) + + // Give scheduler a chance to (incorrectly) execute + let noExecution = XCTestExpectation(description: "No execution after unregister") + noExecution.isInverted = true + mockExecutorA.executeTurnHandler = { _, _ in + noExecution.fulfill() + } + wait(for: [noExecution], timeout: 0.2) + + XCTAssertEqual(mockExecutorA.executeTurnCallCount, 0, + "Unregistered session should not execute") + } + + func testUnregisterCallsCleanupOnExecutor() { + let idA = scheduler.register(mockExecutorA) + scheduler.unregister(sessionId: idA) + + // Wait for async unregister to complete on mutationQueue + iTermGCD.mutationQueue().sync {} + + XCTAssertTrue(mockExecutorA.cleanupCalled, + "cleanupForUnregistration should be called on unregister") + } + + func testUnregisterNonexistentSessionIsNoOp() { + // Register a real session first + let idA = scheduler.register(mockExecutorA) + + // Unregistering non-existent session should not crash or affect existing sessions + scheduler.unregister(sessionId: 999) + + // Verify the existing session still works + let expectation = XCTestExpectation(description: "Existing session still works") + mockExecutorA.executeTurnHandler = { _, completion in + expectation.fulfill() + completion(.completed) + } + + scheduler.sessionDidEnqueueWork(idA) + wait(for: [expectation], timeout: 1.0) + + XCTAssertEqual(mockExecutorA.executeTurnCallCount, 1, + "Existing session should still work after unregistering non-existent session") + } + + func testSessionIdNoReuseAfterUnregistration() { + let idA = scheduler.register(mockExecutorA) + scheduler.unregister(sessionId: idA) + + let idB = scheduler.register(mockExecutorB) + + XCTAssertNotEqual(idA, idB, "Session IDs should never be reused") + XCTAssertGreaterThan(idB, idA, "New ID should be greater than unregistered ID") + } +} + +/// Tests for FairnessScheduler busy list management. +/// (see: testing.md Section 1.2) +final class FairnessSchedulerBusyListTests: XCTestCase { + + var scheduler: FairnessScheduler! + var mockExecutorA: MockFairnessSchedulerExecutor! + var mockExecutorB: MockFairnessSchedulerExecutor! + var mockExecutorC: MockFairnessSchedulerExecutor! + + override func setUp() { + super.setUp() + scheduler = FairnessScheduler() + mockExecutorA = MockFairnessSchedulerExecutor() + mockExecutorB = MockFairnessSchedulerExecutor() + mockExecutorC = MockFairnessSchedulerExecutor() + } + + override func tearDown() { + scheduler = nil + mockExecutorA = nil + mockExecutorB = nil + mockExecutorC = nil + super.tearDown() + } + + // MARK: - Busy List Tests (1.2) + + func testEnqueueWorkAddsToBusyList() { + mockExecutorA.turnResult = .completed + let idA = scheduler.register(mockExecutorA) + + let expectation = XCTestExpectation(description: "Turn executed") + mockExecutorA.executeTurnHandler = { budget, completion in + expectation.fulfill() + completion(.completed) + } + + scheduler.sessionDidEnqueueWork(idA) + + wait(for: [expectation], timeout: 1.0) + XCTAssertEqual(mockExecutorA.executeTurnCallCount, 1, + "Session should get a turn after enqueueing work") + } + + func testEnqueueWorkNoDuplicates() { + mockExecutorA.turnResult = .completed + let idA = scheduler.register(mockExecutorA) + + var turnCount = 0 + let expectation = XCTestExpectation(description: "Single turn executed") + mockExecutorA.executeTurnHandler = { budget, completion in + turnCount += 1 + if turnCount == 1 { + expectation.fulfill() + } + completion(.completed) + } + + // Enqueue work multiple times before execution + scheduler.sessionDidEnqueueWork(idA) + scheduler.sessionDidEnqueueWork(idA) + scheduler.sessionDidEnqueueWork(idA) + + wait(for: [expectation], timeout: 1.0) + + // Should only execute once (no duplicates in busy list) + XCTAssertEqual(turnCount, 1, + "Multiple enqueues before execution should not create duplicates") + } + + func testBusyListMaintainsFIFOOrder() { + // Configure all to yield so we can observe order + mockExecutorA.turnResult = .completed + mockExecutorB.turnResult = .completed + mockExecutorC.turnResult = .completed + + let idA = scheduler.register(mockExecutorA) + let idB = scheduler.register(mockExecutorB) + let idC = scheduler.register(mockExecutorC) + + var executionOrder: [String] = [] + let allDone = XCTestExpectation(description: "All turns executed") + allDone.expectedFulfillmentCount = 3 + + mockExecutorA.executeTurnHandler = { _, completion in + executionOrder.append("A") + allDone.fulfill() + completion(.completed) + } + mockExecutorB.executeTurnHandler = { _, completion in + executionOrder.append("B") + allDone.fulfill() + completion(.completed) + } + mockExecutorC.executeTurnHandler = { _, completion in + executionOrder.append("C") + allDone.fulfill() + completion(.completed) + } + + // Enqueue in order A, B, C + scheduler.sessionDidEnqueueWork(idA) + scheduler.sessionDidEnqueueWork(idB) + scheduler.sessionDidEnqueueWork(idC) + + wait(for: [allDone], timeout: 2.0) + + XCTAssertEqual(executionOrder, ["A", "B", "C"], + "Sessions should execute in FIFO order") + } + + func testEmptyBusyListNoExecution() { + // First verify that enqueueing work DOES trigger execution + let idA = scheduler.register(mockExecutorA) + + let executedWithWork = XCTestExpectation(description: "Executed when work enqueued") + mockExecutorA.executeTurnHandler = { _, completion in + executedWithWork.fulfill() + completion(.completed) + } + + scheduler.sessionDidEnqueueWork(idA) + wait(for: [executedWithWork], timeout: 1.0) + + XCTAssertEqual(mockExecutorA.executeTurnCallCount, 1, + "Session should execute when work is enqueued") + + // Now register a new session but don't enqueue work + let idB = scheduler.register(mockExecutorB) + + let noExecutionWithoutWork = XCTestExpectation(description: "No execution without work") + noExecutionWithoutWork.isInverted = true + + mockExecutorB.executeTurnHandler = { _, _ in + noExecutionWithoutWork.fulfill() + } + + // Don't call sessionDidEnqueueWork for B + wait(for: [noExecutionWithoutWork], timeout: 0.2) + + XCTAssertEqual(mockExecutorB.executeTurnCallCount, 0, + "Session should not execute without enqueued work") + } +} + +/// Tests for FairnessScheduler turn execution flow. +/// (see: testing.md Section 1.3) +final class FairnessSchedulerTurnExecutionTests: XCTestCase { + + var scheduler: FairnessScheduler! + var mockExecutorA: MockFairnessSchedulerExecutor! + var mockExecutorB: MockFairnessSchedulerExecutor! + + override func setUp() { + super.setUp() + scheduler = FairnessScheduler() + mockExecutorA = MockFairnessSchedulerExecutor() + mockExecutorB = MockFairnessSchedulerExecutor() + } + + override func tearDown() { + scheduler = nil + mockExecutorA = nil + mockExecutorB = nil + super.tearDown() + } + + // MARK: - Turn Execution Tests (1.3) + + func testYieldedResultReaddsToBusyListTail() { + let idA = scheduler.register(mockExecutorA) + let idB = scheduler.register(mockExecutorB) + + var aExecutionCount = 0 + var executionOrder: [String] = [] + let expectation = XCTestExpectation(description: "Multiple turns") + expectation.expectedFulfillmentCount = 3 + + mockExecutorA.executeTurnHandler = { _, completion in + aExecutionCount += 1 + executionOrder.append("A\(aExecutionCount)") + expectation.fulfill() + // First time yield, second time complete + completion(aExecutionCount == 1 ? .yielded : .completed) + } + mockExecutorB.executeTurnHandler = { _, completion in + executionOrder.append("B") + expectation.fulfill() + completion(.completed) + } + + scheduler.sessionDidEnqueueWork(idA) + scheduler.sessionDidEnqueueWork(idB) + + wait(for: [expectation], timeout: 2.0) + + // A yields, goes to back, B runs, then A runs again + XCTAssertEqual(executionOrder, ["A1", "B", "A2"], + "Yielded session should go to back of queue") + } + + func testCompletedResultDoesNotReaddWithoutNewWork() { + let idA = scheduler.register(mockExecutorA) + + var executionCount = 0 + let expectation = XCTestExpectation(description: "Single execution") + + mockExecutorA.executeTurnHandler = { _, completion in + executionCount += 1 + expectation.fulfill() + completion(.completed) + } + + scheduler.sessionDidEnqueueWork(idA) + + wait(for: [expectation], timeout: 1.0) + + // Flush mutation queue to ensure all scheduler operations complete + waitForMutationQueue() + + XCTAssertEqual(executionCount, 1, + "Completed session should not be re-added without new work") + } + + func testBlockedResultDoesNotReaddToBusyList() { + let idA = scheduler.register(mockExecutorA) + + var executionCount = 0 + let expectation = XCTestExpectation(description: "Blocked execution") + + mockExecutorA.executeTurnHandler = { _, completion in + executionCount += 1 + expectation.fulfill() + completion(.blocked) + } + + scheduler.sessionDidEnqueueWork(idA) + + wait(for: [expectation], timeout: 1.0) + + // Flush mutation queue to ensure all scheduler operations complete + waitForMutationQueue() + + XCTAssertEqual(executionCount, 1, + "Blocked session should not be re-added until unblocked") + } + + func testExecuteTurnCalledWithCorrectBudget() { + let idA = scheduler.register(mockExecutorA) + + let expectation = XCTestExpectation(description: "Turn executed") + mockExecutorA.executeTurnHandler = { budget, completion in + XCTAssertEqual(budget, 500, "Default token budget should be 500") + expectation.fulfill() + completion(.completed) + } + + scheduler.sessionDidEnqueueWork(idA) + + wait(for: [expectation], timeout: 1.0) + } + + func testNoOverlappingTurnsWhenCompletionDelayed() { + // REQUIREMENT: Scheduler must not call executeTurn on a session that's + // already executing (completion not yet called). This is a key safety + // property for the mutation queue model. + + let idA = scheduler.register(mockExecutorA) + + var executeTurnCallCount = 0 + var concurrentExecutionDetected = false + var isCurrentlyExecuting = false + var storedCompletion: ((TurnResult) -> Void)? + + let firstTurnStarted = XCTestExpectation(description: "First turn started") + let secondTurnStarted = XCTestExpectation(description: "Second turn started") + + mockExecutorA.executeTurnHandler = { _, completion in + executeTurnCallCount += 1 + + // Check for overlapping execution + if isCurrentlyExecuting { + concurrentExecutionDetected = true + } + + isCurrentlyExecuting = true + + if executeTurnCallCount == 1 { + // First turn: delay completion, store it + storedCompletion = completion + firstTurnStarted.fulfill() + } else { + // Second turn: complete immediately + secondTurnStarted.fulfill() + isCurrentlyExecuting = false + completion(.completed) + } + } + + // Start first turn + scheduler.sessionDidEnqueueWork(idA) + + // Wait for first turn to start + wait(for: [firstTurnStarted], timeout: 1.0) + + XCTAssertEqual(executeTurnCallCount, 1, "First turn should have started") + XCTAssertNotNil(storedCompletion, "Completion should be stored") + + // While first turn is executing (completion not called), enqueue more work + // This should NOT trigger another executeTurn call + scheduler.sessionDidEnqueueWork(idA) + scheduler.sessionDidEnqueueWork(idA) + scheduler.sessionDidEnqueueWork(idA) + + // Flush mutation queue to ensure all sessionDidEnqueueWork calls are processed + waitForMutationQueue() + + // Verify no second turn was started while first is still executing + XCTAssertEqual(executeTurnCallCount, 1, + "No new turn should start while completion is pending") + XCTAssertFalse(concurrentExecutionDetected, + "No concurrent execution should occur") + + // Now complete the first turn with .yielded (indicating more work) + isCurrentlyExecuting = false + storedCompletion?(.yielded) + + // Second turn should now start + wait(for: [secondTurnStarted], timeout: 1.0) + + XCTAssertEqual(executeTurnCallCount, 2, + "Second turn should start after first completion") + XCTAssertFalse(concurrentExecutionDetected, + "No concurrent execution should have occurred") + } + + func testWorkArrivedWhileExecutingIsPreserved() { + // REQUIREMENT: Work that arrives while a session is executing should + // cause the session to be re-added to busy list after completion, + // even if the result is .completed + + let idA = scheduler.register(mockExecutorA) + + var executeTurnCallCount = 0 + var storedCompletion: ((TurnResult) -> Void)? + + let firstTurnStarted = XCTestExpectation(description: "First turn started") + let secondTurnStarted = XCTestExpectation(description: "Second turn started") + + mockExecutorA.executeTurnHandler = { _, completion in + executeTurnCallCount += 1 + + if executeTurnCallCount == 1 { + storedCompletion = completion + firstTurnStarted.fulfill() + } else { + secondTurnStarted.fulfill() + completion(.completed) + } + } + + // Start first turn + scheduler.sessionDidEnqueueWork(idA) + wait(for: [firstTurnStarted], timeout: 1.0) + + // While executing, new work arrives + scheduler.sessionDidEnqueueWork(idA) + + // Complete with .completed (normally wouldn't re-add) + // But because work arrived, it SHOULD re-add + storedCompletion?(.completed) + + // Second turn should start because work arrived during execution + wait(for: [secondTurnStarted], timeout: 1.0) + + XCTAssertEqual(executeTurnCallCount, 2, + "Second turn should start because work arrived during first turn") + } +} + +/// Tests for FairnessScheduler round-robin fairness guarantees. +/// (see: testing.md Section 1.4) +final class FairnessSchedulerRoundRobinTests: XCTestCase { + + var scheduler: FairnessScheduler! + var executors: [MockFairnessSchedulerExecutor]! + var sessionIds: [UInt64]! + + override func setUp() { + super.setUp() + scheduler = FairnessScheduler() + executors = (0..<3).map { _ in MockFairnessSchedulerExecutor() } + sessionIds = executors.map { scheduler.register($0) } + } + + override func tearDown() { + scheduler = nil + executors = nil + sessionIds = nil + super.tearDown() + } + + // MARK: - Round-Robin Tests (1.4) + + func testThreeSessionsRoundRobin() { + var executionOrder: [Int] = [] + let expectation = XCTestExpectation(description: "Round robin") + expectation.expectedFulfillmentCount = 6 // Each session twice + + for (index, executor) in executors.enumerated() { + var callCount = 0 + executor.executeTurnHandler = { _, completion in + callCount += 1 + executionOrder.append(index) + expectation.fulfill() + // Yield twice, then complete + completion(callCount < 2 ? .yielded : .completed) + } + } + + // Enqueue work for all sessions + for id in sessionIds { + scheduler.sessionDidEnqueueWork(id) + } + + wait(for: [expectation], timeout: 3.0) + + // Should be: 0, 1, 2, 0, 1, 2 (round robin) + XCTAssertEqual(executionOrder, [0, 1, 2, 0, 1, 2], + "Sessions should execute in round-robin order") + } + + func testSingleSessionGetsAllTurns() { + let expectation = XCTestExpectation(description: "Multiple turns") + expectation.expectedFulfillmentCount = 3 + + var turnCount = 0 + executors[0].executeTurnHandler = { _, completion in + turnCount += 1 + expectation.fulfill() + completion(turnCount < 3 ? .yielded : .completed) + } + + scheduler.sessionDidEnqueueWork(sessionIds[0]) + + wait(for: [expectation], timeout: 2.0) + + XCTAssertEqual(turnCount, 3, + "Single session should get consecutive turns when alone") + } + + func testNewSessionAddedToTail() { + var executionOrder: [String] = [] + let expectation = XCTestExpectation(description: "New session at tail") + expectation.expectedFulfillmentCount = 3 + + // A and B are already registered + executors[0].executeTurnHandler = { _, completion in + executionOrder.append("A") + expectation.fulfill() + completion(.completed) + } + executors[1].executeTurnHandler = { _, completion in + executionOrder.append("B") + expectation.fulfill() + completion(.completed) + } + + // Enqueue A and B + scheduler.sessionDidEnqueueWork(sessionIds[0]) + scheduler.sessionDidEnqueueWork(sessionIds[1]) + + // Register and enqueue C (new session) + let newExecutor = MockFairnessSchedulerExecutor() + let newId = scheduler.register(newExecutor) + newExecutor.executeTurnHandler = { _, completion in + executionOrder.append("C") + expectation.fulfill() + completion(.completed) + } + scheduler.sessionDidEnqueueWork(newId) + + wait(for: [expectation], timeout: 2.0) + + // C should be at the end + XCTAssertEqual(executionOrder, ["A", "B", "C"], + "New session should be added to tail of busy list") + } +} + +/// Tests for FairnessScheduler thread safety. +/// These tests verify correct behavior under concurrent access. +final class FairnessSchedulerThreadSafetyTests: XCTestCase { + + var scheduler: FairnessScheduler! + + override func setUp() { + super.setUp() + scheduler = FairnessScheduler() + } + + override func tearDown() { + scheduler = nil + super.tearDown() + } + + // MARK: - Thread Safety Tests + + func testConcurrentRegistration() { + // REQUIREMENT: Multiple threads can safely call register() simultaneously + // NOTE: This is the WATCHDOG test - keeps a timeout to catch unexpected deadlocks. + // FairnessScheduler.register() has dispatchPrecondition to catch deadlock-prone patterns. + let threadCount = 4 + let registrationsPerThread = 10 + let group = DispatchGroup() + + var allSessionIds: [[UInt64]] = Array(repeating: [], count: threadCount) + let lock = NSLock() + + // Capture scheduler locally to prevent race with tearDown deallocation + let scheduler = self.scheduler! + + for threadIndex in 0..= 3 }) && iterations < maxIterations { + waitForMutationQueue() + iterations += 1 + } + XCTAssertLessThan(iterations, maxIterations, + "All sessions should complete within \(maxIterations) iterations") + + // Each should have executed multiple times due to yielding + var totalExecutions = 0 + for executor in executors { + totalExecutions += executor.executeTurnCallCount + XCTAssertGreaterThanOrEqual(executor.executeTurnCallCount, 1, + "Each executor should have run at least once") + } + + // With yielding, total should be roughly 3x sessionCount + XCTAssertGreaterThanOrEqual(totalExecutions, sessionCount, + "Total executions should be at least once per session") + } +} + +/// Tests for edge cases in session lifecycle. +final class FairnessSchedulerLifecycleEdgeCaseTests: XCTestCase { + + var scheduler: FairnessScheduler! + + override func setUp() { + super.setUp() + scheduler = FairnessScheduler() + } + + override func tearDown() { + scheduler = nil + super.tearDown() + } + + // MARK: - Lifecycle Edge Case Tests + + func testUnregisterDuringExecuteTurn() { + // REQUIREMENT: Unregistering while executeTurn completion hasn't fired should be safe + + // Capture scheduler locally to prevent race with tearDown deallocation + let scheduler = self.scheduler! + + let executor = MockFairnessSchedulerExecutor() + let sessionId = scheduler.register(executor) + + let executionStarted = XCTestExpectation(description: "Execution started") + let unregisterDone = XCTestExpectation(description: "Unregister completed") + + executor.executeTurnHandler = { _, completion in + executionStarted.fulfill() + + // Unregister while execution is "in progress" (before completion called) + DispatchQueue.global().asyncAfter(deadline: .now() + 0.05) { + scheduler.unregister(sessionId: sessionId) + unregisterDone.fulfill() + + // Now call completion - should be safe even though unregistered + completion(.yielded) + } + } + + scheduler.sessionDidEnqueueWork(sessionId) + + wait(for: [executionStarted, unregisterDone], timeout: 2.0) + + // Verify cleanup was called + XCTAssertTrue(executor.cleanupCalled, "Cleanup should be called on unregister") + + // Flush mutation queue to ensure no crash from late completion + waitForMutationQueue() + } + + func testUnregisterAfterYieldedBeforeNextTurn() { + // This test verifies that unregister cleans up properly after yielding. + // NOTE: Due to async scheduling, the second turn may already be queued + // before unregister takes effect. The key verification is that cleanup + // is called and no crash occurs. + let executor = MockFairnessSchedulerExecutor() + let sessionId = scheduler.register(executor) + + var executionCount = 0 + let firstExecution = XCTestExpectation(description: "First execution") + let unregisterDone = XCTestExpectation(description: "Unregister completed") + + executor.executeTurnHandler = { _, completion in + executionCount += 1 + if executionCount == 1 { + firstExecution.fulfill() + completion(.yielded) // Yield - would normally get another turn + + // Unregister - may or may not prevent the already-queued next turn + DispatchQueue.main.async { + self.scheduler.unregister(sessionId: sessionId) + unregisterDone.fulfill() + } + } else { + completion(.completed) + } + } + + scheduler.sessionDidEnqueueWork(sessionId) + + wait(for: [firstExecution, unregisterDone], timeout: 2.0) + + // Flush mutation queue to ensure all pending work is processed + waitForMutationQueue() + + // Verify cleanup was called (the main guarantee) + XCTAssertTrue(executor.cleanupCalled, + "Cleanup should be called on unregister") + + // The execution count may be 1 or 2 depending on timing + // (2 if the next turn was already queued before unregister) + XCTAssertLessThanOrEqual(executionCount, 2, + "At most 2 executions (one queued before unregister)") + } + + func testDoubleUnregister() { + // REQUIREMENT: Calling unregister twice for same session should be safe + let executor = MockFairnessSchedulerExecutor() + let sessionId = scheduler.register(executor) + + // First unregister + scheduler.unregister(sessionId: sessionId) + + // Wait for async unregister to complete on mutationQueue + iTermGCD.mutationQueue().sync {} + + XCTAssertTrue(executor.cleanupCalled, "First unregister should call cleanup") + + // Use a fresh executor to detect if cleanup is called again + // (The original executor's cleanupCalled is already true) + let executor2 = MockFairnessSchedulerExecutor() + // Registering a new session shouldn't affect the old unregistered one + + // Second unregister of original session - should be no-op (no crash) + scheduler.unregister(sessionId: sessionId) + + // Wait for second unregister to complete + iTermGCD.mutationQueue().sync {} + + // The test passes if we get here without crash + // We can't directly verify cleanup wasn't called again, + // but the session is already removed so cleanup can't be called + XCTAssertNotNil(executor, "Double unregister should not crash") + } + + func testEnqueueWorkForSessionBeingUnregistered() { + // REQUIREMENT: Enqueuing work for a session that's being unregistered is safe + let executor = MockFairnessSchedulerExecutor() + let sessionId = scheduler.register(executor) + + var executionCount = 0 + executor.executeTurnHandler = { _, completion in + executionCount += 1 + completion(.completed) + } + + // Rapidly enqueue and unregister + scheduler.sessionDidEnqueueWork(sessionId) + scheduler.unregister(sessionId: sessionId) + scheduler.sessionDidEnqueueWork(sessionId) // This should be no-op + + // Flush queues to ensure all pending operations complete + waitForMutationQueue() + waitForMainQueue() + + // Execution count should be 0 or 1, never more + // (depending on timing, the first enqueue may or may not have executed) + XCTAssertLessThanOrEqual(executionCount, 1, + "At most one execution before unregister") + } + + func testSchedulerProvidesPositiveBudget() { + // REQUIREMENT: FairnessScheduler must provide a positive budget to executors. + // The scheduler uses defaultTokenBudget (500) for all turns. + let executor = MockFairnessSchedulerExecutor() + let sessionId = scheduler.register(executor) + defer { scheduler.unregister(sessionId: sessionId) } + + var receivedBudget: Int? + let expectation = XCTestExpectation(description: "Turn executed") + + executor.executeTurnHandler = { budget, completion in + receivedBudget = budget + expectation.fulfill() + completion(.completed) + } + + scheduler.sessionDidEnqueueWork(sessionId) + wait(for: [expectation], timeout: 1.0) + + XCTAssertNotNil(receivedBudget) + XCTAssertEqual(receivedBudget!, FairnessScheduler.defaultTokenBudget, + "Scheduler should provide defaultTokenBudget") + } + + func testZeroBudgetBehavior() { + // REQUIREMENT: Progress guarantee - at least one group must execute per turn, + // even if that group alone exceeds the budget. This ensures forward progress. + // + // TokenExecutor enforces this at line 583-584: + // if tokensConsumed + groupTokenCount > tokenBudget && groupsExecuted > 0 { return false } + // The `groupsExecuted > 0` check ensures the first group always executes. + // + // Test: Executor simulates consuming more than budget on first group, + // then yields. This verifies the turn completes despite "exceeding" budget. + + let executor = MockFairnessSchedulerExecutor() + let sessionId = scheduler.register(executor) + defer { scheduler.unregister(sessionId: sessionId) } + + var turnCount = 0 + let firstTurnComplete = XCTestExpectation(description: "First turn executed") + let secondTurnComplete = XCTestExpectation(description: "Second turn executed") + + executor.executeTurnHandler = { budget, completion in + turnCount += 1 + if turnCount == 1 { + // First turn: simulate consuming entire budget and having more work + // (progress guarantee: first group always executes) + firstTurnComplete.fulfill() + completion(.yielded) // More work remains + } else { + // Second turn: work completes + secondTurnComplete.fulfill() + completion(.completed) + } + } + + // Trigger execution + scheduler.sessionDidEnqueueWork(sessionId) + + // Both turns should execute + wait(for: [firstTurnComplete, secondTurnComplete], timeout: 1.0, enforceOrder: true) + + XCTAssertEqual(turnCount, 2, "Session should get two turns when yielding after first") + } +} + +// MARK: - Sustained Load Fairness Tests + +/// Tests that verify fairness under sustained load conditions. +/// These tests validate the core fairness goal: no session should wait more than N-1 turns. +final class FairnessSchedulerSustainedLoadTests: XCTestCase { + + var scheduler: FairnessScheduler! + + override func setUp() { + super.setUp() + scheduler = FairnessScheduler() + } + + override func tearDown() { + scheduler = nil + super.tearDown() + } + + func testThreeSessionsSustainedLoadFairness() { + // REQUIREMENT: With 3 sessions continuously producing work, + // turns should interleave fairly: A, B, C, A, B, C, ... + // Each session should never wait more than 2 turns (N-1 where N=3). + + let executorA = MockFairnessSchedulerExecutor() + let executorB = MockFairnessSchedulerExecutor() + let executorC = MockFairnessSchedulerExecutor() + + let sessionA = scheduler.register(executorA) + let sessionB = scheduler.register(executorB) + let sessionC = scheduler.register(executorC) + + var turnOrder: [FairnessScheduler.SessionID] = [] + let lock = NSLock() + let totalTurns = 15 // 5 rounds of 3 sessions each + let turnExpectation = XCTestExpectation(description: "All turns completed") + turnExpectation.expectedFulfillmentCount = totalTurns + + // Configure executors to track turn order and simulate continuous work + func configureExecutor(_ executor: MockFairnessSchedulerExecutor, sessionId: FairnessScheduler.SessionID) { + executor.executeTurnHandler = { budget, completion in + lock.lock() + let currentCount = turnOrder.count + if currentCount < totalTurns { + turnOrder.append(sessionId) + turnExpectation.fulfill() + } + lock.unlock() + // Return .yielded to simulate continuous work + completion(.yielded) + } + } + + configureExecutor(executorA, sessionId: sessionA) + configureExecutor(executorB, sessionId: sessionB) + configureExecutor(executorC, sessionId: sessionC) + + // Trigger initial work for all sessions + scheduler.sessionDidEnqueueWork(sessionA) + scheduler.sessionDidEnqueueWork(sessionB) + scheduler.sessionDidEnqueueWork(sessionC) + + wait(for: [turnExpectation], timeout: 5.0) + + // Verify fairness: each session should appear roughly equally + lock.lock() + let finalOrder = turnOrder + lock.unlock() + + let countA = finalOrder.filter { $0 == sessionA }.count + let countB = finalOrder.filter { $0 == sessionB }.count + let countC = finalOrder.filter { $0 == sessionC }.count + + // With round-robin, each session gets totalTurns/3 turns (5 each) + XCTAssertEqual(countA, 5, "Session A should get 5 turns in 15 total") + XCTAssertEqual(countB, 5, "Session B should get 5 turns in 15 total") + XCTAssertEqual(countC, 5, "Session C should get 5 turns in 15 total") + + // Verify round-robin pattern: check that no session has 2 consecutive turns + for i in 0.. TokenExecutor -> FairnessScheduler + // + // Invariant: No session gets a second turn until all other busy sessions have had one turn. + // This is the KEY REGRESSION TEST for removing activeSessionsWithTokens. + // + // Test design (DETERMINISTIC - no polling/timeouts): + // 1. Create sessions with taskPaused=true to block execution + // 2. Add tokens to all sessions while blocked + // 3. Clear execution history + // 4. Unblock all sessions and kick scheduler + // 5. Sync to mutation queue to let execution complete + // 6. Verify proper round-robin order + try XCTSkipUnless(isDebugBuild, "Test requires ITERM_DEBUG hooks for execution history tracking") + + #if ITERM_DEBUG + // Create sessions with blocking enabled + var sessions: [(state: VT100ScreenMutableState, performer: MockSideEffectPerformer, id: UInt64)] = [] + + for i in 0..<3 { + let performer = MockSideEffectPerformer() + let state = VT100ScreenMutableState(sideEffectPerformer: performer) + state.terminalEnabled = true + state.tokenExecutor.isBackgroundSession = (i > 0) + + // Block execution using taskPaused + iTermGCD.mutationQueue().sync { + state.taskPaused = true + } + + let sessionId = state.tokenExecutor.fairnessSessionId + sessions.append((state: state, performer: performer, id: sessionId)) + } + + waitForMutationQueue() + + // Verify all sessions are registered + for session in sessions { + XCTAssertTrue(FairnessScheduler.shared.testIsSessionRegistered(session.id), + "Session \(session.id) should be registered") + } + + // Add tokens to ALL sessions while blocked + for session in sessions { + for _ in 0..<10 { + var vector = CVector() + CVectorCreate(&vector, 100) + for _ in 0..<100 { + let token = VT100Token() + token.type = VT100_UNKNOWNCHAR + CVectorAppendVT100Token(&vector, token) + } + session.state.tokenExecutor.addTokens(vector, lengthTotal: 1000, lengthExcludingInBandSignaling: 1000) + } + } + + waitForMutationQueue() + + // Clear execution history before unblocking + FairnessScheduler.shared.testClearExecutionHistory() + + // Unblock all sessions + iTermGCD.mutationQueue().sync { + for session in sessions { + session.state.taskPaused = false + } + } + + // Kick scheduler for each session + for session in sessions { + session.state.scheduleTokenExecution() + } + + // Wait for quiescence using iteration-based approach (deterministic, no timeout) + let iterations = waitForSchedulerQuiescence(maxIterations: 100) + XCTAssertNotEqual(iterations, -1, "Scheduler should reach quiescence within 100 iterations") + + // Get execution history + let history = FairnessScheduler.shared.testGetAndClearExecutionHistory() + + // Basic sanity check + XCTAssertGreaterThanOrEqual(history.count, 3, + "Should have at least one round. History: \(history)") + + // VERIFY ROUND-ROBIN INVARIANT: No session executes twice in a row + var violations: [String] = [] + for i in 1..= sessionCount { + let firstRound = Array(history.prefix(sessionCount)) + let uniqueInFirstRound = Set(firstRound) + XCTAssertEqual(uniqueInFirstRound.count, sessionCount, + "First round should include all sessions. First \(sessionCount): \(firstRound)") + } + #endif + + // Cleanup + for session in sessions { + session.state.terminalEnabled = false + } + waitForMutationQueue() + } + + func testMultipleBackgroundSessionsAllGetTurns() throws { + // REQUIREMENT: Multiple background sessions should all get fair turns. + // Tests that fairness applies across ALL sessions, not just foreground vs one background. + // + // Test design (DETERMINISTIC): + // 1. Create sessions with taskPaused=true to block execution + // 2. Add tokens to all sessions while blocked + // 3. Clear history, unblock, and let execution complete + // 4. Verify all sessions got turns via execution history + try XCTSkipUnless(isDebugBuild, "Test requires ITERM_DEBUG hooks for execution history tracking") + + #if ITERM_DEBUG + // Create 3 background sessions with blocking enabled + var sessions: [(state: VT100ScreenMutableState, performer: MockSideEffectPerformer, id: UInt64)] = [] + for _ in 0..<3 { + let performer = MockSideEffectPerformer() + let state = VT100ScreenMutableState(sideEffectPerformer: performer) + state.terminalEnabled = true + state.tokenExecutor.isBackgroundSession = true + + // Block execution + iTermGCD.mutationQueue().sync { + state.taskPaused = true + } + + let sessionId = state.tokenExecutor.fairnessSessionId + sessions.append((state: state, performer: performer, id: sessionId)) + } + + waitForMutationQueue() + + // Add tokens while blocked + for session in sessions { + for _ in 0..<10 { + var vector = CVector() + CVectorCreate(&vector, 100) + for _ in 0..<100 { + let token = VT100Token() + token.type = VT100_UNKNOWNCHAR + CVectorAppendVT100Token(&vector, token) + } + session.state.tokenExecutor.addTokens(vector, lengthTotal: 1000, lengthExcludingInBandSignaling: 1000) + } + } + + waitForMutationQueue() + + // Clear history before unblocking + FairnessScheduler.shared.testClearExecutionHistory() + + // Unblock all sessions + iTermGCD.mutationQueue().sync { + for session in sessions { + session.state.taskPaused = false + } + } + + // Kick scheduler + for session in sessions { + session.state.scheduleTokenExecution() + } + + // Wait for quiescence using iteration-based approach (deterministic, no timeout) + let iterations = waitForSchedulerQuiescence(maxIterations: 100) + XCTAssertNotEqual(iterations, -1, "Scheduler should reach quiescence within 100 iterations") + + // Verify via execution history + let history = FairnessScheduler.shared.testGetAndClearExecutionHistory() + + XCTAssertGreaterThanOrEqual(history.count, 3, + "Should have at least one round. History: \(history)") + + // Each session should have gotten turns + for session in sessions { + let turnCount = history.filter { $0 == session.id }.count + XCTAssertGreaterThan(turnCount, 0, + "Session \(session.id) should have at least one turn. History: \(history)") + } + + // Verify round-robin (no consecutive same-session executions) + var violations = 0 + for i in 1..= 0 + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + // Create a pipe to get valid file descriptors + var pipeFds: [Int32] = [0, 0] + let result = pipe(&pipeFds) + XCTAssertEqual(result, 0, "pipe() should succeed") + + // Set up task with valid fd + task.testSetFd(pipeFds[0]) + + #if ITERM_DEBUG + // Before setup, should have no sources + XCTAssertFalse(task.testHasReadSource, "Fresh task should have no read source") + XCTAssertFalse(task.testHasWriteSource, "Fresh task should have no write source") + #endif + + // Call setupDispatchSources (simulating what didRegister does) + task.testSetupDispatchSourcesForTesting() + + #if ITERM_DEBUG + // After setup, should have sources + XCTAssertTrue(task.testHasReadSource, "Task should have read source after setup") + XCTAssertTrue(task.testHasWriteSource, "Task should have write source after setup") + #endif + + // Cleanup + task.testTeardownDispatchSourcesForTesting() + close(pipeFds[0]) + close(pipeFds[1]) + } + + func testBackpressureHandlerCalledOnBackpressureRelease() throws { + // REQUIREMENT: backpressureReleaseHandler is called when transitioning from + // heavy backpressure to non-heavy during token consumption. + // + // The handler is called in TokenExecutor.onConsumed when: + // availableSlots > 0 && backpressureLevel < .heavy + // + // Test design: + // 1. Drive executor to heavy backpressure (>75% slots consumed) + // 2. Set up handler to track calls + // 3. Consume tokens via execution (scheduler turns) + // 4. Verify handler was called when crossing out of heavy + + try XCTSkipUnless(isDebugBuild, "Test requires ITERM_DEBUG hooks") + + #if ITERM_DEBUG + let terminal = VT100Terminal() + let executor = TokenExecutor(terminal, slownessDetector: SlownessDetector(), queue: iTermGCD.mutationQueue()) + let delegate = MockTokenExecutorDelegate() + executor.delegate = delegate + + let sessionId = FairnessScheduler.shared.register(executor) + executor.fairnessSessionId = sessionId + defer { + FairnessScheduler.shared.unregister(sessionId: sessionId) + waitForMutationQueue() + } + + // Get total slots to calculate how many tokens needed for heavy backpressure + let totalSlots = executor.testTotalSlots + // Heavy = < 25% available, so consume > 75% of slots + let tokensForHeavy = Int(Double(totalSlots) * 0.80) + + // Block execution initially so we can fill up the queue + delegate.shouldQueueTokens = true + + // Add enough token arrays to reach heavy backpressure + for _ in 0..(0) + executor.backpressureReleaseHandler = { + _ = handlerCallCount.mutate { $0 + 1 } + } + + // Unblock execution and let tokens be consumed + iTermGCD.mutationQueue().sync { + delegate.shouldQueueTokens = false + } + executor.schedule() + + // Wait until all tokens consumed (deterministic, iteration-based) + var iterations = 0 + let maxIterations = 100 + while executor.testAvailableSlots < executor.testTotalSlots && iterations < maxIterations { + waitForMutationQueue() + iterations += 1 + } + XCTAssertLessThan(iterations, maxIterations, "Should consume all tokens within \(maxIterations) iterations") + + // Handler should have been called when crossing out of heavy backpressure + let finalCount = handlerCallCount.value + XCTAssertGreaterThan(finalCount, 0, + "backpressureReleaseHandler should be called when transitioning out of heavy. " + + "Final backpressure: \(executor.backpressureLevel)") + + // Backpressure should be reduced after consumption + XCTAssertLessThan(executor.backpressureLevel.rawValue, BackpressureLevel.heavy.rawValue, + "Backpressure should be below heavy after consumption") + #endif + } + + func testTokenExecutorWiringEnablesBackpressureMonitoring() throws { + // REQUIREMENT: PTYSession sets task.tokenExecutor + // PTYTask uses tokenExecutor.backpressureLevel for shouldRead predicate + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + // Create and wire executor + let terminal = VT100Terminal() + let executor = TokenExecutor(terminal, slownessDetector: SlownessDetector(), queue: iTermGCD.mutationQueue()) + let delegate = MockTokenExecutorDelegate() + executor.delegate = delegate + + // Wire up task.tokenExecutor (simulating what PTYSession.taskDidRegister does) + task.tokenExecutor = executor + + // Verify the wiring + XCTAssertNotNil(task.tokenExecutor, "PTYTask.tokenExecutor should be set") + XCTAssert(task.tokenExecutor === executor, "PTYTask.tokenExecutor should reference the wired executor") + + // Verify backpressure level is accessible through the wiring + if let backpressureLevel = task.tokenExecutor?.backpressureLevel { + XCTAssertEqual(backpressureLevel, .none, + "Should be able to read backpressure level through wiring") + } else { + XCTFail("Should be able to read backpressure level through wiring") + } + } +} + +// MARK: - 5.6 PTYSession Wiring Tests + +/// Tests for PTYSession wiring between components (5.6) +final class IntegrationPTYSessionWiringTests: XCTestCase { + + func testFullSessionCreation() throws { + // REQUIREMENT: Full session creates all components correctly + // PTYSession should wire PTYTask, TokenExecutor, and FairnessScheduler + + // Create all components that a PTYSession would create + let terminal = VT100Terminal() + let executor = TokenExecutor(terminal, slownessDetector: SlownessDetector(), queue: DispatchQueue.main) + let delegate = MockTokenExecutorDelegate() + executor.delegate = delegate + + // Create a PTYTask (simulating shell launch would require forkpty) + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + // Wire components as PTYSession would + task.tokenExecutor = executor + + // Register with scheduler as VT100ScreenMutableState would + let sessionId = FairnessScheduler.shared.register(executor) + executor.fairnessSessionId = sessionId + + // Verify wiring + XCTAssertNotNil(task.tokenExecutor, "PTYTask should have tokenExecutor") + XCTAssertEqual(executor.fairnessSessionId, sessionId, "Executor should have session ID") + + #if ITERM_DEBUG + XCTAssertTrue(FairnessScheduler.shared.testIsSessionRegistered(sessionId), + "Session should be registered") + #endif + + // Cleanup + FairnessScheduler.shared.unregister(sessionId: sessionId) + waitForMutationQueue() + } + + func testSessionCloseCleanup() throws { + // REQUIREMENT: Session close cleans up all resources + // No leaks of dispatch sources, scheduler registrations, etc. + + let terminal = VT100Terminal() + let executor = TokenExecutor(terminal, slownessDetector: SlownessDetector(), queue: DispatchQueue.main) + + let sessionId = FairnessScheduler.shared.register(executor) + executor.fairnessSessionId = sessionId + + // Add some tokens to create pending work + var vector = CVector() + CVectorCreate(&vector, 5) + for _ in 0..<5 { + let token = VT100Token() + token.type = VT100_UNKNOWNCHAR + CVectorAppendVT100Token(&vector, token) + } + executor.addTokens(vector, lengthTotal: 50, lengthExcludingInBandSignaling: 50) + + #if ITERM_DEBUG + let registeredBefore = FairnessScheduler.shared.testRegisteredSessionCount + #endif + + // Simulate session close - unregister should cleanup + FairnessScheduler.shared.unregister(sessionId: sessionId) + waitForMutationQueue() + + #if ITERM_DEBUG + XCTAssertEqual(FairnessScheduler.shared.testRegisteredSessionCount, registeredBefore - 1, + "Registered count should decrease after cleanup") + XCTAssertFalse(FairnessScheduler.shared.testIsSessionRegistered(sessionId), + "Session should not be registered after cleanup") + #endif + + // Backpressure should be released + XCTAssertEqual(executor.backpressureLevel, .none, + "Cleanup should release all backpressure") + } +} + +// MARK: - Dispatch Source Lifecycle Integration Tests + +/// Tests for dispatch source lifecycle during process lifecycle +final class DispatchSourceLifecycleIntegrationTests: XCTestCase { + + func testProcessLaunchCreatesSource() throws { + // REQUIREMENT: Dispatch source created after successful forkpty + // This test verifies the ACTUAL launch path calls setupDispatchSources: + // TaskNotifier.registerTask: → dispatch_async(main) → didRegister → setupDispatchSources + // + // This exercises the production code path, not just test helpers. + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + // Create a pipe to get valid file descriptors (simulates what forkpty provides) + var pipeFds: [Int32] = [0, 0] + let pipeResult = pipe(&pipeFds) + XCTAssertEqual(pipeResult, 0, "pipe() should succeed") + + // Cast to iTermTask protocol (PTYTask implements iTermTask but Swift needs explicit cast) + let iTermTaskConformingTask = task as! any iTermTask + + defer { + // Cleanup: teardown sources and close pipe + task.testTeardownDispatchSourcesForTesting() + TaskNotifier.sharedInstance().deregister(iTermTaskConformingTask) + close(pipeFds[0]) + close(pipeFds[1]) + } + + // Set the fd on the task (simulates fd becoming valid after forkpty) + task.testSetFd(pipeFds[0]) + + #if ITERM_DEBUG + // Before registration, task should have no dispatch sources + XCTAssertFalse(task.testHasReadSource, "Task should have no read source before registration") + XCTAssertFalse(task.testHasWriteSource, "Task should have no write source before registration") + #endif + + // Register with TaskNotifier using the ACTUAL production API + // This triggers: registerTask: → dispatch_async(main) → didRegister → setupDispatchSources + TaskNotifier.sharedInstance().register(iTermTaskConformingTask) + + // Wait for the main queue dispatch where didRegister is called + let expectation = XCTestExpectation(description: "didRegister called") + DispatchQueue.main.async { + expectation.fulfill() + } + wait(for: [expectation], timeout: 2.0) + + #if ITERM_DEBUG + // After registration through the real path, dispatch sources should be created + XCTAssertTrue(task.testHasReadSource, + "Task should have read source after TaskNotifier registration calls didRegister") + XCTAssertTrue(task.testHasWriteSource, + "Task should have write source after TaskNotifier registration calls didRegister") + #endif + } + + func testTaskNotifierRegistrationTriggersSetupDispatchSources() throws { + // REQUIREMENT: Verify the wiring from TaskNotifier → didRegister → setupDispatchSources + // This is an end-to-end test of the production dispatch source activation path. + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + // Create a pipe for valid fd + var pipeFds: [Int32] = [0, 0] + XCTAssertEqual(pipe(&pipeFds), 0, "pipe() should succeed") + + // Cast to iTermTask protocol + let iTermTaskConformingTask = task as! any iTermTask + + defer { + task.testTeardownDispatchSourcesForTesting() + TaskNotifier.sharedInstance().deregister(iTermTaskConformingTask) + close(pipeFds[0]) + close(pipeFds[1]) + } + + task.testSetFd(pipeFds[0]) + + // Track whether didRegister was called by observing source creation + #if ITERM_DEBUG + let hadSourceBefore = task.testHasReadSource + XCTAssertFalse(hadSourceBefore, "Should start without sources") + #endif + + // Use the production TaskNotifier registration path + TaskNotifier.sharedInstance().register(iTermTaskConformingTask) + + // The didRegister call is dispatched to main queue, wait for it to complete + waitForMainQueue() + + #if ITERM_DEBUG + // Verify the production path created sources + XCTAssertTrue(task.testHasReadSource, + "Production path (TaskNotifier.register → didRegister) should create read source") + XCTAssertTrue(task.testHasWriteSource, + "Production path (TaskNotifier.register → didRegister) should create write source") + #endif + } + + func testProcessExitCleansUpSource() throws { + // REQUIREMENT: Sources torn down when process exits + // teardownDispatchSources should be called on process exit + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + // Verify PTYTask has teardownDispatchSources method + let selector = NSSelectorFromString("teardownDispatchSources") + XCTAssertTrue(task.responds(to: selector), + "PTYTask should have teardownDispatchSources for cleanup") + + #if ITERM_DEBUG + // Fresh task has no sources + XCTAssertFalse(task.testHasReadSource, "Fresh task has no read source") + XCTAssertFalse(task.testHasWriteSource, "Fresh task has no write source") + + // Call teardown (simulates what happens on process exit/dealloc) + task.perform(selector) + + // State should remain clean + XCTAssertFalse(task.testHasReadSource, "No read source after teardown") + XCTAssertFalse(task.testHasWriteSource, "No write source after teardown") + #endif + } + + func testNoSourceLeakOnRapidRestart() throws { + // REQUIREMENT: Rapidly restarting shells doesn't leak sources + // Each restart should clean up old sources before creating new ones + + // Create many tasks in rapid succession (simulates rapid shell restart) + for i in 0..<20 { + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask at iteration \(i)") + return + } + + // Register with scheduler + let terminal = VT100Terminal() + let executor = TokenExecutor(terminal, slownessDetector: SlownessDetector(), queue: DispatchQueue.main) + let sessionId = FairnessScheduler.shared.register(executor) + executor.fairnessSessionId = sessionId + + task.tokenExecutor = executor + + // Immediately cleanup (simulates quick close) + FairnessScheduler.shared.unregister(sessionId: sessionId) + + let teardownSelector = NSSelectorFromString("teardownDispatchSources") + if task.responds(to: teardownSelector) { + task.perform(teardownSelector) + } + } + + // Wait for all async cleanup + waitForMutationQueue() + + #if ITERM_DEBUG + // All sessions should be cleaned up + XCTAssertEqual(FairnessScheduler.shared.testBusySessionCount, 0, + "No busy sessions should remain after rapid restart test") + #endif + } +} + +// MARK: - Backpressure Integration Tests + +/// Tests for backpressure system integration +final class BackpressureIntegrationTests: XCTestCase { + + func testHighThroughputSuspended() throws { + // REQUIREMENT: High-throughput session's read source suspended at high backpressure + // When backpressure is blocked, reading should stop + + let terminal = VT100Terminal() + let executor = TokenExecutor(terminal, slownessDetector: SlownessDetector(), queue: DispatchQueue.main) + + let sessionId = FairnessScheduler.shared.register(executor) + executor.fairnessSessionId = sessionId + + // Add many tokens to create blocked backpressure (50 tokens > 40 slots) + for _ in 0..<50 { + var vector = CVector() + CVectorCreate(&vector, 10) + for _ in 0..<10 { + let token = VT100Token() + token.type = VT100_UNKNOWNCHAR + CVectorAppendVT100Token(&vector, token) + } + executor.addTokens(vector, lengthTotal: 100, lengthExcludingInBandSignaling: 100) + } + + // Should be blocked when exceeding capacity + XCTAssertEqual(executor.backpressureLevel, .blocked, + "Should be blocked after exceeding slot capacity") + + // Create a PTYTask to verify shouldRead is false + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + FairnessScheduler.shared.unregister(sessionId: sessionId) + return + } + task.tokenExecutor = executor + task.paused = false + + // Use test override to force ioAllowed = true (bypasses jobManager requirement) + task.testIoAllowedOverride = NSNumber(value: true) + + // shouldRead should be false due to blocked backpressure + // shouldRead checks: !paused && ioAllowed && backpressureLevel < .heavy + // With blocked backpressure (>= .heavy), shouldRead must be false + guard let shouldRead = task.value(forKey: "shouldRead") as? Bool else { + XCTFail("Failed to get shouldRead from PTYTask") + FairnessScheduler.shared.unregister(sessionId: sessionId) + return + } + XCTAssertFalse(shouldRead, + "shouldRead should be false when backpressure is blocked") + + // Cleanup + FairnessScheduler.shared.unregister(sessionId: sessionId) + waitForMutationQueue() + } + + func testSuspendedSessionResumedOnDrain() throws { + // REQUIREMENT: Suspended session resumes when tokens consumed + // backpressureReleaseHandler should resume reading + + let terminal = VT100Terminal() + let executor = TokenExecutor(terminal, slownessDetector: SlownessDetector(), queue: DispatchQueue.main) + let delegate = MockTokenExecutorDelegate() + executor.delegate = delegate + + var releaseHandlerCallCount = 0 + executor.backpressureReleaseHandler = { + releaseHandlerCallCount += 1 + } + + let sessionId = FairnessScheduler.shared.register(executor) + executor.fairnessSessionId = sessionId + + // Add tokens to create backpressure + for _ in 0..<30 { + var vector = CVector() + CVectorCreate(&vector, 5) + for _ in 0..<5 { + let token = VT100Token() + token.type = VT100_UNKNOWNCHAR + CVectorAppendVT100Token(&vector, token) + } + executor.addTokens(vector, lengthTotal: 50, lengthExcludingInBandSignaling: 50) + } + + // Record initial release handler calls + let initialCallCount = releaseHandlerCallCount + + // Drain tokens via executeTurn + let drainExpectation = XCTestExpectation(description: "Drain tokens") + drainExpectation.expectedFulfillmentCount = 5 + for _ in 0..<5 { + executor.executeTurn(tokenBudget: 500) { _ in + drainExpectation.fulfill() + } + } + wait(for: [drainExpectation], timeout: 5.0) + + // After draining, backpressureReleaseHandler should have been called + XCTAssertGreaterThan(releaseHandlerCallCount, initialCallCount, + "backpressureReleaseHandler should be called during drain") + + // Cleanup + FairnessScheduler.shared.unregister(sessionId: sessionId) + waitForMutationQueue() + } + + func testBackpressureIsolation() throws { + // REQUIREMENT: Session A's backpressure doesn't affect Session B's reading + // Each session has independent backpressure + + // Test with two independent executors + let terminal1 = VT100Terminal() + let terminal2 = VT100Terminal() + let executor1 = TokenExecutor(terminal1, slownessDetector: SlownessDetector(), queue: DispatchQueue.main) + let executor2 = TokenExecutor(terminal2, slownessDetector: SlownessDetector(), queue: DispatchQueue.main) + + // Register both with scheduler + let sessionId1 = FairnessScheduler.shared.register(executor1) + let sessionId2 = FairnessScheduler.shared.register(executor2) + executor1.fairnessSessionId = sessionId1 + executor2.fairnessSessionId = sessionId2 + + // Both should start with no backpressure + XCTAssertEqual(executor1.backpressureLevel, .none, + "Executor 1 should start with no backpressure") + XCTAssertEqual(executor2.backpressureLevel, .none, + "Executor 2 should start with no backpressure") + + // Drive executor1 to blocked backpressure (50 tokens > 40 slots) + for _ in 0..<50 { + var vector = CVector() + CVectorCreate(&vector, 10) + for _ in 0..<10 { + let token = VT100Token() + token.type = VT100_UNKNOWNCHAR + CVectorAppendVT100Token(&vector, token) + } + executor1.addTokens(vector, lengthTotal: 100, lengthExcludingInBandSignaling: 100) + } + + // Verify executor1 is now blocked + XCTAssertEqual(executor1.backpressureLevel, .blocked, + "Executor 1 should be blocked after exceeding slot capacity") + + // CRITICAL: Verify executor2 remains unaffected - this is the isolation test + XCTAssertEqual(executor2.backpressureLevel, .none, + "Executor 2 should remain at .none - backpressure must be isolated per-session") + + // Cleanup + FairnessScheduler.shared.unregister(sessionId: sessionId1) + FairnessScheduler.shared.unregister(sessionId: sessionId2) + waitForMutationQueue() + } + + func testBackpressureIsolationEndToEndWithDispatchSources() throws { + // CRITICAL END-TO-END TEST: Session A backpressured does not stall Session B reads + // + // This is the core fix for TaskNotifier starvation. In the old select() model, + // when session A blocked, session B couldn't read either because they shared + // the same select loop. With dispatch_source, each session reads independently. + // + // Test setup: + // 1. Two PTYTasks with real pipes and dispatch sources + // 2. Drive session A to backpressure (suspending its read source) + // 3. Write data to BOTH sessions' pipes + // 4. Verify session B still receives data (dispatch source fires) + // 5. Verify session A does NOT receive data (read source suspended) + + #if ITERM_DEBUG + // Create task A + guard let taskA = PTYTask() else { + XCTFail("Failed to create PTYTask A") + return + } + guard let pipeA = createTestPipe() else { + XCTFail("Failed to create pipe A") + return + } + + // Create task B + guard let taskB = PTYTask() else { + closeTestPipe(pipeA) + XCTFail("Failed to create PTYTask B") + return + } + guard let pipeB = createTestPipe() else { + closeTestPipe(pipeA) + XCTFail("Failed to create pipe B") + return + } + + defer { + taskA.testTeardownDispatchSourcesForTesting() + taskB.testTeardownDispatchSourcesForTesting() + closeTestPipe(pipeA) + closeTestPipe(pipeB) + } + + // Set up delegates to track reads + let delegateA = MockPTYTaskDelegate() + let delegateB = MockPTYTaskDelegate() + taskA.delegate = delegateA + taskB.delegate = delegateB + + // Configure tasks with FDs + taskA.testSetFd(pipeA.readFd) + taskB.testSetFd(pipeB.readFd) + taskA.paused = false + taskB.paused = false + + // Set up executors for backpressure tracking + let terminalA = VT100Terminal() + let terminalB = VT100Terminal() + let executorA = TokenExecutor(terminalA, slownessDetector: SlownessDetector(), queue: DispatchQueue.main) + let executorB = TokenExecutor(terminalB, slownessDetector: SlownessDetector(), queue: DispatchQueue.main) + taskA.tokenExecutor = executorA + taskB.tokenExecutor = executorB + + // Register with scheduler + let sessionIdA = FairnessScheduler.shared.register(executorA) + let sessionIdB = FairnessScheduler.shared.register(executorB) + executorA.fairnessSessionId = sessionIdA + executorB.fairnessSessionId = sessionIdB + defer { + FairnessScheduler.shared.unregister(sessionId: sessionIdA) + FairnessScheduler.shared.unregister(sessionId: sessionIdB) + waitForMutationQueue() + } + + // Setup dispatch sources for both tasks + taskA.testSetupDispatchSourcesForTesting() + taskB.testSetupDispatchSourcesForTesting() + taskA.testWaitForIOQueue() + taskB.testWaitForIOQueue() + + // Verify both start with no backpressure and resumed read sources + XCTAssertEqual(executorA.backpressureLevel, .none) + XCTAssertEqual(executorB.backpressureLevel, .none) + XCTAssertFalse(taskA.testIsReadSourceSuspended, "Task A read source should start resumed") + XCTAssertFalse(taskB.testIsReadSourceSuspended, "Task B read source should start resumed") + + // Drive Task A to blocked backpressure (200 tokens > 40 slots) + executorA.addMultipleTokenArrays(count: 200, tokensPerArray: 5) + XCTAssertEqual(executorA.backpressureLevel, .blocked, + "Executor A should be blocked after exceeding slot capacity") + + // Trigger state update on Task A + taskA.perform(NSSelectorFromString("updateReadSourceState")) + taskA.testWaitForIOQueue() + + // Verify Task A's read source is suspended + XCTAssertTrue(taskA.testIsReadSourceSuspended, + "Task A read source should be SUSPENDED due to backpressure") + + // CRITICAL: Verify Task B is UNAFFECTED + XCTAssertEqual(executorB.backpressureLevel, .none, + "Executor B should remain at .none") + XCTAssertFalse(taskB.testIsReadSourceSuspended, + "Task B read source should remain RESUMED - isolation required") + + // Now write data to BOTH pipes + let testDataA = "Data for session A".data(using: .utf8)! + let testDataB = "Data for session B".data(using: .utf8)! + + let initialReadCountA = delegateA.readCallCount + let initialReadCountB = delegateB.readCallCount + + // Set up expectation for Task B to receive data + let taskBReadExpectation = XCTestExpectation(description: "Task B receives data") + delegateB.onThreadedRead = { _ in + taskBReadExpectation.fulfill() + } + + // Write to both pipes + testDataA.withUnsafeBytes { bufferPointer in + _ = Darwin.write(pipeA.writeFd, bufferPointer.baseAddress!, testDataA.count) + } + testDataB.withUnsafeBytes { bufferPointer in + _ = Darwin.write(pipeB.writeFd, bufferPointer.baseAddress!, testDataB.count) + } + + // Wait for Task B to receive data (should happen quickly since not backpressured) + wait(for: [taskBReadExpectation], timeout: 2.0) + + // Flush queues + taskA.testWaitForIOQueue() + taskB.testWaitForIOQueue() + waitForMainQueue() + + // CRITICAL ASSERTIONS: + // Task B MUST have received data (proves dispatch_source independence) + XCTAssertGreaterThan(delegateB.readCallCount, initialReadCountB, + "Task B MUST receive data - this proves session isolation works") + + // Task A should NOT have received data (read source suspended) + XCTAssertEqual(delegateA.readCallCount, initialReadCountA, + "Task A should NOT receive data while backpressured") + + // This test proves the core fix: Session A's backpressure does NOT stall Session B's reads. + // In the old TaskNotifier select() model, both sessions would have been blocked. + #else + throw XCTSkip("End-to-end backpressure isolation test requires ITERM_DEBUG") + #endif + } +} + +// MARK: - Session Lifecycle Integration Tests + +/// Tests for session lifecycle edge cases +final class SessionLifecycleIntegrationTests: XCTestCase { + + func testSessionCloseWithPendingTokens() throws { + // REQUIREMENT: Closing session with queued tokens doesn't leak/crash + // cleanupForUnregistration should handle pending tokens + + let terminal = VT100Terminal() + let executor = TokenExecutor(terminal, slownessDetector: SlownessDetector(), queue: DispatchQueue.main) + + let sessionId = FairnessScheduler.shared.register(executor) + executor.fairnessSessionId = sessionId + + // Add tokens + var vector = CVector() + CVectorCreate(&vector, 1) + let token = VT100Token() + token.type = VT100_UNKNOWNCHAR + CVectorAppendVT100Token(&vector, token) + executor.addTokens(vector, lengthTotal: 10, lengthExcludingInBandSignaling: 10) + + // Close session - should not crash + FairnessScheduler.shared.unregister(sessionId: sessionId) + + // If we get here without crashing, test passes + XCTAssertTrue(true, "Session closed with pending tokens without crash") + } + + func testSessionCloseAccountingCorrect() throws { + // REQUIREMENT: availableSlots returns to initial after close + // No accounting drift after session lifecycle + + let terminal = VT100Terminal() + let executor = TokenExecutor(terminal, slownessDetector: SlownessDetector(), queue: DispatchQueue.main) + + let sessionId = FairnessScheduler.shared.register(executor) + executor.fairnessSessionId = sessionId + + // Add tokens + var vector = CVector() + CVectorCreate(&vector, 1) + let token = VT100Token() + token.type = VT100_UNKNOWNCHAR + CVectorAppendVT100Token(&vector, token) + executor.addTokens(vector, lengthTotal: 10, lengthExcludingInBandSignaling: 10) + + // Close session + FairnessScheduler.shared.unregister(sessionId: sessionId) + + // Backpressure should return to none + XCTAssertEqual(executor.backpressureLevel, .none, + "Accounting should be correct after close") + } + + func testRapidSessionOpenClose() throws { + // REQUIREMENT: Rapidly opening/closing sessions doesn't cause issues + // Stress test for session lifecycle + + let terminal = VT100Terminal() + + for _ in 0..<10 { + let executor = TokenExecutor(terminal, slownessDetector: SlownessDetector(), queue: DispatchQueue.main) + let sessionId = FairnessScheduler.shared.register(executor) + executor.fairnessSessionId = sessionId + FairnessScheduler.shared.unregister(sessionId: sessionId) + } + + // If we get here without crash/hang, test passes + XCTAssertTrue(true, "Rapid open/close completed successfully") + } + + func testSessionCloseDuringExecution() throws { + // REQUIREMENT: Session closes while its turn is executing + // Edge case: session removed from scheduler mid-turn + + let terminal = VT100Terminal() + let executor = TokenExecutor(terminal, slownessDetector: SlownessDetector(), queue: DispatchQueue.main) + let delegate = MockTokenExecutorDelegate() + executor.delegate = delegate + + let sessionId = FairnessScheduler.shared.register(executor) + executor.fairnessSessionId = sessionId + + // Add tokens + var vector = CVector() + CVectorCreate(&vector, 10) + for _ in 0..<10 { + let token = VT100Token() + token.type = VT100_UNKNOWNCHAR + CVectorAppendVT100Token(&vector, token) + } + executor.addTokens(vector, lengthTotal: 100, lengthExcludingInBandSignaling: 100) + + // Start execution + let executionStarted = XCTestExpectation(description: "Execution started") + let executionComplete = XCTestExpectation(description: "Execution complete") + + executor.executeTurn(tokenBudget: 500) { result in + executionComplete.fulfill() + } + executionStarted.fulfill() + + // Immediately unregister (mid-turn) + FairnessScheduler.shared.unregister(sessionId: sessionId) + + // Wait for both to complete without crash + wait(for: [executionStarted, executionComplete], timeout: 2.0) + + // After everything settles, verify cleanup + waitForMutationQueue() + + #if ITERM_DEBUG + XCTAssertFalse(FairnessScheduler.shared.testIsSessionRegistered(sessionId), + "Session should be unregistered after mid-turn close") + #endif + } +} diff --git a/ModernTests/FairnessScheduler/Mocks/EnqueuingPTYTaskDelegate.swift b/ModernTests/FairnessScheduler/Mocks/EnqueuingPTYTaskDelegate.swift new file mode 100644 index 0000000000..2dc5c2f20b --- /dev/null +++ b/ModernTests/FairnessScheduler/Mocks/EnqueuingPTYTaskDelegate.swift @@ -0,0 +1,144 @@ +// +// EnqueuingPTYTaskDelegate.swift +// ModernTests +// +// Specialized PTYTaskDelegate for testing the read pipeline that enqueues +// read data to a TokenExecutor instead of just tracking calls. +// This enables end-to-end pipeline testing from read → parse → queue. +// + +import Foundation +@testable import iTerm2SharedARC + +/// A PTYTaskDelegate that actually enqueues read data to a TokenExecutor. +/// Used for testing the full read handler pipeline. +final class EnqueuingPTYTaskDelegate: NSObject, PTYTaskDelegate { + + // MARK: - Configuration + + /// The TokenExecutor to enqueue tokens to. + var tokenExecutor: TokenExecutor? + + /// Callback invoked when data is read (before enqueueing). + var onThreadedRead: ((Data) -> Void)? + + /// Callback invoked after tokens are enqueued. + var onEnqueued: (() -> Void)? + + // MARK: - Call Tracking + + private let lock = NSLock() + private var _readCount = 0 + private var _totalBytesRead = 0 + private var _enqueueCount = 0 + + var readCount: Int { + lock.lock() + defer { lock.unlock() } + return _readCount + } + + var totalBytesRead: Int { + lock.lock() + defer { lock.unlock() } + return _totalBytesRead + } + + var enqueueCount: Int { + lock.lock() + defer { lock.unlock() } + return _enqueueCount + } + + // MARK: - Initialization + + override init() { + super.init() + } + + /// Convenience initializer for tests that need an executor set immediately. + convenience init(executor: TokenExecutor) { + self.init() + self.tokenExecutor = executor + } + + // MARK: - PTYTaskDelegate + + func threadedReadTask(_ buffer: UnsafeMutablePointer, length: Int32) { + lock.lock() + _readCount += 1 + _totalBytesRead += Int(length) + let data = Data(bytes: buffer, count: Int(length)) + lock.unlock() + + onThreadedRead?(data) + + // Enqueue tokens to the executor (mimics PTYSession behavior) + if let executor = tokenExecutor { + // Add enough tokens to trigger backpressure change for verification + executor.addMultipleTokenArrays(count: 100, tokensPerArray: 5) + + lock.lock() + _enqueueCount += 1 + lock.unlock() + + onEnqueued?() + } + } + + func threadedTaskBrokenPipe() { + // Not used in pipeline tests + } + + func brokenPipe() { + // Not used in pipeline tests + } + + func tmuxClientWrite(_ data: Data) { + // Not used in pipeline tests + } + + func taskDiedImmediately() { + // Not used in pipeline tests + } + + func taskDiedWithError(_ error: String!) { + // Not used in pipeline tests + } + + func taskDidChangeTTY(_ task: PTYTask) { + // Not used in pipeline tests + } + + func taskDidRegister(_ task: PTYTask) { + // Not used in pipeline tests + } + + func taskDidChangePaused(_ task: PTYTask, paused: Bool) { + // Not used in pipeline tests + } + + func taskMuteCoprocessDidChange(_ task: PTYTask, hasMuteCoprocess: Bool) { + // Not used in pipeline tests + } + + func taskDidResize(to gridSize: VT100GridSize, pixelSize: NSSize) { + // Not used in pipeline tests + } + + func taskDidReadFromCoprocessWhileSSHIntegration(inUse data: Data) { + // Not used in pipeline tests + } + + // MARK: - Test Helpers + + func reset() { + lock.lock() + _readCount = 0 + _totalBytesRead = 0 + _enqueueCount = 0 + onThreadedRead = nil + onEnqueued = nil + lock.unlock() + } +} diff --git a/ModernTests/FairnessScheduler/Mocks/MockFairnessSchedulerExecutor.swift b/ModernTests/FairnessScheduler/Mocks/MockFairnessSchedulerExecutor.swift new file mode 100644 index 0000000000..aff87e7ad6 --- /dev/null +++ b/ModernTests/FairnessScheduler/Mocks/MockFairnessSchedulerExecutor.swift @@ -0,0 +1,85 @@ +// +// MockFairnessSchedulerExecutor.swift +// ModernTests +// +// Mock executor for testing FairnessScheduler in isolation. +// This simulates the TokenExecutor interface that FairnessScheduler will use. +// + +import Foundation +@testable import iTerm2SharedARC + +// TurnResult and FairnessSchedulerExecutor are defined in FairnessScheduler.swift + +/// Mock implementation for testing FairnessScheduler without real TokenExecutor. +final class MockFairnessSchedulerExecutor: FairnessSchedulerExecutor { + + // MARK: - Configuration + + /// The result to return from executeTurn. Set this before each test scenario. + var turnResult: TurnResult = .completed + + /// If set, executeTurn calls this instead of using turnResult. + /// Useful for dynamic behavior or async testing. + var executeTurnHandler: ((Int, @escaping (TurnResult) -> Void) -> Void)? + + /// Delay before calling completion (simulates execution time) + var executionDelay: TimeInterval = 0 + + /// Whether cleanupForUnregistration was called + private(set) var cleanupCalled = false + + // MARK: - Call Tracking + + struct ExecuteTurnCall: Equatable { + let tokenBudget: Int + let timestamp: Date + + static func == (lhs: ExecuteTurnCall, rhs: ExecuteTurnCall) -> Bool { + return lhs.tokenBudget == rhs.tokenBudget + } + } + + private(set) var executeTurnCalls: [ExecuteTurnCall] = [] + private(set) var totalTokenBudgetConsumed: Int = 0 + + // MARK: - FairnessSchedulerExecutor + + func executeTurn(tokenBudget: Int, completion: @escaping (TurnResult) -> Void) { + executeTurnCalls.append(ExecuteTurnCall(tokenBudget: tokenBudget, timestamp: Date())) + totalTokenBudgetConsumed += tokenBudget + + if let handler = executeTurnHandler { + handler(tokenBudget, completion) + return + } + + if executionDelay > 0 { + DispatchQueue.global().asyncAfter(deadline: .now() + executionDelay) { [turnResult] in + completion(turnResult) + } + } else { + completion(turnResult) + } + } + + func cleanupForUnregistration() { + cleanupCalled = true + } + + // MARK: - Test Helpers + + func reset() { + turnResult = .completed + executeTurnHandler = nil + executionDelay = 0 + cleanupCalled = false + executeTurnCalls = [] + totalTokenBudgetConsumed = 0 + } + + /// Returns the number of times executeTurn was called + var executeTurnCallCount: Int { + return executeTurnCalls.count + } +} diff --git a/ModernTests/FairnessScheduler/Mocks/MockPTYTaskDelegate.swift b/ModernTests/FairnessScheduler/Mocks/MockPTYTaskDelegate.swift new file mode 100644 index 0000000000..9bf9612c1c --- /dev/null +++ b/ModernTests/FairnessScheduler/Mocks/MockPTYTaskDelegate.swift @@ -0,0 +1,134 @@ +// +// MockPTYTaskDelegate.swift +// ModernTests +// +// Mock implementation of PTYTaskDelegate for testing dispatch source integration. +// Provides call tracking and callbacks for verifying read handler pipeline. +// + +import Foundation +@testable import iTerm2SharedARC + +/// Mock delegate for testing the read handler pipeline in PTYTask. +final class MockPTYTaskDelegate: NSObject, PTYTaskDelegate { + + // MARK: - Configuration + + /// Callback invoked when threadedReadTask is called. + /// Use this to fulfill expectations or capture data in tests. + var onThreadedRead: ((Data) -> Void)? + + // MARK: - Call Tracking + + private let lock = NSLock() + private var _readCallCount = 0 + private var _lastReadData: Data? + private var _brokenPipeCount = 0 + private var _threadedBrokenPipeCount = 0 + + var readCallCount: Int { + lock.lock() + defer { lock.unlock() } + return _readCallCount + } + + var lastReadData: Data? { + lock.lock() + defer { lock.unlock() } + return _lastReadData + } + + var brokenPipeCount: Int { + lock.lock() + defer { lock.unlock() } + return _brokenPipeCount + } + + var threadedBrokenPipeCount: Int { + lock.lock() + defer { lock.unlock() } + return _threadedBrokenPipeCount + } + + // MARK: - PTYTaskDelegate + + func threadedReadTask(_ buffer: UnsafeMutablePointer, length: Int32) { + lock.lock() + _readCallCount += 1 + let data = Data(bytes: buffer, count: Int(length)) + _lastReadData = data + lock.unlock() + + onThreadedRead?(data) + } + + func threadedTaskBrokenPipe() { + lock.lock() + _threadedBrokenPipeCount += 1 + lock.unlock() + } + + func brokenPipe() { + lock.lock() + _brokenPipeCount += 1 + lock.unlock() + } + + func tmuxClientWrite(_ data: Data) { + // Not used in these tests + } + + func taskDiedImmediately() { + // Not used in these tests + } + + func taskDiedWithError(_ error: String!) { + // Not used in these tests + } + + func taskDidChangeTTY(_ task: PTYTask) { + // Not used in these tests + } + + func taskDidRegister(_ task: PTYTask) { + // Not used in these tests + } + + func taskDidChangePaused(_ task: PTYTask, paused: Bool) { + // Not used in these tests + } + + func taskMuteCoprocessDidChange(_ task: PTYTask, hasMuteCoprocess: Bool) { + // Not used in these tests + } + + func taskDidResize(to gridSize: VT100GridSize, pixelSize: NSSize) { + // Not used in these tests + } + + func taskDidReadFromCoprocessWhileSSHIntegration(inUse data: Data) { + // Not used in these tests + } + + // MARK: - Test Helpers + + func reset() { + lock.lock() + _readCallCount = 0 + _lastReadData = nil + _brokenPipeCount = 0 + _threadedBrokenPipeCount = 0 + onThreadedRead = nil + lock.unlock() + } + + /// Thread-safe accessor for read count + func getReadCount() -> Int { + return readCallCount + } + + /// Thread-safe accessor for last read data + func getLastReadData() -> Data? { + return lastReadData + } +} diff --git a/ModernTests/FairnessScheduler/Mocks/MockSideEffectPerformer.swift b/ModernTests/FairnessScheduler/Mocks/MockSideEffectPerformer.swift new file mode 100644 index 0000000000..c24f8a7e90 --- /dev/null +++ b/ModernTests/FairnessScheduler/Mocks/MockSideEffectPerformer.swift @@ -0,0 +1,25 @@ +// +// MockSideEffectPerformer.swift +// ModernTests +// +// Mock implementation of VT100ScreenSideEffectPerforming for testing. +// Used to create VT100ScreenMutableState instances without a real PTYSession. +// + +import Foundation +@testable import iTerm2SharedARC + +/// Mock implementation of VT100ScreenSideEffectPerforming for testing VT100ScreenMutableState. +/// Returns nil for delegates which is acceptable for test scenarios (init/deinit don't use them). +@objc final class MockSideEffectPerformer: NSObject, VT100ScreenSideEffectPerforming { + + // MARK: - VT100ScreenSideEffectPerforming + + @objc func sideEffectPerformingScreenDelegate() -> (any VT100ScreenDelegate)! { + return nil + } + + @objc func sideEffectPerformingIntervalTreeObserver() -> (any iTermIntervalTreeObserver)! { + return nil + } +} diff --git a/ModernTests/FairnessScheduler/Mocks/MockTokenExecutorDelegate.swift b/ModernTests/FairnessScheduler/Mocks/MockTokenExecutorDelegate.swift new file mode 100644 index 0000000000..0383e97711 --- /dev/null +++ b/ModernTests/FairnessScheduler/Mocks/MockTokenExecutorDelegate.swift @@ -0,0 +1,170 @@ +// +// MockTokenExecutorDelegate.swift +// ModernTests +// +// Mock implementation of TokenExecutorDelegate for testing. +// Provides configurable behavior and call tracking for token execution tests. +// + +import Foundation +@testable import iTerm2SharedARC + +/// Mock implementation of TokenExecutorDelegate for testing. +final class MockTokenExecutorDelegate: NSObject, TokenExecutorDelegate { + + // MARK: - Configuration + + /// When true, tokenExecutorShouldQueueTokens() returns true (simulates paused/blocked state) + var shouldQueueTokens = false + + /// When true, tokenExecutorShouldDiscard() returns true + var shouldDiscardTokens = false + + /// Callback invoked when tokenExecutorWillExecuteTokens is called. + /// Use this to fulfill expectations in tests. + var onWillExecute: (() -> Void)? + + // MARK: - Call Tracking + + private let lock = NSLock() + private var _executedLengths: [(total: Int, excluding: Int, throughput: Int)] = [] + private var _syncCount = 0 + private var _willExecuteCount = 0 + private var _handledFlags: [Int64] = [] + + var executedLengths: [(total: Int, excluding: Int, throughput: Int)] { + lock.lock() + defer { lock.unlock() } + return _executedLengths + } + + var syncCount: Int { + lock.lock() + defer { lock.unlock() } + return _syncCount + } + + var willExecuteCount: Int { + lock.lock() + defer { lock.unlock() } + return _willExecuteCount + } + + var handledFlags: [Int64] { + lock.lock() + defer { lock.unlock() } + return _handledFlags + } + + // MARK: - TokenExecutorDelegate + + func tokenExecutorShouldQueueTokens() -> Bool { + return shouldQueueTokens + } + + func tokenExecutorShouldDiscard(token: VT100Token, highPriority: Bool) -> Bool { + return shouldDiscardTokens + } + + func tokenExecutorDidExecute(lengthTotal: Int, lengthExcludingInBandSignaling: Int, throughput: Int) { + lock.lock() + _executedLengths.append((lengthTotal, lengthExcludingInBandSignaling, throughput)) + lock.unlock() + } + + func tokenExecutorCursorCoordString() -> NSString { + return "(0,0)" as NSString + } + + func tokenExecutorSync() { + lock.lock() + _syncCount += 1 + lock.unlock() + } + + func tokenExecutorHandleSideEffectFlags(_ flags: Int64) { + lock.lock() + _handledFlags.append(flags) + lock.unlock() + } + + func tokenExecutorWillExecuteTokens() { + lock.lock() + _willExecuteCount += 1 + lock.unlock() + onWillExecute?() + } + + // MARK: - Test Helpers + + func reset() { + lock.lock() + shouldQueueTokens = false + shouldDiscardTokens = false + _executedLengths = [] + _syncCount = 0 + _willExecuteCount = 0 + _handledFlags = [] + onWillExecute = nil + lock.unlock() + } +} + +/// TokenExecutorDelegate that tracks execution order for ordering tests. +/// Provides callbacks when tokens are executed to verify priority ordering. +final class OrderTrackingTokenExecutorDelegate: NSObject, TokenExecutorDelegate { + + private let lock = NSLock() + private var _willExecuteCount = 0 + private var _totalExecutedLength = 0 + + /// Callback invoked with lengthTotal when tokenExecutorDidExecute is called + var onExecute: ((Int) -> Void)? + + var willExecuteCount: Int { + lock.lock() + defer { lock.unlock() } + return _willExecuteCount + } + + var totalExecutedLength: Int { + lock.lock() + defer { lock.unlock() } + return _totalExecutedLength + } + + // MARK: - TokenExecutorDelegate + + func tokenExecutorShouldQueueTokens() -> Bool { + return false // Allow execution + } + + func tokenExecutorShouldDiscard(token: VT100Token, highPriority: Bool) -> Bool { + return false // Don't discard + } + + func tokenExecutorDidExecute(lengthTotal: Int, lengthExcludingInBandSignaling: Int, throughput: Int) { + lock.lock() + _totalExecutedLength += lengthTotal + lock.unlock() + onExecute?(lengthTotal) + } + + func tokenExecutorCursorCoordString() -> NSString { + return "(0,0)" as NSString + } + + func tokenExecutorSync() { + // Not used in ordering tests + } + + func tokenExecutorHandleSideEffectFlags(_ flags: Int64) { + // Not used in ordering tests + } + + func tokenExecutorWillExecuteTokens() { + lock.lock() + _willExecuteCount += 1 + lock.unlock() + } +} diff --git a/ModernTests/FairnessScheduler/Mocks/SpyPTYTask.swift b/ModernTests/FairnessScheduler/Mocks/SpyPTYTask.swift new file mode 100644 index 0000000000..1e8b038c7d --- /dev/null +++ b/ModernTests/FairnessScheduler/Mocks/SpyPTYTask.swift @@ -0,0 +1,30 @@ +// +// SpyPTYTask.swift +// ModernTests +// +// Spy PTYTask that tracks updateReadSourceState calls. +// Used to verify that PTYSession correctly wires the backpressureReleaseHandler +// to call updateReadSourceState on the task. +// + +import Foundation +@testable import iTerm2SharedARC + +/// Spy PTYTask that tracks calls to updateReadSourceState. +/// Use this to verify that backpressure release correctly triggers read source updates. +@objc final class SpyPTYTask: PTYTask { + + /// Number of times updateReadSourceState was called + @objc private(set) var updateReadSourceStateCallCount: Int = 0 + + /// Tracks the call (does not call super to avoid dispatch source operations) + @objc override func updateReadSourceState() { + updateReadSourceStateCallCount += 1 + // Don't call super - we don't have a real dispatch source + } + + /// Reset call counts + @objc func resetSpyCounts() { + updateReadSourceStateCallCount = 0 + } +} diff --git a/ModernTests/FairnessScheduler/Mocks/SpyVT100Screen.swift b/ModernTests/FairnessScheduler/Mocks/SpyVT100Screen.swift new file mode 100644 index 0000000000..f55697cc1c --- /dev/null +++ b/ModernTests/FairnessScheduler/Mocks/SpyVT100Screen.swift @@ -0,0 +1,44 @@ +// +// SpyVT100Screen.swift +// ModernTests +// +// Spy VT100Screen that captures mutateAsynchronously blocks for testing PTYSession wiring. +// Used to verify that PTYSession methods like taskDidChangePaused and shortcutNavigationDidComplete +// correctly dispatch to the mutation queue and call scheduleTokenExecution. +// + +import Foundation +@testable import iTerm2SharedARC + +/// Type alias for the mutation block signature +typealias MutationBlock = (VT100Terminal?, VT100ScreenMutableState, (any VT100ScreenDelegate)?) -> Void + +/// Spy VT100Screen that captures blocks passed to mutateAsynchronously. +/// This allows tests to verify PTYSession wiring without running the full mutation queue. +@objc final class SpyVT100Screen: VT100Screen { + + /// The most recently captured mutation block + private(set) var capturedMutationBlock: MutationBlock? + + /// Number of times mutateAsynchronously was called + private(set) var mutateAsynchronouslyCallCount = 0 + + /// Captures the block instead of executing it asynchronously + @objc override func mutateAsynchronously(_ block: @escaping (VT100Terminal?, VT100ScreenMutableState, (any VT100ScreenDelegate)?) -> Void) { + mutateAsynchronouslyCallCount += 1 + capturedMutationBlock = block + } + + /// Execute the captured block with a controlled mutableState for testing + func executeCapturedBlock(with mutableState: VT100ScreenMutableState, + terminal: VT100Terminal? = nil, + delegate: (any VT100ScreenDelegate)? = nil) { + capturedMutationBlock?(terminal, mutableState, delegate) + } + + /// Reset the spy state + func reset() { + capturedMutationBlock = nil + mutateAsynchronouslyCallCount = 0 + } +} diff --git a/ModernTests/FairnessScheduler/Mocks/SpyVT100ScreenMutableState.swift b/ModernTests/FairnessScheduler/Mocks/SpyVT100ScreenMutableState.swift new file mode 100644 index 0000000000..abeb66929e --- /dev/null +++ b/ModernTests/FairnessScheduler/Mocks/SpyVT100ScreenMutableState.swift @@ -0,0 +1,30 @@ +// +// SpyVT100ScreenMutableState.swift +// ModernTests +// +// Spy VT100ScreenMutableState that tracks scheduleTokenExecution calls. +// Used to verify that PTYSession wiring correctly invokes scheduleTokenExecution +// when unpausing or completing shortcut navigation. +// + +import Foundation +@testable import iTerm2SharedARC + +/// Spy VT100ScreenMutableState that tracks calls to scheduleTokenExecution. +/// Use this to verify that PTYSession methods correctly re-kick the scheduler. +@objc final class SpyVT100ScreenMutableState: VT100ScreenMutableState { + + /// Number of times scheduleTokenExecution was called + @objc private(set) var scheduleTokenExecutionCallCount: Int = 0 + + /// Tracks the call and forwards to super + @objc override func scheduleTokenExecution() { + scheduleTokenExecutionCallCount += 1 + super.scheduleTokenExecution() + } + + /// Reset call counts + @objc func resetSpyCounts() { + scheduleTokenExecutionCallCount = 0 + } +} diff --git a/ModernTests/FairnessScheduler/PTYSessionWiringTests.swift b/ModernTests/FairnessScheduler/PTYSessionWiringTests.swift new file mode 100644 index 0000000000..4966dcf935 --- /dev/null +++ b/ModernTests/FairnessScheduler/PTYSessionWiringTests.swift @@ -0,0 +1,293 @@ +// +// PTYSessionWiringTests.swift +// ModernTests +// +// Tests that verify PTYSession correctly wires taskDidChangePaused and +// shortcutNavigationDidComplete to call mutateAsynchronously and scheduleTokenExecution. +// +// These tests address the gap identified in IntegrationTests.swift where tests +// were directly manipulating VT100ScreenMutableState rather than going through +// PTYSession. This ensures regressions in the actual PTYSession wiring are caught. +// + +import XCTest +@testable import iTerm2SharedARC + +/// Tests for PTYSession's wiring of pause/unpause and shortcut navigation to the scheduler. +final class PTYSessionWiringTests: XCTestCase { + + private var session: PTYSession! + private var spyScreen: SpyVT100Screen! + private var spyMutableState: SpyVT100ScreenMutableState! + private var performer: MockSideEffectPerformer! + + override func setUp() { + super.setUp() + performer = MockSideEffectPerformer() + spyScreen = SpyVT100Screen() + spyMutableState = SpyVT100ScreenMutableState(sideEffectPerformer: performer) + + // Create a PTYSession with synthetic=YES to avoid full initialization + session = PTYSession(synthetic: true) + + // Swap the screen with our spy + session.screen = spyScreen + } + + override func tearDown() { + // Cleanup + spyMutableState.terminalEnabled = false + waitForMutationQueue() + + session = nil + spyScreen = nil + spyMutableState = nil + performer = nil + super.tearDown() + } + + // MARK: - taskDidChangePaused Tests + + func testTaskDidChangePausedCallsMutateAsynchronously() { + // REQUIREMENT: taskDidChangePaused should call mutateAsynchronously on screen + spyScreen.reset() + + // Call the method under test + session.taskDidChangePaused(PTYTask(), paused: false) + + // Verify mutateAsynchronously was called + XCTAssertEqual(spyScreen.mutateAsynchronouslyCallCount, 1, + "taskDidChangePaused should call mutateAsynchronously exactly once") + XCTAssertNotNil(spyScreen.capturedMutationBlock, + "A mutation block should be captured") + } + + func testTaskDidChangePausedWithPausedTrueSetsTaskPaused() { + // REQUIREMENT: When paused=true, block sets mutableState.taskPaused = true + spyScreen.reset() + spyMutableState.taskPaused = false + spyMutableState.resetSpyCounts() + + // Call the method under test + session.taskDidChangePaused(PTYTask(), paused: true) + + // Execute the captured block with our spy mutableState + spyScreen.executeCapturedBlock(with: spyMutableState) + + // Verify taskPaused was set to true + XCTAssertTrue(spyMutableState.taskPaused, + "Block should set taskPaused = true when paused parameter is true") + + // Verify scheduleTokenExecution was NOT called when pausing + XCTAssertEqual(spyMutableState.scheduleTokenExecutionCallCount, 0, + "scheduleTokenExecution should NOT be called when pausing") + } + + func testTaskDidChangePausedWithPausedFalseSetsTaskPausedAndSchedulesExecution() { + // REQUIREMENT: When paused=false, block sets mutableState.taskPaused = false + // AND calls scheduleTokenExecution + spyScreen.reset() + spyMutableState.taskPaused = true // Start in paused state + spyMutableState.resetSpyCounts() + + // Call the method under test + session.taskDidChangePaused(PTYTask(), paused: false) + + // Execute the captured block with our spy mutableState + spyScreen.executeCapturedBlock(with: spyMutableState) + + // Verify taskPaused was set to false + XCTAssertFalse(spyMutableState.taskPaused, + "Block should set taskPaused = false when paused parameter is false") + + // Verify scheduleTokenExecution WAS called when unpausing + XCTAssertEqual(spyMutableState.scheduleTokenExecutionCallCount, 1, + "scheduleTokenExecution should be called exactly once when unpausing") + } + + // MARK: - shortcutNavigationDidComplete Tests + + func testShortcutNavigationDidCompleteCallsMutateAsynchronously() { + // REQUIREMENT: shortcutNavigationDidComplete should call mutateAsynchronously on screen + spyScreen.reset() + + // Call the method under test using performSelector since the protocol + // conformance (iTermShortcutNavigationModeHandlerDelegate) is in PTYSession+Private.h + // which can't be imported in the bridging header due to circular dependencies. + session.perform(NSSelectorFromString("shortcutNavigationDidComplete")) + + // Verify mutateAsynchronously was called + XCTAssertEqual(spyScreen.mutateAsynchronouslyCallCount, 1, + "shortcutNavigationDidComplete should call mutateAsynchronously exactly once") + XCTAssertNotNil(spyScreen.capturedMutationBlock, + "A mutation block should be captured") + } + + func testShortcutNavigationDidCompleteSetsShortcutNavigationModeAndSchedulesExecution() { + // REQUIREMENT: shortcutNavigationDidComplete block sets shortcutNavigationMode = false + // AND calls scheduleTokenExecution + spyScreen.reset() + spyMutableState.shortcutNavigationMode = true // Start in shortcut nav mode + spyMutableState.resetSpyCounts() + + // Call the method under test using performSelector + session.perform(NSSelectorFromString("shortcutNavigationDidComplete")) + + // Execute the captured block with our spy mutableState + spyScreen.executeCapturedBlock(with: spyMutableState) + + // Verify shortcutNavigationMode was set to false + XCTAssertFalse(spyMutableState.shortcutNavigationMode, + "Block should set shortcutNavigationMode = false") + + // Verify scheduleTokenExecution was called + XCTAssertEqual(spyMutableState.scheduleTokenExecutionCallCount, 1, + "scheduleTokenExecution should be called exactly once") + } + + // MARK: - Helpers + + private func waitForMutationQueue() { + let expectation = self.expectation(description: "Mutation queue drained") + iTermGCD.mutationQueue().async { + expectation.fulfill() + } + wait(for: [expectation], timeout: 5.0) + } +} + +// MARK: - Backpressure Wiring Tests + +/// Tests for PTYSession's wiring of backpressureReleaseHandler to PTYTask.updateReadSourceState. +/// This is critical for resuming reads after backpressure drops. +final class PTYSessionBackpressureWiringTests: XCTestCase { + + private var session: PTYSession! + private var realScreen: VT100Screen! + private var spyTask: SpyPTYTask! + + override func setUp() { + super.setUp() + + // Create a PTYSession with synthetic=YES to avoid full initialization + session = PTYSession(synthetic: true) + + // Use a real VT100Screen (not spy) because we need the real tokenExecutor + realScreen = VT100Screen() + + // Swap the screen + session.screen = realScreen + + // Create spy task + spyTask = SpyPTYTask() + } + + override func tearDown() { + // Cleanup - disable terminal to unregister from FairnessScheduler + realScreen.mutableState.terminalEnabled = false + waitForMutationQueue() + + session = nil + realScreen = nil + spyTask = nil + super.tearDown() + } + + // MARK: - taskDidRegister Backpressure Wiring Tests + + func testTaskDidRegisterWiresBackpressureReleaseHandler() { + // REQUIREMENT: taskDidRegister should set backpressureReleaseHandler on tokenExecutor + let executor = realScreen.mutableState.tokenExecutor + + // Before calling taskDidRegister, handler should be nil + XCTAssertNil(executor.backpressureReleaseHandler, + "Handler should be nil before taskDidRegister") + + // Call the method under test + session.taskDidRegister(spyTask) + + // After calling taskDidRegister, handler should be set + XCTAssertNotNil(executor.backpressureReleaseHandler, + "backpressureReleaseHandler should be set after taskDidRegister") + } + + func testTaskDidRegisterSetsTokenExecutorOnTask() { + // REQUIREMENT: taskDidRegister sets task.tokenExecutor = executor + let executor = realScreen.mutableState.tokenExecutor + + XCTAssertNil(spyTask.tokenExecutor as AnyObject?, + "Task's tokenExecutor should be nil before taskDidRegister") + + // Call the method under test + session.taskDidRegister(spyTask) + + // Verify task has tokenExecutor set + XCTAssertTrue(spyTask.tokenExecutor === executor, + "Task's tokenExecutor should be set to screen's tokenExecutor") + } + + func testBackpressureReleaseHandlerCallsUpdateReadSourceState() { + // REQUIREMENT: When backpressureReleaseHandler is invoked, + // it should call updateReadSourceState on the task. + // This is the critical wiring that resumes reads after backpressure drops. + + let executor = realScreen.mutableState.tokenExecutor + + // Wire up via taskDidRegister + session.taskDidRegister(spyTask) + + // Verify handler is set + guard let handler = executor.backpressureReleaseHandler else { + XCTFail("backpressureReleaseHandler should be set") + return + } + + // Reset spy counts + spyTask.resetSpyCounts() + XCTAssertEqual(spyTask.updateReadSourceStateCallCount, 0, + "updateReadSourceState should not have been called yet") + + // Invoke the handler (simulating backpressure release) + handler() + + // Verify updateReadSourceState was called + XCTAssertEqual(spyTask.updateReadSourceStateCallCount, 1, + "backpressureReleaseHandler should call updateReadSourceState exactly once") + } + + func testBackpressureReleaseHandlerUsesWeakTask() { + // REQUIREMENT: The handler uses a weak reference to task to avoid retain cycles. + // If the task is deallocated, the handler should not crash. + + let executor = realScreen.mutableState.tokenExecutor + + // Create a task that will be deallocated + var temporaryTask: SpyPTYTask? = SpyPTYTask() + session.taskDidRegister(temporaryTask!) + + // Verify handler is set + guard let handler = executor.backpressureReleaseHandler else { + XCTFail("backpressureReleaseHandler should be set") + return + } + + // Deallocate the task + temporaryTask = nil + + // Invoking the handler after task deallocation should not crash + // (weak reference becomes nil, updateReadSourceState is not called) + handler() + + // If we reach here without crashing, the test passes + } + + // MARK: - Helpers + + private func waitForMutationQueue() { + let expectation = self.expectation(description: "Mutation queue drained") + iTermGCD.mutationQueue().async { + expectation.fulfill() + } + wait(for: [expectation], timeout: 5.0) + } +} diff --git a/ModernTests/FairnessScheduler/PTYTaskDispatchSourceBasicTests.swift b/ModernTests/FairnessScheduler/PTYTaskDispatchSourceBasicTests.swift new file mode 100644 index 0000000000..07353261a3 --- /dev/null +++ b/ModernTests/FairnessScheduler/PTYTaskDispatchSourceBasicTests.swift @@ -0,0 +1,425 @@ +// +// PTYTaskDispatchSourceBasicTests.swift +// ModernTests +// +// Basic dispatch source tests: lifecycle, protocol, state transitions. +// + +import XCTest +@testable import iTerm2SharedARC + +// MARK: - 3.1 Dispatch Source Lifecycle Tests + +/// Tests for dispatch source setup and teardown (3.1) +final class PTYTaskDispatchSourceLifecycleTests: XCTestCase { + + func testSetupCreatesSourcesWhenFdValid() throws { + // REQUIREMENT: setupDispatchSources creates read and write sources when fd is valid + try XCTSkipUnless(isDebugBuild, "Test requires ITERM_DEBUG hooks for dispatch source introspection") + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + // Create a pipe to provide a valid fd + guard let pipe = createTestPipe() else { + XCTFail("Failed to create test pipe") + return + } + defer { closeTestPipe(pipe) } + + // Set the fd to the read end of the pipe + task.testSetFd(pipe.readFd) + + #if ITERM_DEBUG + // Before setup, no sources should exist + XCTAssertFalse(task.testHasReadSource, "No read source before setup") + XCTAssertFalse(task.testHasWriteSource, "No write source before setup") + + // Setup dispatch sources + task.testSetupDispatchSourcesForTesting() + + // Wait for ioQueue to process (sources are created async on ioQueue) + task.testWaitForIOQueue() + + // After setup, both sources should exist + XCTAssertTrue(task.testHasReadSource, "Read source should be created") + XCTAssertTrue(task.testHasWriteSource, "Write source should be created") + + // Write source should be suspended (empty buffer, shouldWrite=false) + XCTAssertTrue(task.testIsWriteSourceSuspended, "Write source should start suspended (empty buffer)") + + // Read source state depends on shouldRead result: + // - Fresh task has paused=false + // - ioAllowed from jobManager may be true + // - No tokenExecutor means backpressure=none + // So read source may be resumed. The important behavior to test is that pausing suspends it. + + // Verify that pausing suspends the read source + task.paused = true + task.testWaitForIOQueue() + XCTAssertTrue(task.testIsReadSourceSuspended, "Read source should be suspended when paused") + + // Cleanup + task.testTeardownDispatchSourcesForTesting() + #endif + } + + func testTeardownCleansUpSources() throws { + // REQUIREMENT: teardownDispatchSources removes sources and cleans up state + try XCTSkipUnless(isDebugBuild, "Test requires ITERM_DEBUG hooks for dispatch source introspection") + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + // Create a pipe to provide a valid fd + guard let pipe = createTestPipe() else { + XCTFail("Failed to create test pipe") + return + } + defer { closeTestPipe(pipe) } + + task.testSetFd(pipe.readFd) + + #if ITERM_DEBUG + // Setup sources first + task.testSetupDispatchSourcesForTesting() + task.testWaitForIOQueue() + + XCTAssertTrue(task.testHasReadSource, "Read source should exist after setup") + XCTAssertTrue(task.testHasWriteSource, "Write source should exist after setup") + + // Teardown + task.testTeardownDispatchSourcesForTesting() + + // Wait for ioQueue to process teardown + task.testWaitForIOQueue() + + // After teardown, sources should be gone + XCTAssertFalse(task.testHasReadSource, "Read source should be nil after teardown") + XCTAssertFalse(task.testHasWriteSource, "Write source should be nil after teardown") + #endif + } + + func testUpdateMethodsExist() { + // REQUIREMENT: PTYTask must have updateReadSourceState and updateWriteSourceState methods + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + let readSelector = NSSelectorFromString("updateReadSourceState") + let writeSelector = NSSelectorFromString("updateWriteSourceState") + + XCTAssertTrue(task.responds(to: readSelector), + "PTYTask should have updateReadSourceState") + XCTAssertTrue(task.responds(to: writeSelector), + "PTYTask should have updateWriteSourceState") + } + + func testTeardownIsSafeWithoutSetup() { + // REQUIREMENT: Calling teardown without setup should not crash + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + #if ITERM_DEBUG + // Verify no sources exist before teardown + XCTAssertFalse(task.testHasReadSource, "No read source should exist before setup") + XCTAssertFalse(task.testHasWriteSource, "No write source should exist before setup") + #endif + + // This should not crash - sources were never created + let selector = NSSelectorFromString("teardownDispatchSources") + if task.responds(to: selector) { + task.perform(selector) + } + + #if ITERM_DEBUG + // Verify state remains valid after teardown + XCTAssertFalse(task.testHasReadSource, "No read source after teardown on fresh task") + XCTAssertFalse(task.testHasWriteSource, "No write source after teardown on fresh task") + #endif + + XCTAssertNotNil(task, "Task should remain valid after teardown") + } + + func testMultipleTeardownCallsSafe() { + // REQUIREMENT: Multiple teardown calls should be safe (idempotent) + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + let selector = NSSelectorFromString("teardownDispatchSources") + guard task.responds(to: selector) else { + XCTFail("PTYTask should respond to teardownDispatchSources") + return + } + + // Call teardown multiple times - should be idempotent + for i in 0..<5 { + task.perform(selector) + + #if ITERM_DEBUG + // After each teardown, state should be consistent + XCTAssertFalse(task.testHasReadSource, + "No read source after teardown \(i)") + XCTAssertFalse(task.testHasWriteSource, + "No write source after teardown \(i)") + #endif + } + + XCTAssertNotNil(task, "Task should remain valid after multiple teardowns") + } + + // MARK: - Gap 2: Teardown with Suspended Sources + + func testTeardownWithSuspendedReadSource() throws { + // GAP 2: Verify teardown doesn't crash when read source is suspended. + // dispatch_source_cancel on a suspended source crashes unless resumed first. + // The implementation must resume before canceling. + + try XCTSkipUnless(isDebugBuild, "Test requires ITERM_DEBUG hooks") + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + guard let pipe = createTestPipe() else { + XCTFail("Failed to create test pipe") + return + } + defer { closeTestPipe(pipe) } + + task.testSetFd(pipe.readFd) + + #if ITERM_DEBUG + task.testSetupDispatchSourcesForTesting() + task.testWaitForIOQueue() + + // Force read source to suspend by pausing + task.paused = true + task.testWaitForIOQueue() + XCTAssertTrue(task.testIsReadSourceSuspended, "Read source should be suspended when paused") + + // Teardown with suspended read source - should NOT crash + task.testTeardownDispatchSourcesForTesting() + + // If we get here, test passed (no crash) + XCTAssertFalse(task.testHasReadSource, "Read source should be nil after teardown") + #endif + } + + func testTeardownWithSuspendedWriteSource() throws { + // GAP 2: Verify teardown doesn't crash when write source is suspended. + // Write source starts suspended (empty buffer) and stays that way if no writes. + + try XCTSkipUnless(isDebugBuild, "Test requires ITERM_DEBUG hooks") + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + guard let pipe = createTestPipe() else { + XCTFail("Failed to create test pipe") + return + } + defer { closeTestPipe(pipe) } + + task.testSetFd(pipe.writeFd) + + #if ITERM_DEBUG + task.testSetupDispatchSourcesForTesting() + task.testWaitForIOQueue() + + // Write source starts suspended (empty buffer) + XCTAssertTrue(task.testIsWriteSourceSuspended, "Write source should be suspended with empty buffer") + + // Teardown with suspended write source - should NOT crash + task.testTeardownDispatchSourcesForTesting() + + // If we get here, test passed (no crash) + XCTAssertFalse(task.testHasWriteSource, "Write source should be nil after teardown") + #endif + } + + func testTeardownWithBothSourcesSuspended() throws { + // GAP 2: Verify teardown doesn't crash when BOTH sources are suspended. + // This is the worst case - both read and write sources need resume-before-cancel. + + try XCTSkipUnless(isDebugBuild, "Test requires ITERM_DEBUG hooks") + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + guard let pipe = createTestPipe() else { + XCTFail("Failed to create test pipe") + return + } + defer { closeTestPipe(pipe) } + + task.testSetFd(pipe.readFd) + + #if ITERM_DEBUG + task.testSetupDispatchSourcesForTesting() + task.testWaitForIOQueue() + + // Pause to suspend read source + task.paused = true + task.testWaitForIOQueue() + + // Verify both sources are suspended + XCTAssertTrue(task.testIsReadSourceSuspended, "Read source should be suspended") + XCTAssertTrue(task.testIsWriteSourceSuspended, "Write source should be suspended (empty buffer)") + + // Teardown with both sources suspended - should NOT crash + task.testTeardownDispatchSourcesForTesting() + + // If we get here, test passed (no crash) + XCTAssertFalse(task.testHasReadSource, "Read source should be nil after teardown") + XCTAssertFalse(task.testHasWriteSource, "Write source should be nil after teardown") + #endif + } +} + +final class PTYTaskUseDispatchSourceTests: XCTestCase { + + func testUseDispatchSourceMethodExists() { + // REQUIREMENT: PTYTask must respond to useDispatchSource + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + let selector = NSSelectorFromString("useDispatchSource") + XCTAssertTrue(task.responds(to: selector), + "PTYTask should respond to useDispatchSource") + } + + func testUseDispatchSourceReturnsTrue() { + // REQUIREMENT: PTYTask.useDispatchSource should return YES + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + // Get the value using KVC + if let result = task.value(forKey: "useDispatchSource") as? Bool { + XCTAssertTrue(result, "PTYTask.useDispatchSource should return YES") + } else { + XCTFail("Could not read useDispatchSource value") + } + } +} + +// MARK: - State Transition Tests + +/// Tests for state transition correctness +final class PTYTaskStateTransitionTests: XCTestCase { + + func testPauseUnpauseCycle() { + // REQUIREMENT: Pause/unpause cycle should be consistent + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + // Record initial state + let initialPaused = task.paused + + // Pause + task.paused = true + XCTAssertTrue(task.paused) + + // Verify shouldRead is false when paused + if let shouldRead = task.value(forKey: "shouldRead") as? Bool { + XCTAssertFalse(shouldRead, "shouldRead should be false when paused") + } + + // Unpause + task.paused = false + XCTAssertFalse(task.paused) + + // shouldRead may or may not be true (depends on other conditions) + // but it should not crash + + // Restore initial state + task.paused = initialPaused + } + + func testRapidPauseUnpauseCycle() { + // REQUIREMENT: Rapid pause/unpause should not cause issues + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + // Rapidly toggle pause state + for _ in 0..<100 { + task.paused = true + task.paused = false + } + + // Should complete without crash + XCTAssertFalse(task.paused, "Should end in unpaused state") + } + + func testUpdateMethodsIdempotent() { + // REQUIREMENT: Update methods should be safe to call multiple times + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + let readSelector = NSSelectorFromString("updateReadSourceState") + let writeSelector = NSSelectorFromString("updateWriteSourceState") + + guard task.responds(to: readSelector) && task.responds(to: writeSelector) else { + XCTFail("PTYTask should respond to update methods") + return + } + + #if ITERM_DEBUG + // Record initial state + let initialHasReadSource = task.testHasReadSource + let initialHasWriteSource = task.testHasWriteSource + #endif + + // Call update methods many times - should be idempotent + for _ in 0..<20 { + task.perform(readSelector) + task.perform(writeSelector) + } + + #if ITERM_DEBUG + // State should be unchanged after idempotent calls + XCTAssertEqual(task.testHasReadSource, initialHasReadSource, + "Read source state should remain stable") + XCTAssertEqual(task.testHasWriteSource, initialHasWriteSource, + "Write source state should remain stable") + #endif + + XCTAssertNotNil(task, "Multiple update calls should be safe") + } +} + +// MARK: - Edge Case Tests + +/// Tests for edge cases and error conditions diff --git a/ModernTests/FairnessScheduler/PTYTaskDispatchSourceHandlerTests.swift b/ModernTests/FairnessScheduler/PTYTaskDispatchSourceHandlerTests.swift new file mode 100644 index 0000000000..524ea34b14 --- /dev/null +++ b/ModernTests/FairnessScheduler/PTYTaskDispatchSourceHandlerTests.swift @@ -0,0 +1,820 @@ +// +// PTYTaskDispatchSourceHandlerTests.swift +// ModernTests +// +// Event handler tests: handleReadEvent, handleWriteEvent, pipelines. +// + +import XCTest +@testable import iTerm2SharedARC + +final class PTYTaskEventHandlerTests: XCTestCase { + + func testHandleReadEventMethodExists() { + // REQUIREMENT: PTYTask must have handleReadEvent method + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + let selector = NSSelectorFromString("handleReadEvent") + XCTAssertTrue(task.responds(to: selector), + "PTYTask should have handleReadEvent method") + } + + func testHandleWriteEventMethodExists() { + // REQUIREMENT: PTYTask must have handleWriteEvent method + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + let selector = NSSelectorFromString("handleWriteEvent") + XCTAssertTrue(task.responds(to: selector), + "PTYTask should have handleWriteEvent method") + } + + func testWriteBufferDidChangeWakesWriteSource() { + // REQUIREMENT: Adding data to write buffer should wake (resume) write source + // when conditions are favorable (not paused, ioAllowed, buffer has data) + // Uses testShouldWriteOverride to bypass jobManager.isReadOnly constraint + // + // NOTE: When the write source resumes on a valid fd, it may fire immediately + // and drain the buffer. This test verifies the shouldWrite predicate works + // correctly, and that the write mechanism is functional (buffer gets drained). + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + // Create a pipe for valid fd - use WRITE end for write source testing + guard let pipe = createTestPipe() else { + XCTFail("Failed to create test pipe") + return + } + defer { closeTestPipe(pipe) } + + // Set the WRITE fd (pipe.writeFd) for write source to work correctly + // The fd must be >= 0 for ioAllowed to return true + task.testSetFd(pipe.writeFd) + task.paused = false + + #if ITERM_DEBUG + // Enable write override to bypass jobManager.isReadOnly constraint + task.testShouldWriteOverride = true + + // Setup dispatch sources + task.testSetupDispatchSourcesForTesting() + task.testWaitForIOQueue() + + // Initially write source is suspended (empty buffer, shouldWrite=false) + XCTAssertTrue(task.testIsWriteSourceSuspended, "Write source should start suspended (empty buffer)") + XCTAssertFalse(task.testWriteBufferHasData, "Write buffer should be empty initially") + + // Add data to write buffer + let testData = "Hello".data(using: .utf8)! + task.testAppendData(toWriteBuffer: testData) + + // Verify buffer has data BEFORE triggering writeBufferDidChange + XCTAssertTrue(task.testWriteBufferHasData, "Write buffer should have data after append") + + // Verify shouldWrite is true BEFORE the dispatch source has a chance to drain + guard let shouldWriteBefore = task.value(forKey: "shouldWrite") as? Bool else { + XCTFail("Could not read shouldWrite") + return + } + XCTAssertTrue(shouldWriteBefore, + "shouldWrite should be true with override and data in buffer (before notification)") + + // Now call writeBufferDidChange to trigger the write source resume + task.perform(NSSelectorFromString("writeBufferDidChange")) + task.testWaitForIOQueue() + + // Wait for the dispatch source to fire and drain the buffer. + // Use iteration-based loop for determinism instead of wall-clock timeout. + var bufferDrained = false + for _ in 0..<100 { + task.testWaitForIOQueue() + if !task.testWriteBufferHasData { + bufferDrained = true + break + } + } + XCTAssertTrue(bufferDrained, + "Write buffer should be drained after write source fires") + + // Reset override + task.testShouldWriteOverride = false + + // Cleanup + task.testTeardownDispatchSourcesForTesting() + #else + XCTAssertTrue(task.responds(to: NSSelectorFromString("writeBufferDidChange")), + "PTYTask should have writeBufferDidChange method") + #endif + } + + func testWriteSourceResumesWhenBufferFills() { + // REQUIREMENT: Write source should resume when buffer transitions from empty to non-empty + // Uses testShouldWriteOverride to bypass jobManager.isReadOnly constraint + // + // NOTE: When write source resumes on a valid writable fd, it fires and drains buffer. + // This test verifies the shouldWrite predicate and confirms writes complete. + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + guard let pipe = createTestPipe() else { + XCTFail("Failed to create test pipe") + return + } + defer { closeTestPipe(pipe) } + + task.testSetFd(pipe.writeFd) + task.paused = false + + #if ITERM_DEBUG + // Enable write override to bypass jobManager.isReadOnly constraint + task.testShouldWriteOverride = true + + task.testSetupDispatchSourcesForTesting() + task.testWaitForIOQueue() + + // Verify initial state: empty buffer, write source suspended + XCTAssertFalse(task.testWriteBufferHasData, "Buffer should be empty initially") + XCTAssertTrue(task.testIsWriteSourceSuspended, "Write source should be suspended with empty buffer") + + // Fill buffer + let testData = "Test data for write source".data(using: .utf8)! + task.testAppendData(toWriteBuffer: testData) + XCTAssertTrue(task.testWriteBufferHasData, "Buffer should have data after append") + + // Check shouldWrite predicate BEFORE triggering notification + guard let shouldWrite = task.value(forKey: "shouldWrite") as? Bool else { + XCTFail("Could not read shouldWrite") + return + } + XCTAssertTrue(shouldWrite, "shouldWrite should be true with override and data in buffer") + + // Trigger write buffer change notification - this will resume write source + task.perform(NSSelectorFromString("writeBufferDidChange")) + task.testWaitForIOQueue() + + // Wait for the dispatch source to fire and drain the buffer. + // Use iteration-based loop for determinism instead of wall-clock timeout. + var bufferDrained = false + for _ in 0..<100 { + task.testWaitForIOQueue() + if !task.testWriteBufferHasData { + bufferDrained = true + break + } + } + XCTAssertTrue(bufferDrained, + "Buffer should be drained after write source fires (write completed)") + + // Reset override + task.testShouldWriteOverride = false + + task.testTeardownDispatchSourcesForTesting() + #else + XCTAssertTrue(task.responds(to: NSSelectorFromString("updateWriteSourceState"))) + #endif + } + + func testWriteSourceSuspendResumeCycleViaPause() { + // REQUIREMENT: Write source should suspend when paused and resume when unpaused + // This tests the pause -> unpause cycle for write source using a paused state + // to prevent the write from completing, allowing us to observe the resume. + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + guard let pipe = createTestPipe() else { + XCTFail("Failed to create test pipe") + return + } + defer { closeTestPipe(pipe) } + + task.testSetFd(pipe.writeFd) + + #if ITERM_DEBUG + // Enable write override to bypass jobManager.isReadOnly constraint + task.testShouldWriteOverride = true + + // Start PAUSED - this prevents writes from completing + task.paused = true + + task.testSetupDispatchSourcesForTesting() + task.testWaitForIOQueue() + + // Step 1: Start paused with empty buffer - write source should be SUSPENDED + XCTAssertFalse(task.testWriteBufferHasData, "Buffer should be empty initially") + XCTAssertTrue(task.testIsWriteSourceSuspended, "Write source should be SUSPENDED when paused") + + // Step 2: Add data to buffer while paused + let testData = "Data for resume test".data(using: .utf8)! + task.testAppendData(toWriteBuffer: testData) + XCTAssertTrue(task.testWriteBufferHasData, "Buffer should have data after append") + + // Trigger update - but since we're paused, write source should stay suspended + task.perform(NSSelectorFromString("writeBufferDidChange")) + task.testWaitForIOQueue() + + // shouldWrite should be false (paused) + if let shouldWrite = task.value(forKey: "shouldWrite") as? Bool { + XCTAssertFalse(shouldWrite, "shouldWrite should be false when paused") + } + XCTAssertTrue(task.testIsWriteSourceSuspended, "Write source should stay SUSPENDED when paused") + XCTAssertTrue(task.testWriteBufferHasData, "Buffer should still have data (no write occurred)") + + // Step 3: Unpause - write source should RESUME and then drain buffer + task.paused = false + task.perform(NSSelectorFromString("updateWriteSourceState")) + task.testWaitForIOQueue() + + // Wait for the dispatch source to fire and drain the buffer after unpause. + // Use iteration-based loop for determinism instead of wall-clock timeout. + var bufferDrained = false + for _ in 0..<100 { + task.testWaitForIOQueue() + if !task.testWriteBufferHasData { + bufferDrained = true + break + } + } + XCTAssertTrue(bufferDrained, + "Buffer should be drained after unpause triggers write") + + // Reset override + task.testShouldWriteOverride = false + + task.testTeardownDispatchSourcesForTesting() + #else + XCTAssertTrue(task.responds(to: NSSelectorFromString("updateWriteSourceState"))) + #endif + } + + func testProcessReadMethodExists() { + // REQUIREMENT: processRead is called by dispatch source - must exist + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + // processRead is part of the iTermTask protocol + XCTAssertTrue(task.responds(to: #selector(task.processRead)), + "PTYTask should have processRead method") + } + + func testProcessWriteMethodExists() { + // REQUIREMENT: processWrite is called by dispatch source - must exist + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + // processWrite is part of the iTermTask protocol + XCTAssertTrue(task.responds(to: #selector(task.processWrite)), + "PTYTask should have processWrite method") + } +} + +// MARK: - 3.5 Pause State Integration Tests + +/// Tests for pause state affecting behavior (3.5) +final class PTYTaskReadHandlerPipelineTests: XCTestCase { + + func testReadSourceTriggersThreadedReadTask() { + // REQUIREMENT: When data is available on fd, handleReadEvent should read it + // and call delegate's threadedReadTask with the data + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + guard let pipe = createTestPipe() else { + XCTFail("Failed to create test pipe") + return + } + defer { closeTestPipe(pipe) } + + // Create and set mock delegate + let mockDelegate = MockPTYTaskDelegate() + task.delegate = mockDelegate + + // Set the READ fd (data will be read from here) + task.testSetFd(pipe.readFd) + task.paused = false + + #if ITERM_DEBUG + // Set up expectation BEFORE any data flow + let readExpectation = XCTestExpectation(description: "threadedReadTask called") + mockDelegate.onThreadedRead = { _ in + readExpectation.fulfill() + } + + // Setup dispatch sources + task.testSetupDispatchSourcesForTesting() + task.testWaitForIOQueue() + + // Verify read source is active (not suspended) + XCTAssertFalse(task.testIsReadSourceSuspended, "Read source should be resumed") + + // Write data to the pipe (write end) - this should trigger the read source + let testMessage = "Hello from read handler test!" + let testData = testMessage.data(using: .utf8)! + let bytesWritten = testData.withUnsafeBytes { bufferPointer -> Int in + let rawPointer = bufferPointer.baseAddress! + return Darwin.write(pipe.writeFd, rawPointer, testData.count) + } + XCTAssertEqual(bytesWritten, testData.count, "Should write all bytes to pipe") + + // Wait for dispatch source to fire and process the read + wait(for: [readExpectation], timeout: 2.0) + + // Verify the delegate received the data + XCTAssertGreaterThan(mockDelegate.getReadCount(), 0, "threadedReadTask should be called") + + if let receivedData = mockDelegate.getLastReadData() { + let receivedString = String(data: receivedData, encoding: .utf8) + XCTAssertEqual(receivedString, testMessage, "Delegate should receive the written data") + } else { + XCTFail("Delegate should have received data") + } + + // Cleanup + task.testTeardownDispatchSourcesForTesting() + #else + // Non-debug build: basic verification + XCTAssertTrue(task.responds(to: NSSelectorFromString("handleReadEvent"))) + #endif + } + + func testReadHandlerDoesNotBlock() { + // REQUIREMENT: The read handler should complete quickly (not block on main thread operations) + // This test verifies the handler returns promptly by measuring elapsed time + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + guard let pipe = createTestPipe() else { + XCTFail("Failed to create test pipe") + return + } + defer { closeTestPipe(pipe) } + + let mockDelegate = MockPTYTaskDelegate() + task.delegate = mockDelegate + task.testSetFd(pipe.readFd) + task.paused = false + + #if ITERM_DEBUG + // Set up callback BEFORE starting sources to avoid race condition + let readExpectation = XCTestExpectation(description: "Quick read") + mockDelegate.onThreadedRead = { _ in + readExpectation.fulfill() + } + + task.testSetupDispatchSourcesForTesting() + task.testWaitForIOQueue() + + // Measure time from write to callback + let startTime = CFAbsoluteTimeGetCurrent() + + // Write data to trigger read + let testData = "Quick read test".data(using: .utf8)! + testData.withUnsafeBytes { bufferPointer in + let rawPointer = bufferPointer.baseAddress! + _ = Darwin.write(pipe.writeFd, rawPointer, testData.count) + } + + wait(for: [readExpectation], timeout: 2.0) + let elapsed = CFAbsoluteTimeGetCurrent() - startTime + + // Handler should complete quickly (much less than 1 second) + // If it were blocking on a semaphore or main thread sync, it would timeout + XCTAssertLessThan(elapsed, 0.5, "Read handler should complete quickly (not block)") + + task.testTeardownDispatchSourcesForTesting() + #else + XCTAssertTrue(task.responds(to: NSSelectorFromString("handleReadEvent"))) + #endif + } + + func testMultipleReadsAccumulate() { + // REQUIREMENT: Multiple reads should all be delivered to the delegate + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + guard let pipe = createTestPipe() else { + XCTFail("Failed to create test pipe") + return + } + defer { closeTestPipe(pipe) } + + let mockDelegate = MockPTYTaskDelegate() + task.delegate = mockDelegate + task.testSetFd(pipe.readFd) + task.paused = false + + #if ITERM_DEBUG + task.testSetupDispatchSourcesForTesting() + task.testWaitForIOQueue() + + // Track total data received + var totalReceived = Data() + let lock = NSLock() + + // Calculate expected total bytes + let messages = ["First", "Second", "Third"] + let expectedBytes = messages.reduce(0) { $0 + $1.data(using: .utf8)!.count } + + mockDelegate.onThreadedRead = { data in + lock.lock() + totalReceived.append(data) + lock.unlock() + } + + // Write multiple chunks of data + for msg in messages { + let data = msg.data(using: .utf8)! + data.withUnsafeBytes { bufferPointer in + let rawPointer = bufferPointer.baseAddress! + _ = Darwin.write(pipe.writeFd, rawPointer, data.count) + } + } + + // Wait for all bytes to be received using iteration-based loop + // The reads may be coalesced into fewer callbacks, but total data should match + var allDataReceived = false + for _ in 0..<200 { + task.testWaitForIOQueue() + lock.lock() + let received = totalReceived.count >= expectedBytes + lock.unlock() + if received { + allDataReceived = true + break + } + } + XCTAssertTrue(allDataReceived, "All data should be received") + + // Verify all data was received + lock.lock() + let receivedString = String(data: totalReceived, encoding: .utf8) ?? "" + lock.unlock() + + for msg in messages { + XCTAssertTrue(receivedString.contains(msg), "Should receive message: \(msg)") + } + + task.testTeardownDispatchSourcesForTesting() + #else + XCTAssertTrue(task.responds(to: NSSelectorFromString("handleReadEvent"))) + #endif + } + + func testReadPipelineEnqueuesToTokenExecutor() { + // REQUIREMENT: Full pipeline test - data on fd → read → parse → TokenExecutor enqueue + // This tests that the dispatch_source handler correctly reads data and the data + // flows through to the token processing pipeline. + // + // The test verifies: + // 1. Dispatch source reads data from fd + // 2. Handler calls delegate.threadedReadTask (non-blocking) + // 3. Delegate can enqueue tokens to TokenExecutor (mimicking PTYSession) + // 4. TokenExecutor receives the tokens (verified via backpressure change) + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + guard let pipe = createTestPipe() else { + XCTFail("Failed to create test pipe") + return + } + defer { closeTestPipe(pipe) } + + // Create a real VT100Terminal and TokenExecutor + let terminal = VT100Terminal() + let executor = TokenExecutor(terminal, slownessDetector: SlownessDetector(), queue: DispatchQueue.main) + + // Create a delegate that enqueues tokens when data is received (mimicking PTYSession) + let enqueuingDelegate = EnqueuingPTYTaskDelegate(executor: executor) + task.delegate = enqueuingDelegate + task.tokenExecutor = executor + + task.testSetFd(pipe.readFd) + task.paused = false + + #if ITERM_DEBUG + // Track initial backpressure level + let initialLevel = executor.backpressureLevel + + // Set up expectation for delegate call and token enqueue + let enqueueExpectation = XCTestExpectation(description: "Tokens enqueued") + enqueuingDelegate.onEnqueued = { + enqueueExpectation.fulfill() + } + + task.testSetupDispatchSourcesForTesting() + task.testWaitForIOQueue() + + XCTAssertFalse(task.testIsReadSourceSuspended, "Read source should be active") + + // Write data to the pipe - this should trigger the read source + let testData = "Test data for token pipeline".data(using: .utf8)! + testData.withUnsafeBytes { bufferPointer in + let rawPointer = bufferPointer.baseAddress! + _ = Darwin.write(pipe.writeFd, rawPointer, testData.count) + } + + // Wait for tokens to be enqueued + wait(for: [enqueueExpectation], timeout: 2.0) + + // Verify the full pipeline worked: + // 1. Delegate was called (handler didn't block - we got here within timeout) + XCTAssertGreaterThan(enqueuingDelegate.enqueueCount, 0, + "Delegate should have enqueued tokens") + + // 2. Tokens were actually added to executor + // The delegate adds enough tokens to change backpressure level + XCTAssertNotEqual(executor.backpressureLevel, initialLevel, + "TokenExecutor backpressure should change after enqueue") + + task.testTeardownDispatchSourcesForTesting() + #else + XCTAssertTrue(task.responds(to: NSSelectorFromString("handleReadEvent"))) + #endif + } +} + +// EnqueuingPTYTaskDelegate is defined in Mocks/EnqueuingPTYTaskDelegate.swift + +// MARK: - 3.8 Write Path Round-Trip Tests + +/// Tests that verify data written via writeTask: actually appears on the fd. +/// These tests exercise the complete write path, not just state changes. +final class PTYTaskWritePathRoundTripTests: XCTestCase { + + func testWriteTaskDataAppearsOnFd() throws { + // GAP 6: Verify the complete write path via dispatch source. + // This tests: writeTask: -> writeBuffer -> writeBufferDidChange -> updateWriteSourceState + // -> shouldWrite=YES -> write source resumes -> handleWriteEvent -> processWrite + // -> data written to fd + // + // If this test fails, the write path is broken (which matches the "typing doesn't work" bug). + + try XCTSkipUnless(isDebugBuild, "Test requires ITERM_DEBUG hooks") + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + // Create pipe: task writes to writeFd, we read from readFd + guard let pipe = createTestPipe() else { + XCTFail("Failed to create test pipe") + return + } + defer { closeTestPipe(pipe) } + + // Set the WRITE fd - this is what processWrite will write to + task.testSetFd(pipe.writeFd) + task.paused = false + + #if ITERM_DEBUG + // Enable ioAllowed override (necessary since we don't have a real process) + // but do NOT use testShouldWriteOverride - we want to test the real shouldWrite logic + task.testIoAllowedOverride = NSNumber(value: true) + + // Setup dispatch sources + task.testSetupDispatchSourcesForTesting() + defer { task.testTeardownDispatchSourcesForTesting() } + task.testWaitForIOQueue() + + // Verify initial state + XCTAssertTrue(task.testIsWriteSourceSuspended, "Write source should start suspended (empty buffer)") + + // Use writeTask: - the public API that typing uses + let testMessage = "Hello from keyboard!" + let testData = testMessage.data(using: .utf8)! + task.write(testData) + + // Wait for the write to complete + // Use iteration-based loop, not wall-clock timeout + var bufferDrained = false + for _ in 0..<100 { + task.testWaitForIOQueue() + if !task.testWriteBufferHasData { + bufferDrained = true + break + } + } + XCTAssertTrue(bufferDrained, "Write buffer should be drained after write source fires") + + // NOW THE KEY ASSERTION: verify data actually appeared on the pipe + var readBuffer = [UInt8](repeating: 0, count: 256) + let bytesRead = Darwin.read(pipe.readFd, &readBuffer, readBuffer.count) + + XCTAssertGreaterThan(bytesRead, 0, "Should have read data from pipe") + if bytesRead > 0 { + let receivedData = Data(bytes: readBuffer, count: bytesRead) + let receivedString = String(data: receivedData, encoding: .utf8) + XCTAssertEqual(receivedString, testMessage, + "Data read from pipe should match what was written via writeTask:") + } + #endif + } + + func testWriteTaskWithoutIoAllowedDoesNotWrite() throws { + // Verify that shouldWrite returns false when ioAllowed is false, + // and data stays in buffer (not written to fd) + + try XCTSkipUnless(isDebugBuild, "Test requires ITERM_DEBUG hooks") + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + guard let pipe = createTestPipe() else { + XCTFail("Failed to create test pipe") + return + } + defer { closeTestPipe(pipe) } + + task.testSetFd(pipe.writeFd) + task.paused = false + + #if ITERM_DEBUG + // Set ioAllowed to FALSE - write should NOT happen + task.testIoAllowedOverride = NSNumber(value: false) + + task.testSetupDispatchSourcesForTesting() + defer { task.testTeardownDispatchSourcesForTesting() } + task.testWaitForIOQueue() + + // Use writeTask: + let testData = "Should not appear".data(using: .utf8)! + task.write(testData) + task.testWaitForIOQueue() + + // Buffer should still have data (not drained, because shouldWrite=false) + XCTAssertTrue(task.testWriteBufferHasData, + "Buffer should retain data when ioAllowed is false") + + // Pipe should be empty + var readBuffer = [UInt8](repeating: 0, count: 256) + let bytesRead = Darwin.read(pipe.readFd, &readBuffer, readBuffer.count) + XCTAssertEqual(bytesRead, -1, "Pipe should have no data (EAGAIN expected)") + XCTAssertEqual(errno, EAGAIN, "Read should return EAGAIN on empty non-blocking pipe") + #endif + } + + func testMultipleWriteTaskCallsAccumulate() throws { + // Verify multiple writeTask: calls accumulate in buffer and all get written + + try XCTSkipUnless(isDebugBuild, "Test requires ITERM_DEBUG hooks") + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + guard let pipe = createTestPipe() else { + XCTFail("Failed to create test pipe") + return + } + defer { closeTestPipe(pipe) } + + task.testSetFd(pipe.writeFd) + task.paused = false + + #if ITERM_DEBUG + task.testIoAllowedOverride = NSNumber(value: true) + + task.testSetupDispatchSourcesForTesting() + defer { task.testTeardownDispatchSourcesForTesting() } + task.testWaitForIOQueue() + + // Write multiple chunks + task.write("Hello ".data(using: .utf8)!) + task.write("World".data(using: .utf8)!) + task.write("!".data(using: .utf8)!) + + // Wait for all writes to complete + var bufferDrained = false + for _ in 0..<100 { + task.testWaitForIOQueue() + if !task.testWriteBufferHasData { + bufferDrained = true + break + } + } + XCTAssertTrue(bufferDrained, "All data should be written") + + // Read all data from pipe + var allData = Data() + var readBuffer = [UInt8](repeating: 0, count: 256) + while true { + let bytesRead = Darwin.read(pipe.readFd, &readBuffer, readBuffer.count) + if bytesRead <= 0 { break } + allData.append(contentsOf: readBuffer[0..= 0) which should be true. + // LegacyJobManager.isReadOnly returns NO. + // If this test fails, the bug is in job manager behavior. + + try XCTSkipUnless(isDebugBuild, "Test requires ITERM_DEBUG hooks") + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + guard let pipe = createTestPipe() else { + XCTFail("Failed to create test pipe") + return + } + defer { closeTestPipe(pipe) } + + // testSetFd creates LegacyJobManager and sets fd + task.testSetFd(pipe.writeFd) + task.paused = false + + #if ITERM_DEBUG + // NO OVERRIDES - use real job manager behavior + // Verify preconditions + let jobManager = task.value(forKey: "jobManager") + XCTAssertNotNil(jobManager, "Job manager should exist after testSetFd") + + // Check what shouldWrite returns BEFORE dispatch sources + if let shouldWriteBefore = task.value(forKey: "shouldWrite") as? Bool { + // shouldWrite should be false (no data in buffer yet) + XCTAssertFalse(shouldWriteBefore, "shouldWrite should be false with empty buffer") + } + + task.testSetupDispatchSourcesForTesting() + defer { task.testTeardownDispatchSourcesForTesting() } + task.testWaitForIOQueue() + + // Write data + let testMessage = "Real job manager test" + task.write(testMessage.data(using: .utf8)!) + + // Check shouldWrite AFTER adding data + if let shouldWriteAfter = task.value(forKey: "shouldWrite") as? Bool { + XCTAssertTrue(shouldWriteAfter, + "shouldWrite should be true after adding data (ioAllowed=\(task.value(forKey: "effectiveIoAllowed") ?? "nil"))") + } + + // Wait for write to complete + var bufferDrained = false + for _ in 0..<100 { + task.testWaitForIOQueue() + if !task.testWriteBufferHasData { + bufferDrained = true + break + } + } + XCTAssertTrue(bufferDrained, "Buffer should be drained with real job manager") + + // Verify data arrived + var readBuffer = [UInt8](repeating: 0, count: 256) + let bytesRead = Darwin.read(pipe.readFd, &readBuffer, readBuffer.count) + XCTAssertGreaterThan(bytesRead, 0, "Data should have been written to pipe") + if bytesRead > 0 { + let received = String(data: Data(bytes: readBuffer, count: bytesRead), encoding: .utf8) + XCTAssertEqual(received, testMessage, "Written data should match") + } + #endif + } +} diff --git a/ModernTests/FairnessScheduler/PTYTaskDispatchSourceIntegrationTests.swift b/ModernTests/FairnessScheduler/PTYTaskDispatchSourceIntegrationTests.swift new file mode 100644 index 0000000000..741c8e3b4d --- /dev/null +++ b/ModernTests/FairnessScheduler/PTYTaskDispatchSourceIntegrationTests.swift @@ -0,0 +1,464 @@ +// +// PTYTaskDispatchSourceIntegrationTests.swift +// ModernTests +// +// Integration and edge case tests for PTYTask dispatch sources. +// + +import XCTest +@testable import iTerm2SharedARC + +final class PTYTaskBackpressureIntegrationTests: XCTestCase { + + func testTokenExecutorPropertyExists() { + // REQUIREMENT: PTYTask must have tokenExecutor property for backpressure + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + // Verify tokenExecutor property exists and is settable + // Initial value should be nil + XCTAssertNil(task.tokenExecutor, "tokenExecutor should initially be nil") + + // Should be able to set it + let terminal = VT100Terminal() + let executor = TokenExecutor(terminal, slownessDetector: SlownessDetector(), queue: DispatchQueue.main) + task.tokenExecutor = executor + + XCTAssertNotNil(task.tokenExecutor, "tokenExecutor should be settable") + } + + func testBackpressureHeavyWithPositiveSlotsStillSuspendsReadSource() { + // REQUIREMENT: Heavy backpressure (ratio < 0.25, availableSlots > 0) should suspend read source + // This tests the "heavy but not blocked" cutoff: backpressureLevel < .heavy gate + // + // PTYTask.shouldRead checks: backpressureLevel < BackpressureLevelHeavy + // .heavy is NOT less than .heavy, so shouldRead=false when level is .heavy + // + // With totalSlots=40: + // - .heavy occurs at ratio < 0.25, meaning available < 10 + // - .blocked occurs at available <= 0 + // Adding 35 tokens leaves 5 available → ratio 0.125 → .heavy (not .blocked) + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + // Create a pipe for valid fd + guard let pipe = createTestPipe() else { + XCTFail("Failed to create test pipe") + return + } + defer { closeTestPipe(pipe) } + + task.testSetFd(pipe.readFd) + task.paused = false + + // Setup executor for backpressure tracking + let terminal = VT100Terminal() + let executor = TokenExecutor(terminal, slownessDetector: SlownessDetector(), queue: DispatchQueue.main) + task.tokenExecutor = executor + + XCTAssertEqual(executor.backpressureLevel, .none, + "Fresh executor should have no backpressure") + + #if ITERM_DEBUG + // Setup dispatch sources + task.testSetupDispatchSourcesForTesting() + task.testWaitForIOQueue() + + // Create .heavy backpressure (NOT .blocked) by consuming most but not all slots + // With 40 slots, adding 35 leaves 5 available → ratio 0.125 → .heavy + executor.addMultipleTokenArrays(count: 35, tokensPerArray: 5) + + // Verify we're at .heavy (not .blocked) with positive available slots + XCTAssertEqual(executor.backpressureLevel, .heavy, + "Adding 35 tokens (of 40 slots) should cause .heavy backpressure") + XCTAssertGreaterThan(executor.testAvailableSlots, 0, + "Should have positive availableSlots (not blocked)") + + // Trigger state update + let selector = NSSelectorFromString("updateReadSourceState") + if task.responds(to: selector) { + task.perform(selector) + } + task.testWaitForIOQueue() + + // With .heavy backpressure, read source should be suspended + // because shouldRead requires backpressureLevel < .heavy + XCTAssertTrue(task.testIsReadSourceSuspended, + "Read source should be suspended at .heavy backpressure (even with availableSlots > 0)") + + // Cleanup + task.testTeardownDispatchSourcesForTesting() + #else + // Non-debug: basic verification + XCTAssertEqual(executor.backpressureLevel, .none, + "Fresh executor should have no backpressure") + #endif + } + + func testBackpressureBlockedSuspendsReadSource() { + // REQUIREMENT: Blocked backpressure (availableSlots <= 0) should suspend read source + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + // Create a pipe for valid fd + guard let pipe = createTestPipe() else { + XCTFail("Failed to create test pipe") + return + } + defer { closeTestPipe(pipe) } + + task.testSetFd(pipe.readFd) + task.paused = false + + // Setup executor for backpressure tracking + let terminal = VT100Terminal() + let executor = TokenExecutor(terminal, slownessDetector: SlownessDetector(), queue: DispatchQueue.main) + task.tokenExecutor = executor + + XCTAssertEqual(executor.backpressureLevel, .none, + "Fresh executor should have no backpressure") + + #if ITERM_DEBUG + // Setup dispatch sources + task.testSetupDispatchSourcesForTesting() + task.testWaitForIOQueue() + + // Create blocked backpressure by adding many token arrays (200 > 40 slots) + executor.addMultipleTokenArrays(count: 200, tokensPerArray: 5) + + // Check backpressure level - should be blocked when exceeding capacity + XCTAssertEqual(executor.backpressureLevel, .blocked, + "Adding more tokens than slots should cause blocked backpressure") + + // Trigger state update + let selector = NSSelectorFromString("updateReadSourceState") + if task.responds(to: selector) { + task.perform(selector) + } + task.testWaitForIOQueue() + + // With blocked backpressure, read source should be suspended + XCTAssertTrue(task.testIsReadSourceSuspended, + "Read source should be suspended with blocked backpressure") + + // Cleanup + task.testTeardownDispatchSourcesForTesting() + #else + // Non-debug: basic verification + XCTAssertEqual(executor.backpressureLevel, .none, + "Fresh executor should have no backpressure") + #endif + } + + func testBackpressureReleaseHandlerCanBeSet() { + // REQUIREMENT: TokenExecutor's backpressureReleaseHandler should be settable + + let terminal = VT100Terminal() + let executor = TokenExecutor(terminal, slownessDetector: SlownessDetector(), queue: DispatchQueue.main) + + var handlerCalled = false + executor.backpressureReleaseHandler = { + handlerCalled = true + } + + XCTAssertNotNil(executor.backpressureReleaseHandler, + "backpressureReleaseHandler should be settable") + } + + func testReadSourceResumesWhenBackpressureDrops() { + // REQUIREMENT: Read source should resume when backpressure drops from heavy to below heavy + // This tests the backpressure release -> read source resume path + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + guard let pipe = createTestPipe() else { + XCTFail("Failed to create test pipe") + return + } + defer { closeTestPipe(pipe) } + + task.testSetFd(pipe.readFd) + task.paused = false + + // Setup executor for backpressure tracking + let terminal = VT100Terminal() + let executor = TokenExecutor(terminal, slownessDetector: SlownessDetector(), queue: DispatchQueue.main) + task.tokenExecutor = executor + + #if ITERM_DEBUG + // Setup dispatch sources + task.testSetupDispatchSourcesForTesting() + task.testWaitForIOQueue() + + // Initially: no backpressure, fd valid, not paused -> read source should be resumed + XCTAssertEqual(executor.backpressureLevel, .none) + XCTAssertFalse(task.testIsReadSourceSuspended, "Read source should start resumed (no backpressure)") + + // Create blocked backpressure (200 tokens > 40 slots) + executor.addMultipleTokenArrays(count: 200, tokensPerArray: 5) + XCTAssertEqual(executor.backpressureLevel, .blocked, "Should be blocked when exceeding capacity") + + // Trigger state update + task.perform(NSSelectorFromString("updateReadSourceState")) + task.testWaitForIOQueue() + + // With blocked backpressure, read source should be suspended + XCTAssertTrue(task.testIsReadSourceSuspended, "Read source should suspend with blocked backpressure") + + // Set up handler to track heavy->non-heavy transition + // The handler fires once per token group consumed, but we only care that + // it fires at least once when transitioning from heavy to below heavy + var handlerFired = false + var wasHeavyWhenHandlerFired = false + executor.backpressureReleaseHandler = { [weak task, weak executor] in + if !handlerFired { + handlerFired = true + wasHeavyWhenHandlerFired = (executor?.backpressureLevel == .heavy) + } + // Handler should trigger read state re-evaluation + task?.perform(NSSelectorFromString("updateReadSourceState")) + } + + // Drain tokens by executing a turn with large budget + let drainExpectation = XCTestExpectation(description: "Tokens drained") + executor.executeTurn(tokenBudget: 10000) { result in + drainExpectation.fulfill() + } + wait(for: [drainExpectation], timeout: 2.0) + + // Give time for handler to fire and state to update + task.testWaitForIOQueue() + + // Backpressure should now be below heavy + XCTAssertNotEqual(executor.backpressureLevel, .heavy, + "Backpressure should drop after draining tokens") + + // Handler should have fired (at least once during the drain) + XCTAssertTrue(handlerFired, + "backpressureReleaseHandler should fire when backpressure drops") + + // Read source should have resumed + XCTAssertFalse(task.testIsReadSourceSuspended, + "Read source should RESUME after backpressure drops") + + task.testTeardownDispatchSourcesForTesting() + #else + XCTAssertNotNil(executor.backpressureReleaseHandler) + #endif + } + + func testHeavyBackpressureStopsDataFlow() { + // REQUIREMENT: When backpressure becomes heavy, the read source should be suspended + // AND data should actually stop being delivered to the delegate. + // This is the end-to-end verification that backpressure throttling works. + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + guard let pipe = createTestPipe() else { + XCTFail("Failed to create test pipe") + return + } + defer { closeTestPipe(pipe) } + + // Create mock delegate to track data flow + let mockDelegate = MockPTYTaskDelegate() + task.delegate = mockDelegate + + task.testSetFd(pipe.readFd) + task.paused = false + + // Setup executor for backpressure tracking + let terminal = VT100Terminal() + let executor = TokenExecutor(terminal, slownessDetector: SlownessDetector(), queue: DispatchQueue.main) + task.tokenExecutor = executor + + #if ITERM_DEBUG + // Set up expectation BEFORE starting anything + let readExpectation = XCTestExpectation(description: "Data read with no backpressure") + mockDelegate.onThreadedRead = { _ in + readExpectation.fulfill() + } + + // Setup dispatch sources + task.testSetupDispatchSourcesForTesting() + task.testWaitForIOQueue() + + XCTAssertEqual(executor.backpressureLevel, .none) + XCTAssertFalse(task.testIsReadSourceSuspended, "Read source should start resumed") + + // Step 1: Verify data flows when backpressure is low + let initialReadCount = mockDelegate.readCallCount + let testData1 = "Initial data flow test".data(using: .utf8)! + testData1.withUnsafeBytes { bufferPointer in + let rawPointer = bufferPointer.baseAddress! + _ = Darwin.write(pipe.writeFd, rawPointer, testData1.count) + } + + // Wait for data to be read + wait(for: [readExpectation], timeout: 2.0) + + XCTAssertGreaterThan(mockDelegate.readCallCount, initialReadCount, + "Data should flow when backpressure is low") + + // Clear the callback for next phase + mockDelegate.onThreadedRead = nil + + // Step 2: Create blocked backpressure (200 tokens > 40 slots) + executor.addMultipleTokenArrays(count: 200, tokensPerArray: 5) + XCTAssertEqual(executor.backpressureLevel, .blocked, "Should be blocked when exceeding capacity") + + // Trigger state update + task.perform(NSSelectorFromString("updateReadSourceState")) + task.testWaitForIOQueue() + + XCTAssertTrue(task.testIsReadSourceSuspended, "Read source should suspend with blocked backpressure") + + // Step 3: Write more data - it should NOT be delivered while suspended + let readCountBeforeWrite = mockDelegate.readCallCount + let testData2 = "Data during blocked backpressure".data(using: .utf8)! + testData2.withUnsafeBytes { bufferPointer in + let rawPointer = bufferPointer.baseAddress! + _ = Darwin.write(pipe.writeFd, rawPointer, testData2.count) + } + + // Flush queues to ensure any pending dispatch source events would have been processed + task.testWaitForIOQueue() + waitForMainQueue() + + // Data should NOT have been read (source is suspended) + XCTAssertEqual(mockDelegate.readCallCount, readCountBeforeWrite, + "Data should NOT be delivered when read source is suspended due to blocked backpressure") + + // Cleanup + task.testTeardownDispatchSourcesForTesting() + #else + // Non-debug build: basic verification + XCTAssertNotNil(task.tokenExecutor) + #endif + } +} + +// MARK: - 3.7 useDispatchSource Protocol Tests + +/// Tests for the useDispatchSource protocol method (3.7) +final class PTYTaskEdgeCaseTests: XCTestCase { + + func testFreshTaskHasValidState() { + // REQUIREMENT: Fresh PTYTask should have consistent initial state + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + // Fresh task should not be paused + XCTAssertFalse(task.paused, "Fresh task should not be paused") + + // Fresh task has fd = -1 (no process) + XCTAssertEqual(task.fd, -1, "Fresh task should have invalid fd") + + // Fresh task has no tokenExecutor + XCTAssertNil(task.tokenExecutor, "Fresh task should have nil tokenExecutor") + } + + func testTaskWithNilDelegate() { + // REQUIREMENT: Task should handle nil delegate gracefully + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + // Ensure delegate is nil + task.delegate = nil + XCTAssertNil(task.delegate, "Delegate should be nil for this test") + + // Operations should not crash with nil delegate + task.paused = true + XCTAssertTrue(task.paused, "Pause should work with nil delegate") + + task.paused = false + XCTAssertFalse(task.paused, "Unpause should work with nil delegate") + + // Verify shouldRead/shouldWrite don't crash with nil delegate + if let shouldRead = task.value(forKey: "shouldRead") as? Bool { + // With nil delegate and no job manager, shouldRead is likely false + // The important thing is it didn't crash + XCTAssertFalse(shouldRead, "shouldRead should be false without job manager") + } + + if let shouldWrite = task.value(forKey: "shouldWrite") as? Bool { + // With nil delegate and no buffer, shouldWrite should be false + XCTAssertFalse(shouldWrite, "shouldWrite should be false without job manager") + } + + // Update methods should be safe with nil delegate + let readSelector = NSSelectorFromString("updateReadSourceState") + let writeSelector = NSSelectorFromString("updateWriteSourceState") + + if task.responds(to: readSelector) { + task.perform(readSelector) + } + if task.responds(to: writeSelector) { + task.perform(writeSelector) + } + + #if ITERM_DEBUG + // State should be valid after operations + // No sources should have been created (no valid fd) + XCTAssertFalse(task.testHasReadSource, "No read source with nil delegate") + XCTAssertFalse(task.testHasWriteSource, "No write source with nil delegate") + #endif + + XCTAssertNotNil(task, "Task should remain valid with nil delegate") + } + + func testConcurrentPauseChanges() { + // REQUIREMENT: Concurrent pause changes should be thread-safe + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + let group = DispatchGroup() + + // Toggle pause from multiple threads + for _ in 0..<10 { + group.enter() + DispatchQueue.global().async { + for _ in 0..<100 { + task.paused = true + task.paused = false + } + group.leave() + } + } + + // No timeout - operations are bounded (10 threads × 100 iterations each) + // If there's a deadlock, test runner will kill the test + group.wait() + } +} + +// MockPTYTaskDelegate is defined in Mocks/MockPTYTaskDelegate.swift + +// MARK: - Read Handler Pipeline Tests + +/// Tests for the read handler pipeline (read → threadedReadTask) +/// These tests verify that data flows correctly from dispatch source to delegate diff --git a/ModernTests/FairnessScheduler/PTYTaskDispatchSourcePredicateTests.swift b/ModernTests/FairnessScheduler/PTYTaskDispatchSourcePredicateTests.swift new file mode 100644 index 0000000000..46d9dd988e --- /dev/null +++ b/ModernTests/FairnessScheduler/PTYTaskDispatchSourcePredicateTests.swift @@ -0,0 +1,950 @@ +// +// PTYTaskDispatchSourcePredicateTests.swift +// ModernTests +// +// State predicate tests: shouldRead, shouldWrite, pause, ioAllowed. +// + +import XCTest +@testable import iTerm2SharedARC + +// MARK: - 3.2 Unified State Check - Read Tests + +/// Tests for read state predicate behavior (3.2) +final class PTYTaskReadStateTests: XCTestCase { + + func testShouldReadMethodExists() { + // REQUIREMENT: PTYTask must have shouldRead method + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + let selector = NSSelectorFromString("shouldRead") + XCTAssertTrue(task.responds(to: selector), + "PTYTask should have shouldRead method") + } + + func testShouldReadFalseWhenPaused() { + // REQUIREMENT: shouldRead returns false when paused is true + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + // Set paused to true + task.paused = true + + // shouldRead should return false when paused + let selector = NSSelectorFromString("shouldRead") + if task.responds(to: selector) { + // Call shouldRead and check result + // For BOOL methods, we use value(forKey:) + let result = task.value(forKey: "shouldRead") as? Bool ?? true + XCTAssertFalse(result, "shouldRead should return false when paused") + } + } + + func testShouldReadChangesWithPauseState() { + // REQUIREMENT: shouldRead changes when pause state changes + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + // Initially not paused - shouldRead depends on other conditions too + // but paused=false is necessary (not sufficient) + task.paused = false + + // Now pause + task.paused = true + + // shouldRead must be false when paused + if let result = task.value(forKey: "shouldRead") as? Bool { + XCTAssertFalse(result, "shouldRead should be false when paused=true") + } + + // Unpause + task.paused = false + + // shouldRead being true also requires ioAllowed and backpressure < heavy + // We can only verify that pausing definitely makes it false + } + + func testUpdateReadSourceStateMethodExists() { + // REQUIREMENT: updateReadSourceState method must exist + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + let selector = NSSelectorFromString("updateReadSourceState") + XCTAssertTrue(task.responds(to: selector), + "PTYTask should have updateReadSourceState method") + } + + func testUpdateReadSourceStateSafeWithoutSources() { + // REQUIREMENT: Calling updateReadSourceState without dispatch sources should be safe + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + #if ITERM_DEBUG + // Before update, no sources exist + XCTAssertFalse(task.testHasReadSource, "No read source before update") + #endif + + // This should not crash even though sources don't exist + let selector = NSSelectorFromString("updateReadSourceState") + guard task.responds(to: selector) else { + XCTFail("PTYTask should respond to updateReadSourceState") + return + } + + // Call multiple times - should be no-op without sources + for _ in 0..<3 { + task.perform(selector) + } + + #if ITERM_DEBUG + // State should remain unchanged - no source created + XCTAssertFalse(task.testHasReadSource, + "updateReadSourceState should not create source") + #endif + + XCTAssertNotNil(task, "Task should remain valid after updateReadSourceState") + } +} + +// MARK: - 3.3 Unified State Check - Write Tests + +/// Tests for write state predicate behavior (3.3) +final class PTYTaskWriteStateTests: XCTestCase { + + func testShouldWriteMethodExists() { + // REQUIREMENT: PTYTask must have shouldWrite method + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + let selector = NSSelectorFromString("shouldWrite") + XCTAssertTrue(task.responds(to: selector), + "PTYTask should have shouldWrite method") + } + + func testShouldWriteFalseWhenPaused() { + // REQUIREMENT: shouldWrite returns false when paused is true + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + task.paused = true + + if let result = task.value(forKey: "shouldWrite") as? Bool { + XCTAssertFalse(result, "shouldWrite should return false when paused") + } + } + + func testShouldWriteFalseWhenBufferEmpty() { + // REQUIREMENT: shouldWrite returns false when write buffer is empty + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + // Fresh task has empty buffer + task.paused = false + + // shouldWrite should be false because buffer is empty + // (and also because jobManager may not be configured) + if let result = task.value(forKey: "shouldWrite") as? Bool { + XCTAssertFalse(result, + "shouldWrite should be false with empty buffer") + } + } + + func testShouldWriteOverrideProperty() { + // REQUIREMENT: testShouldWriteOverride should bypass jobManager constraints + // This tests that the override property is properly accessible from Swift + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + #if ITERM_DEBUG + // Initially override should be false + XCTAssertFalse(task.testShouldWriteOverride, "Override should initially be false") + + // Set override to true + task.testShouldWriteOverride = true + XCTAssertTrue(task.testShouldWriteOverride, "Override should be settable to true") + + // Reset override + task.testShouldWriteOverride = false + XCTAssertFalse(task.testShouldWriteOverride, "Override should be resettable to false") + + // Now test that override affects shouldWrite with buffer data + task.testShouldWriteOverride = true + task.paused = false + + // Add data to buffer + let testData = "Test data".data(using: .utf8)! + task.testAppendData(toWriteBuffer: testData) + + // Verify buffer has data + XCTAssertTrue(task.testWriteBufferHasData, "Buffer should have data after append") + + // With override and data, shouldWrite should be true + if let shouldWrite = task.value(forKey: "shouldWrite") as? Bool { + XCTAssertTrue(shouldWrite, "shouldWrite should be true with override and data in buffer") + } + + // Clean up + task.testShouldWriteOverride = false + #endif + } + + func testUpdateWriteSourceStateMethodExists() { + // REQUIREMENT: updateWriteSourceState method must exist + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + let selector = NSSelectorFromString("updateWriteSourceState") + XCTAssertTrue(task.responds(to: selector), + "PTYTask should have updateWriteSourceState method") + } + + func testUpdateWriteSourceStateSafeWithoutSources() { + // REQUIREMENT: Calling updateWriteSourceState without dispatch sources should be safe + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + #if ITERM_DEBUG + // Before update, no sources exist + XCTAssertFalse(task.testHasWriteSource, "No write source before update") + #endif + + let selector = NSSelectorFromString("updateWriteSourceState") + guard task.responds(to: selector) else { + XCTFail("PTYTask should respond to updateWriteSourceState") + return + } + + // Call multiple times - should be no-op without sources + for _ in 0..<3 { + task.perform(selector) + } + + #if ITERM_DEBUG + // State should remain unchanged - no source created + XCTAssertFalse(task.testHasWriteSource, + "updateWriteSourceState should not create source") + #endif + + XCTAssertNotNil(task, "Task should remain valid after updateWriteSourceState") + } +} + +// MARK: - 3.4 Event Handler Tests + +/// Tests for event handler method existence (3.4) +final class PTYTaskPauseStateTests: XCTestCase { + + func testPausedPropertyExists() { + // REQUIREMENT: PTYTask must have paused property + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + // Verify we can read and write the paused property + let initialPaused = task.paused + task.paused = !initialPaused + XCTAssertEqual(task.paused, !initialPaused, "paused property should be settable") + task.paused = initialPaused + XCTAssertEqual(task.paused, initialPaused, "paused property should round-trip") + } + + func testPauseAffectsShouldRead() { + // REQUIREMENT: Pausing should affect shouldRead result + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + // When paused, shouldRead must be false + task.paused = true + if let result = task.value(forKey: "shouldRead") as? Bool { + XCTAssertFalse(result, "shouldRead should be false when paused") + } + } + + func testPauseAffectsShouldWrite() { + // REQUIREMENT: Pausing should affect shouldWrite result + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + // When paused, shouldWrite must be false + task.paused = true + if let result = task.value(forKey: "shouldWrite") as? Bool { + XCTAssertFalse(result, "shouldWrite should be false when paused") + } + } + + func testSetPausedTogglesSourceSuspendState() { + // REQUIREMENT: Setting paused should toggle read source suspend state + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + // Create a pipe for valid fd + guard let pipe = createTestPipe() else { + XCTFail("Failed to create test pipe") + return + } + defer { closeTestPipe(pipe) } + + task.testSetFd(pipe.readFd) + + #if ITERM_DEBUG + // Set paused BEFORE setup to ensure sources start suspended + task.paused = true + + // Setup dispatch sources + task.testSetupDispatchSourcesForTesting() + task.testWaitForIOQueue() + + // With paused=true, read source should be suspended + XCTAssertTrue(task.testIsReadSourceSuspended, "Read source should be suspended when paused=true") + XCTAssertTrue(task.paused, "Task should be paused") + + // Set paused = false + task.paused = false + task.testWaitForIOQueue() + + // After unpause, source should resume (fd is valid so ioAllowed=true, no tokenExecutor so no backpressure) + XCTAssertFalse(task.paused, "Task should be unpaused") + XCTAssertFalse(task.testIsReadSourceSuspended, "Read source should RESUME after unpause with valid fd") + + // Now pause again - this should suspend the read source + task.paused = true + task.testWaitForIOQueue() + + // Read source should be suspended again + XCTAssertTrue(task.testIsReadSourceSuspended, "Read source should be suspended after re-pause") + + // Cleanup + task.testTeardownDispatchSourcesForTesting() + #else + // Non-debug: verify basic contract + task.paused = true + task.paused = false + task.paused = true + XCTAssertTrue(task.paused, "paused should be true after setting") + #endif + } + + func testReadSourceResumesAfterUnpause() { + // REQUIREMENT: Read source should resume when unpause makes shouldRead true + // This tests the full suspend -> resume cycle + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + guard let pipe = createTestPipe() else { + XCTFail("Failed to create test pipe") + return + } + defer { closeTestPipe(pipe) } + + task.testSetFd(pipe.readFd) + + #if ITERM_DEBUG + // Start unpaused, setup sources + task.paused = false + task.testSetupDispatchSourcesForTesting() + task.testWaitForIOQueue() + + // With valid fd and no pause, read source should be resumed + // (ioAllowed=true because fd>=0, no tokenExecutor means no backpressure check) + XCTAssertFalse(task.testIsReadSourceSuspended, "Read source should be resumed initially (favorable conditions)") + + // Pause - should suspend + task.paused = true + task.testWaitForIOQueue() + XCTAssertTrue(task.testIsReadSourceSuspended, "Read source should suspend on pause") + + // Unpause - should resume (this is the key resume test) + task.paused = false + task.testWaitForIOQueue() + XCTAssertFalse(task.testIsReadSourceSuspended, "Read source should RESUME after unpause") + + task.testTeardownDispatchSourcesForTesting() + #else + XCTAssertTrue(task.responds(to: NSSelectorFromString("updateReadSourceState"))) + #endif + } +} + +// MARK: - 3.5b ioAllowed Predicate Tests + +/// Tests for ioAllowed affecting shouldRead/shouldWrite predicates +/// These tests use testIoAllowedOverride to control the ioAllowed input +/// without needing a real jobManager or launched process. +final class PTYTaskIoAllowedPredicateTests: XCTestCase { + + func testIoAllowedOverridePropertyExists() { + // REQUIREMENT: PTYTask must have testIoAllowedOverride property for testing + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + #if ITERM_DEBUG + // Should be nil by default + XCTAssertNil(task.testIoAllowedOverride, "testIoAllowedOverride should be nil by default") + + // Should be settable to @YES + task.testIoAllowedOverride = NSNumber(value: true) + XCTAssertEqual(task.testIoAllowedOverride?.boolValue, true) + + // Should be settable to @NO + task.testIoAllowedOverride = NSNumber(value: false) + XCTAssertEqual(task.testIoAllowedOverride?.boolValue, false) + + // Should be resettable to nil + task.testIoAllowedOverride = nil + XCTAssertNil(task.testIoAllowedOverride) + #else + XCTAssertTrue(task.responds(to: NSSelectorFromString("testIoAllowedOverride"))) + #endif + } + + func testShouldReadFalseWhenIoAllowedFalse() { + // REQUIREMENT: shouldRead returns false when ioAllowed is false + // Per spec: shouldRead = !paused && ioAllowed && backpressure < heavy + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + #if ITERM_DEBUG + // Set up favorable conditions except ioAllowed + task.paused = false // not paused + // No tokenExecutor means no backpressure check + + // Force ioAllowed = false + task.testIoAllowedOverride = NSNumber(value: false) + + // shouldRead should be false because ioAllowed is false + if let result = task.value(forKey: "shouldRead") as? Bool { + XCTAssertFalse(result, "shouldRead should be false when ioAllowed=false") + } else { + XCTFail("Could not read shouldRead value") + } + #else + task.paused = true + if let result = task.value(forKey: "shouldRead") as? Bool { + XCTAssertFalse(result) + } + #endif + } + + func testShouldReadTrueWhenIoAllowedTrueAndOtherConditionsMet() { + // REQUIREMENT: shouldRead returns true when ioAllowed=true, paused=false, backpressure (task: MockTaskNotifierTask, writeFd: Int32)? { + var writeFd: Int32 = 0 + guard let task = MockTaskNotifierTask.createPipeTask(withWriteFd: &writeFd) else { + return nil + } + return (task, writeFd) +} + +// MARK: - 4.1 Dispatch Source Protocol Tests + +/// Tests for the useDispatchSource optional protocol method (4.1) +final class TaskNotifierDispatchSourceProtocolTests: XCTestCase { + + func testUseDispatchSourceOptionalMethod() throws { + // REQUIREMENT: useDispatchSource is @optional in iTermTask protocol + // This means tasks can implement it or not - default is NO (use select) + + // Verify PTYTask implements useDispatchSource + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + let selector = NSSelectorFromString("useDispatchSource") + XCTAssertTrue(task.responds(to: selector), + "PTYTask should respond to useDispatchSource") + } + + func testPTYTaskReturnsYesForUseDispatchSource() throws { + // REQUIREMENT: PTYTask returns YES for useDispatchSource + // This indicates PTYTask uses dispatch_source for I/O, not select() + + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + let selector = NSSelectorFromString("useDispatchSource") + XCTAssertTrue(task.responds(to: selector), + "PTYTask should respond to useDispatchSource") + + // Use value(forKey:) to call the getter + let value = task.value(forKey: "useDispatchSource") as? Bool + XCTAssertEqual(value, true, "PTYTask.useDispatchSource should return YES") + } + + func testRespondsToSelectorCheckUsed() throws { + // REQUIREMENT: TaskNotifier uses respondsToSelector: before calling useDispatchSource + // This ensures backward compatibility with tasks that don't implement the method + try XCTSkipUnless(isDebugBuild, "Test requires ITERM_DEBUG hooks for MockTaskNotifierTask") + + #if ITERM_DEBUG + // Create a mock task that simulates NOT implementing useDispatchSource + let mockTask = MockTaskNotifierTask() + mockTask.simulateLegacyTask = true + + // Verify the mock correctly simulates legacy behavior + XCTAssertFalse(mockTask.responds(to: NSSelectorFromString("useDispatchSource")), + "Legacy mock should not respond to useDispatchSource") + + // Now test with dispatch source enabled + let dispatchSourceTask = MockTaskNotifierTask() + dispatchSourceTask.dispatchSourceEnabled = true + XCTAssertTrue(dispatchSourceTask.responds(to: NSSelectorFromString("useDispatchSource")), + "Dispatch source mock should respond to useDispatchSource") + XCTAssertTrue(dispatchSourceTask.useDispatchSource(), + "Dispatch source mock should return YES") + #endif + } + + func testDefaultBehaviorIsSelectLoop() throws { + // REQUIREMENT: Tasks not implementing useDispatchSource use select() path + // This is the default/legacy behavior for backward compatibility + + #if ITERM_DEBUG + // Create mock with simulateLegacyTask = true (no useDispatchSource) + let mockTask = MockTaskNotifierTask() + mockTask.simulateLegacyTask = true + mockTask.dispatchSourceEnabled = false + + // Verify it doesn't respond to useDispatchSource + XCTAssertFalse(mockTask.responds(to: NSSelectorFromString("useDispatchSource")), + "Legacy task should not respond to useDispatchSource") + + // Default mock (without legacy flag) should respond but return NO + let defaultMock = MockTaskNotifierTask() + defaultMock.dispatchSourceEnabled = false + XCTAssertTrue(defaultMock.responds(to: NSSelectorFromString("useDispatchSource")), + "Default mock responds to useDispatchSource") + XCTAssertFalse(defaultMock.useDispatchSource(), + "Default mock returns NO for useDispatchSource") + #else + let notifier = TaskNotifier.sharedInstance() + XCTAssertNotNil(notifier, "TaskNotifier singleton should exist") + #endif + } +} + +// MARK: - 4.2 Select Loop Changes Tests + +/// Tests for TaskNotifier select() loop changes (4.2) +final class TaskNotifierSelectLoopTests: XCTestCase { + + func testDispatchSourceTaskSkipsFdSet() throws { + // REQUIREMENT: Tasks with useDispatchSource=YES are not added to fd_set + // Their I/O is handled by dispatch_source, not select() + + #if ITERM_DEBUG + // Create a mock task with useDispatchSource=YES and a real pipe FD + guard let (mockTask, writeFd) = createMockPipeTask() else { + XCTFail("Failed to create test pipe") + return + } + defer { + mockTask.closeFd() + close(writeFd) + } + + mockTask.dispatchSourceEnabled = true + mockTask.wantsRead = true + + // Register with TaskNotifier + let notifier = TaskNotifier.sharedInstance() + notifier?.register(mockTask) + defer { notifier?.deregister(mockTask) } + + // Wait for registration to complete (dispatched to main queue) + waitForMainQueue() + + // Reset call count after registration + mockTask.reset() + mockTask.dispatchSourceEnabled = true + mockTask.wantsRead = true + + // Write to the pipe to make data available + let testData = "test data for dispatch source task" + _ = testData.withCString { ptr in + Darwin.write(writeFd, ptr, strlen(ptr)) + } + + // Give TaskNotifier's select loop time to run by flushing queues multiple times. + // The select loop runs in its own thread, so we flush main queue several times + // to allow it to iterate. This is a negative test - we're verifying processRead + // is NOT called for dispatch source tasks. + for _ in 0..<5 { + waitForMainQueue() + } + + // Since useDispatchSource=YES, TaskNotifier should NOT call processRead + // (the task's main FD is skipped in fd_set) + XCTAssertEqual(mockTask.processReadCallCount, 0, + "Dispatch source task should NOT have processRead called by TaskNotifier's select() loop") + #else + // Fallback verification for non-debug builds + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + let usesDispatchSource = task.value(forKey: "useDispatchSource") as? Bool + XCTAssertEqual(usesDispatchSource, true, + "PTYTask should use dispatch source") + #endif + } + + func testLegacyTaskProcessReadCalledBySelect() throws { + // REQUIREMENT: Tasks NOT using dispatch source SHOULD have processRead called + + #if ITERM_DEBUG + // Create a mock task simulating legacy behavior (no useDispatchSource) + guard let (mockTask, writeFd) = createMockPipeTask() else { + XCTFail("Failed to create test pipe") + return + } + defer { + mockTask.closeFd() + close(writeFd) + } + + // Configure as legacy task - does not respond to useDispatchSource + mockTask.simulateLegacyTask = true + mockTask.wantsRead = true + + // Verify it doesn't respond to useDispatchSource + XCTAssertFalse(mockTask.responds(to: NSSelectorFromString("useDispatchSource")), + "Legacy task should not respond to useDispatchSource") + + // Register with TaskNotifier + let notifier = TaskNotifier.sharedInstance() + notifier?.register(mockTask) + defer { notifier?.deregister(mockTask) } + + // Wait for registration (dispatched to main queue) + waitForMainQueue() + + // Reset and re-configure after registration + let initialCount = mockTask.processReadCallCount + mockTask.simulateLegacyTask = true + mockTask.wantsRead = true + + // Write to the pipe to make data available + let testData = "legacy test data" + _ = testData.withCString { ptr in + Darwin.write(writeFd, ptr, strlen(ptr)) + } + + // Wait for TaskNotifier's select loop to process + let success = mockTask.wait(forProcessReadCalls: initialCount + 1, timeout: 2.0) + + // Legacy task SHOULD have processRead called by TaskNotifier + XCTAssertTrue(success, + "Legacy task (no useDispatchSource) SHOULD have processRead called via select()") + XCTAssertGreaterThan(mockTask.processReadCallCount, initialCount, + "processRead should have been called at least once") + #else + // Fallback for non-debug builds + let notifier = TaskNotifier.sharedInstance() + XCTAssertNotNil(notifier, "TaskNotifier should exist") + #endif + } + + // MARK: - Gap 1: processWrite Skip Test + + func testDispatchSourceTaskSkipsProcessWrite() throws { + // GAP 1: Verify TaskNotifier skips processWrite for dispatch source tasks. + // When useDispatchSource=YES, the task's write FD is NOT in select()'s wfds, + // so processWrite should never be called by TaskNotifier. + + #if ITERM_DEBUG + guard let (mockTask, writeFd) = createMockPipeTask() else { + XCTFail("Failed to create test pipe") + return + } + defer { + mockTask.closeFd() + close(writeFd) + } + + mockTask.dispatchSourceEnabled = true + mockTask.wantsWrite = true // Indicate buffer has data to write + + // Register with TaskNotifier + let notifier = TaskNotifier.sharedInstance() + notifier?.register(mockTask) + defer { notifier?.deregister(mockTask) } + + // Wait for registration to complete + waitForMainQueue() + + // Reset call count after registration + mockTask.reset() + mockTask.dispatchSourceEnabled = true + mockTask.wantsWrite = true + + // Unblock to wake select loop + notifier?.unblock() + + // Give TaskNotifier's select loop time to run + for _ in 0..<5 { + waitForMainQueue() + } + + // Since useDispatchSource=YES, TaskNotifier should NOT call processWrite + XCTAssertEqual(mockTask.processWriteCallCount, 0, + "Dispatch source task should NOT have processWrite called by TaskNotifier's select() loop") + #else + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + let usesDispatchSource = task.value(forKey: "useDispatchSource") as? Bool + XCTAssertEqual(usesDispatchSource, true, + "PTYTask should use dispatch source") + #endif + } + + func testLegacyTaskProcessWriteCalledBySelect() throws { + // GAP 1 (inverse): Verify legacy tasks DO have processWrite called. + + #if ITERM_DEBUG + guard let (mockTask, _) = createMockPipeTask() else { + XCTFail("Failed to create test pipe") + return + } + defer { mockTask.closeFd() } + + // Configure as legacy task + mockTask.simulateLegacyTask = true + mockTask.wantsWrite = true + + // Register with TaskNotifier + let notifier = TaskNotifier.sharedInstance() + notifier?.register(mockTask) + defer { notifier?.deregister(mockTask) } + + // Wait for registration + waitForMainQueue() + + let initialCount = mockTask.processWriteCallCount + mockTask.simulateLegacyTask = true + mockTask.wantsWrite = true + + // Unblock to wake select loop + notifier?.unblock() + + // Wait for processWrite to be called + // Use short timeout since this should happen quickly + var success = false + for _ in 0..<50 { + if mockTask.processWriteCallCount > initialCount { + success = true + break + } + Thread.sleep(forTimeInterval: 0.01) + } + + XCTAssertTrue(success, + "Legacy task SHOULD have processWrite called via select()") + #else + let notifier = TaskNotifier.sharedInstance() + XCTAssertNotNil(notifier, "TaskNotifier should exist") + #endif + } + + func testDispatchSourceTaskStillIteratedForCoprocess() throws { + // REQUIREMENT: Dispatch source tasks are still iterated for coprocess handling + // Even if PTY I/O is via dispatch_source, coprocess FDs need select() + // + // Coprocess FDs stay on select() while PTY FDs use dispatch_source. + // Data flow bridging handled by: + // - handleReadEvent calls writeToCoprocess (PTY output → coprocess) + // - writeTask:coprocess: calls writeBufferDidChange (coprocess output → PTY) + + #if ITERM_DEBUG + let mockTask = MockTaskNotifierTask() + mockTask.dispatchSourceEnabled = true + mockTask.hasCoprocess = true + + // Structural verification only + XCTAssertTrue(mockTask.useDispatchSource(), "Task should use dispatch source") + XCTAssertTrue(mockTask.hasCoprocess, "Task should have coprocess flag set") + #else + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + XCTAssertFalse(task.hasCoprocess, "New PTYTask should not have a coprocess") + #endif + } + + func testUnblockPipeStillInSelect() throws { + // REQUIREMENT: Unblock pipe remains in select() set + // The unblock pipe is used to wake select() on registration changes + + #if ITERM_DEBUG + // Create a mock task + let mockTask = MockTaskNotifierTask() + mockTask.dispatchSourceEnabled = true + mockTask.fd = -1 + + // Verify TaskNotifier has unblock method + let notifier = TaskNotifier.sharedInstance() + XCTAssertNotNil(notifier, "TaskNotifier should exist") + XCTAssertTrue(notifier!.responds(to: #selector(TaskNotifier.unblock)), + "TaskNotifier should have unblock method") + + // Register task - this should use the unblock pipe internally + notifier?.register(mockTask) + + // didRegister should be called on main queue (proves unblock worked) + waitForMainQueue() + + XCTAssertGreaterThan(mockTask.didRegisterCallCount, 0, + "didRegister should be called after registration (proves unblock pipe works)") + + // Cleanup + notifier?.deregister(mockTask) + #else + let notifier = TaskNotifier.sharedInstance() + XCTAssertNotNil(notifier, "TaskNotifier should exist") + XCTAssertTrue(notifier!.responds(to: #selector(TaskNotifier.unblock)), + "TaskNotifier should have unblock method") + #endif + } + + func testCoprocessFdsStillInSelect() throws { + // REQUIREMENT: Coprocess FDs remain in select() set + // Coprocess I/O stays on select() even when PTY uses dispatch_source + // + // The hybrid approach works because: + // - Coprocess FDs are non-blocking (O_NONBLOCK set in Coprocess.m) + // - Data flow is bridged at PTY I/O boundary + // - No blocking risks in TaskNotifier's select() loop + + #if ITERM_DEBUG + let mockTask = MockTaskNotifierTask() + mockTask.dispatchSourceEnabled = true + mockTask.hasCoprocess = true + + // Structural verification only + XCTAssertTrue(mockTask.hasCoprocess, "Task should have coprocess") + XCTAssertTrue(mockTask.useDispatchSource(), "Task should use dispatch source for main I/O") + #else + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + XCTAssertFalse(task.hasCoprocess, "New PTYTask should not have a coprocess") + #endif + } + + func testDeadpoolHandlingUnchanged() throws { + // REQUIREMENT: Deadpool/waitpid handling continues working + // Process reaping is independent of I/O mechanism (uses WNOHANG) + + #if ITERM_DEBUG + let mockTask = MockTaskNotifierTask() + mockTask.dispatchSourceEnabled = true + mockTask.pid = 0 + mockTask.pidToWaitOn = 0 + + // Verify TaskNotifier has waitForPid method + let notifier = TaskNotifier.sharedInstance() + XCTAssertNotNil(notifier, "TaskNotifier should exist") + XCTAssertTrue(notifier!.responds(to: #selector(TaskNotifier.wait(forPid:))), + "TaskNotifier should have waitForPid method") + #else + let notifier = TaskNotifier.sharedInstance() + XCTAssertNotNil(notifier, "TaskNotifier should exist") + XCTAssertTrue(notifier!.responds(to: #selector(TaskNotifier.wait(forPid:))), + "TaskNotifier should have waitForPid method") + #endif + } +} + +// MARK: - 4.3 Mixed Mode Operation Tests + +/// Tests for mixed dispatch_source and select() operation (4.3) +final class TaskNotifierMixedModeTests: XCTestCase { + + func testMixedDispatchSourceAndSelectTasks() throws { + // REQUIREMENT: System works with some tasks on dispatch_source, some on select() + // This enables gradual migration and coexistence + + #if ITERM_DEBUG + // Create two tasks: one with dispatch source, one legacy + guard let (legacyTask, legacyWriteFd) = createMockPipeTask() else { + XCTFail("Failed to create legacy pipe") + return + } + defer { + legacyTask.closeFd() + close(legacyWriteFd) + } + + guard let (dispatchTask, dispatchWriteFd) = createMockPipeTask() else { + XCTFail("Failed to create dispatch pipe") + return + } + defer { + dispatchTask.closeFd() + close(dispatchWriteFd) + } + + // Configure legacy task (uses select) + legacyTask.simulateLegacyTask = true + legacyTask.wantsRead = true + + // Configure dispatch source task (skips select) + dispatchTask.dispatchSourceEnabled = true + dispatchTask.wantsRead = true + + // Register both + let notifier = TaskNotifier.sharedInstance() + notifier?.register(legacyTask) + notifier?.register(dispatchTask) + defer { + notifier?.deregister(legacyTask) + notifier?.deregister(dispatchTask) + } + + // Wait for registration (dispatched to main queue) + waitForMainQueue() + + // Reset counts + legacyTask.reset() + legacyTask.simulateLegacyTask = true + legacyTask.wantsRead = true + dispatchTask.reset() + dispatchTask.dispatchSourceEnabled = true + dispatchTask.wantsRead = true + + // Write to both pipes + _ = "legacy data".withCString { ptr in Darwin.write(legacyWriteFd, ptr, strlen(ptr)) } + _ = "dispatch data".withCString { ptr in Darwin.write(dispatchWriteFd, ptr, strlen(ptr)) } + + // Wait for select loop + let success = legacyTask.wait(forProcessReadCalls: 1, timeout: 2.0) + + // Legacy task should have processRead called + XCTAssertTrue(success, "Legacy task should have processRead called") + XCTAssertGreaterThan(legacyTask.processReadCallCount, 0, + "Legacy task should be processed via select()") + + // Dispatch source task should NOT have processRead called by select + XCTAssertEqual(dispatchTask.processReadCallCount, 0, + "Dispatch source task should NOT be processed via select()") + #else + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + let usesDispatchSource = task.value(forKey: "useDispatchSource") as? Bool + XCTAssertEqual(usesDispatchSource, true, "PTYTask uses dispatch_source") + #endif + } + + func testTmuxTaskStaysOnSelect() throws { + // REQUIREMENT: Tmux tasks (fd < 0) continue using select() path + // Tmux tasks have no FD to add anyway, but they shouldn't be affected + + #if ITERM_DEBUG + // Create a mock task simulating a tmux task (fd < 0) + let mockTask = MockTaskNotifierTask() + mockTask.fd = -1 // Tmux tasks typically have fd = -1 + mockTask.dispatchSourceEnabled = false // Tmux doesn't use dispatch source + mockTask.simulateLegacyTask = true // Doesn't implement useDispatchSource + + // Verify configuration + XCTAssertEqual(mockTask.fd, -1, "Tmux task should have fd = -1") + XCTAssertFalse(mockTask.responds(to: NSSelectorFromString("useDispatchSource")), + "Legacy tmux task should not respond to useDispatchSource") + + // Register with TaskNotifier + let notifier = TaskNotifier.sharedInstance() + notifier?.register(mockTask) + + // Wait for registration to complete (dispatched to main queue) + waitForMainQueue() + + // Task should be registered without issue + XCTAssertGreaterThan(mockTask.didRegisterCallCount, 0, + "Tmux task should be registered successfully") + + // Cleanup + notifier?.deregister(mockTask) + #else + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + XCTAssertEqual(task.fd, -1, "Unlaunched PTYTask should have fd = -1") + #endif + } + + func testLegacyTasksUnaffected() throws { + // REQUIREMENT: Tasks not implementing useDispatchSource work unchanged + // Backward compatibility - existing conformers need no changes + + #if ITERM_DEBUG + // Create a mock task that simulates a legacy task (no useDispatchSource method) + guard let (mockTask, writeFd) = createMockPipeTask() else { + XCTFail("Failed to create test pipe") + return + } + defer { + mockTask.closeFd() + close(writeFd) + } + + // Configure as legacy task - does not respond to useDispatchSource + mockTask.simulateLegacyTask = true + mockTask.wantsRead = true + + // Verify it doesn't respond to useDispatchSource + XCTAssertFalse(mockTask.responds(to: NSSelectorFromString("useDispatchSource")), + "Legacy task should not respond to useDispatchSource") + + // Register with TaskNotifier + TaskNotifier.sharedInstance()?.register(mockTask) + defer { TaskNotifier.sharedInstance()?.deregister(mockTask) } + + // Wait for registration (dispatched to main queue) + waitForMainQueue() + + // Reset and reconfigure + let initialCount = mockTask.processReadCallCount + mockTask.simulateLegacyTask = true + mockTask.wantsRead = true + + // Write to the pipe to make data available + _ = "legacy test data".withCString { ptr in Darwin.write(writeFd, ptr, strlen(ptr)) } + + // Wait for TaskNotifier's select loop to process + let success = mockTask.wait(forProcessReadCalls: initialCount + 1, timeout: 2.0) + + // Legacy task should have processRead called by TaskNotifier + XCTAssertTrue(success, "Legacy task should have processRead called via select()") + XCTAssertGreaterThan(mockTask.processReadCallCount, initialCount, + "Legacy task should have processRead called via select()") + #else + let notifier = TaskNotifier.sharedInstance() + XCTAssertNotNil(notifier, "TaskNotifier should exist for legacy task support") + #endif + } + + func testCoprocessFdProcessedBySelect() throws { + // REQUIREMENT: Tasks with coprocess should be iterated for coprocess FD handling + // even when main FD uses dispatch_source. + // + // Hybrid approach: Coprocess FDs stay on select(), PTY FDs use dispatch_source. + // Data flow bridging ensures coprocess I/O works correctly: + // - PTY output → coprocess: handleReadEvent calls writeToCoprocess + // - Coprocess output → PTY: writeTask:coprocess: calls writeBufferDidChange + + #if ITERM_DEBUG + let mockTask = MockTaskNotifierTask() + mockTask.dispatchSourceEnabled = true + mockTask.hasCoprocess = true + + // Structural verification only + XCTAssertTrue(mockTask.dispatchSourceEnabled, "Task should use dispatch_source") + XCTAssertTrue(mockTask.hasCoprocess, "Task should have coprocess") + #else + // Non-debug build: basic verification + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + XCTAssertFalse(task.hasCoprocess, "New PTYTask should not have a coprocess") + #endif + } +} + +// MARK: - 4.4 Coprocess Data Flow Bridge Tests + +/// Tests for coprocess data flow bridging with dispatch_source PTY I/O. +/// These tests verify the bridge code paths are correctly wired: +/// - handleReadEvent calls writeToCoprocess (PTY output → coprocess) +/// - writeTask:coprocess: calls writeBufferDidChange (coprocess output → PTY) +final class CoprocessDataFlowBridgeTests: XCTestCase { + + func testHandleReadEventRoutesToCoprocess() throws { + // REQUIREMENT: handleReadEvent should call writeToCoprocess when coprocess is attached + // This tests that PTY output flows to coprocess.outputBuffer via the bridge + + #if ITERM_DEBUG + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + // Create pipe for PTY fd + guard let ptyPipe = createTestPipe() else { + XCTFail("Failed to create PTY test pipe") + return + } + defer { closeTestPipe(ptyPipe) } + + // Create MockCoprocess + guard let coprocess = MockCoprocess.createPipe() else { + XCTFail("Failed to create MockCoprocess") + return + } + defer { + coprocess.closeTestFds() + coprocess.terminate() + } + // Set up the PTYTask + task.testSetFd(ptyPipe.readFd) + task.paused = false + task.testIoAllowedOverride = NSNumber(value: true) + + // Attach coprocess to task + task.coprocess = coprocess + XCTAssertTrue(task.hasCoprocess, "Task should have coprocess attached") + + // Set up dispatch sources directly (not via TaskNotifier to avoid complexity) + task.testSetupDispatchSourcesForTesting() + defer { task.testTeardownDispatchSourcesForTesting() } + + // Verify setup + task.testWaitForIOQueue() + XCTAssertTrue(task.testHasReadSource, "Task should have read source") + XCTAssertFalse(task.testIsReadSourceSuspended, "Read source should be resumed (shouldRead=true)") + + // Write data to PTY pipe - this triggers handleReadEvent + let testMessage = "Hello coprocess!" + let testData = testMessage.data(using: .utf8)! + testData.withUnsafeBytes { bufferPointer in + let rawPointer = bufferPointer.baseAddress! + _ = Darwin.write(ptyPipe.writeFd, rawPointer, testData.count) + } + + // Wait for dispatch source to fire and handleReadEvent to run + // Multiple waits to ensure async processing completes + for _ in 0..<5 { + task.testWaitForIOQueue() + if coprocess.outputBuffer.length > 0 { break } + Thread.sleep(forTimeInterval: 0.01) + } + + // Verify data was routed to coprocess.outputBuffer + // The bridge code (writeToCoprocess) should have been called + XCTAssertGreaterThan(coprocess.outputBuffer.length, 0, + "handleReadEvent should route PTY data to coprocess.outputBuffer via writeToCoprocess bridge") + + let outputData = coprocess.outputBuffer as Data + if let outputString = String(data: outputData, encoding: .utf8) { + XCTAssertTrue(outputString.contains(testMessage), + "Coprocess outputBuffer should contain the PTY data") + } + #else + throw XCTSkip("Test requires ITERM_DEBUG build") + #endif + } + + func testWriteTaskTriggersWriteSource() throws { + // REQUIREMENT: writeTask should call writeBufferDidChange + // This tests that the write dispatch_source is triggered when data is added + + #if ITERM_DEBUG + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + // Create pipe for PTY fd + guard let ptyPipe = createTestPipe() else { + XCTFail("Failed to create PTY test pipe") + return + } + defer { closeTestPipe(ptyPipe) } + + // Set up the PTYTask + task.testSetFd(ptyPipe.writeFd) + task.paused = false + task.testShouldWriteOverride = true + defer { task.testShouldWriteOverride = false } + + // Set up dispatch sources directly + task.testSetupDispatchSourcesForTesting() + defer { task.testTeardownDispatchSourcesForTesting() } + + // Verify setup + task.testWaitForIOQueue() + XCTAssertTrue(task.testHasWriteSource, "Task should have write source") + XCTAssertFalse(task.testWriteBufferHasData, "Write buffer should start empty") + + // Write data via writeTask (this tests writeBufferDidChange is called) + let testMessage = "Hello PTY!" + let testData = testMessage.data(using: .utf8)! + task.write(testData) + + // NOTE: We cannot assert testWriteBufferHasData here because it's racy. + // The write dispatch source may have already drained the buffer to the pipe. + // Instead, we verify data appears on the pipe (below). + + // Wait for write source to drain the buffer to the pipe + task.testWaitForIOQueue() + + // Read from the PTY pipe to verify data was written + var buffer = [UInt8](repeating: 0, count: 1024) + let flags = fcntl(ptyPipe.readFd, F_GETFL) + fcntl(ptyPipe.readFd, F_SETFL, flags | O_NONBLOCK) + + var receivedData = Data() + for _ in 0..<10 { + task.testWaitForIOQueue() + let bytesRead = Darwin.read(ptyPipe.readFd, &buffer, buffer.count) + if bytesRead > 0 { + receivedData.append(contentsOf: buffer[0..= testData.count { break } + Thread.sleep(forTimeInterval: 0.01) + } + + XCTAssertEqual(receivedData.count, testData.count, + "writeBufferDidChange should trigger write source which drains to PTY fd") + + if let receivedString = String(data: receivedData, encoding: .utf8) { + XCTAssertEqual(receivedString, testMessage, + "PTY should receive the data") + } + #else + throw XCTSkip("Test requires ITERM_DEBUG build") + #endif + } + + func testCoprocessOutputRoutesToPTY() throws { + // REQUIREMENT: Coprocess output flows to PTY via writeTask:coprocess: + // This tests the coprocess → PTY direction of the data flow bridge + // + // Flow: writeTask:coprocess:YES → writeBuffer → writeBufferDidChange + // → write source → PTY fd + // + // Note: In production, TaskNotifier's select() reads from coprocess.inputFd + // and calls writeTask:coprocess:YES. Here we call it directly to test the bridge. + + #if ITERM_DEBUG + guard let task = PTYTask() else { + XCTFail("Failed to create PTYTask") + return + } + + // Create pipe for PTY fd (task writes here, we read to verify) + guard let ptyPipe = createTestPipe() else { + XCTFail("Failed to create PTY test pipe") + return + } + defer { closeTestPipe(ptyPipe) } + + // Set up the PTYTask with write fd + task.testSetFd(ptyPipe.writeFd) + task.paused = false + task.testIoAllowedOverride = NSNumber(value: true) + task.testShouldWriteOverride = true + defer { task.testShouldWriteOverride = false } + + // Set up dispatch sources + task.testSetupDispatchSourcesForTesting() + defer { task.testTeardownDispatchSourcesForTesting() } + + task.testWaitForIOQueue() + + // Simulate coprocess output by calling writeTask:coprocess:YES directly + // This is what TaskNotifier does when it reads from coprocess.inputFd + let testMessage = "From coprocess!" + let testData = testMessage.data(using: .utf8)! + task.testWrite(fromCoprocess: testData) + + // Wait for write source to drain buffer to PTY + task.testWaitForIOQueue() + + // Read from PTY pipe to verify data arrived + var receivedData = Data() + var buffer = [UInt8](repeating: 0, count: 1024) + + let flags = fcntl(ptyPipe.readFd, F_GETFL) + fcntl(ptyPipe.readFd, F_SETFL, flags | O_NONBLOCK) + + for _ in 0..<10 { + task.testWaitForIOQueue() + let bytesRead = Darwin.read(ptyPipe.readFd, &buffer, buffer.count) + if bytesRead > 0 { + receivedData.append(contentsOf: buffer[0..= testData.count { break } + Thread.sleep(forTimeInterval: 0.01) + } + + XCTAssertEqual(receivedData.count, testData.count, + "Coprocess output should flow to PTY via writeTask:coprocess: bridge") + + if let receivedString = String(data: receivedData, encoding: .utf8) { + XCTAssertEqual(receivedString, testMessage, + "PTY should receive the coprocess output") + } + #else + throw XCTSkip("Test requires ITERM_DEBUG build") + #endif + } + + // MARK: - Gap 3: TaskNotifier Coprocess Write FD Handling + + func testCoprocessWriteFdProcessedBySelect() throws { + // GAP 3: Verify TaskNotifier calls [coprocess write] when coprocess has outgoing data. + // The flow is: + // 1. Coprocess.outputBuffer has data (wantToWrite=YES) + // 2. TaskNotifier adds coprocess.writeFileDescriptor to select()'s wfds + // 3. When fd is writable, TaskNotifier calls [coprocess write] + // 4. Data flows from outputBuffer to the fd + // + // We verify by reading from MockCoprocess.testReadFd after the select() runs. + + #if ITERM_DEBUG + // Create a mock task with a pipe + guard let (mockTask, writeFd) = createMockPipeTask() else { + XCTFail("Failed to create mock pipe task") + return + } + defer { + mockTask.closeFd() + close(writeFd) + } + + // Configure as dispatch source task (the PTY I/O is handled by dispatch source, + // but coprocess FDs still go through select()) + mockTask.dispatchSourceEnabled = true + mockTask.hasCoprocess = true + mockTask.writeBufferHasRoom = true + + // Create MockCoprocess + guard let coprocess = MockCoprocess.createPipe() else { + XCTFail("Failed to create MockCoprocess") + return + } + defer { + coprocess.closeTestFds() + coprocess.terminate() + } + + // Attach coprocess to task + mockTask.coprocess = coprocess + + // Put data in coprocess.outputBuffer - this makes wantToWrite return YES + let testMessage = "Outgoing coprocess data!" + let testData = testMessage.data(using: .utf8)! + coprocess.outputBuffer.append(testData) + + // Verify wantToWrite is true before we register + XCTAssertTrue(coprocess.wantToWrite(), "Coprocess should wantToWrite when outputBuffer has data") + + // Register with TaskNotifier + let notifier = TaskNotifier.sharedInstance() + notifier?.register(mockTask) + defer { notifier?.deregister(mockTask) } + + // Wait for registration + waitForMainQueue() + + // Unblock to wake select loop + notifier?.unblock() + + // Wait for select() to process the coprocess write fd + // The data should be written from outputBuffer to writeFileDescriptor + var receivedData = Data() + var buffer = [UInt8](repeating: 0, count: 1024) + + // Make testReadFd non-blocking + let flags = fcntl(coprocess.testReadFd, F_GETFL) + fcntl(coprocess.testReadFd, F_SETFL, flags | O_NONBLOCK) + + // Poll for data with iteration-based waiting + for _ in 0..<50 { + waitForMainQueue() + let bytesRead = Darwin.read(coprocess.testReadFd, &buffer, buffer.count) + if bytesRead > 0 { + receivedData.append(contentsOf: buffer[0..= testData.count { break } + Thread.sleep(forTimeInterval: 0.01) + } + + XCTAssertEqual(receivedData.count, testData.count, + "TaskNotifier select() should call [coprocess write] draining outputBuffer to fd") + + if let receivedString = String(data: receivedData, encoding: .utf8) { + XCTAssertEqual(receivedString, testMessage, + "Data from coprocess.outputBuffer should appear on testReadFd") + } + #else + throw XCTSkip("Test requires ITERM_DEBUG build") + #endif + } +} diff --git a/ModernTests/FairnessScheduler/TestUtilities.swift b/ModernTests/FairnessScheduler/TestUtilities.swift new file mode 100644 index 0000000000..a7fce22621 --- /dev/null +++ b/ModernTests/FairnessScheduler/TestUtilities.swift @@ -0,0 +1,213 @@ +// +// TestUtilities.swift +// ModernTests +// +// Shared test utilities for fairness scheduler tests. +// Provides helper functions for creating test fixtures and synchronization. +// + +import XCTest +@testable import iTerm2SharedARC + +// MARK: - Debug Build Detection + +/// Indicates whether the test is running in a debug build with ITERM_DEBUG hooks available. +/// Tests that require ITERM_DEBUG-only APIs should use: +/// `try XCTSkipUnless(isDebugBuild, "Test requires ITERM_DEBUG hooks")` +/// This makes test requirements explicit rather than silently becoming no-ops. +let isDebugBuild: Bool = { + #if ITERM_DEBUG + return true + #else + return false + #endif +}() + +// MARK: - Token Vector Creation + +/// Creates a CVector containing test tokens. +/// - Parameter count: Number of tokens to create (minimum 1) +/// - Returns: A CVector containing VT100_UNKNOWNCHAR tokens +func createTestTokenVector(count: Int) -> CVector { + var vector = CVector() + CVectorCreate(&vector, Int32(max(count, 1))) + for _ in 0.. (vector: CVector, length: Int) { + let vector = createTestTokenVector(count: tokenCount) + let totalLength = tokenCount * bytesPerToken + return (vector, totalLength) +} + +// MARK: - Pipe Utilities + +/// Creates a non-blocking pipe for testing I/O operations. +/// - Returns: Tuple with read and write file descriptors, or nil on failure +func createTestPipe() -> (readFd: Int32, writeFd: Int32)? { + var fds: [Int32] = [0, 0] + guard pipe(&fds) == 0 else { return nil } + + // Set non-blocking on both ends + let readFlags = fcntl(fds[0], F_GETFL) + let writeFlags = fcntl(fds[1], F_GETFL) + _ = fcntl(fds[0], F_SETFL, readFlags | O_NONBLOCK) + _ = fcntl(fds[1], F_SETFL, writeFlags | O_NONBLOCK) + + return (fds[0], fds[1]) +} + +/// Closes both ends of a test pipe. +func closeTestPipe(_ fds: (readFd: Int32, writeFd: Int32)) { + close(fds.readFd) + close(fds.writeFd) +} + +/// Writes data to a file descriptor. +/// - Parameters: +/// - fd: File descriptor to write to +/// - data: String data to write +/// - Returns: Number of bytes written, or -1 on error +@discardableResult +func writeToFd(_ fd: Int32, data: String) -> Int { + return data.withCString { ptr in + Darwin.write(fd, ptr, strlen(ptr)) + } +} + +// MARK: - Queue Synchronization + +/// Waits for mutationQueue to process all pending work. +/// Use this to synchronize tests with async scheduler operations. +func waitForMutationQueue() { + iTermGCD.mutationQueue().sync {} +} + +/// Waits for mutationQueue with a timeout. +/// - Parameter timeout: Maximum time to wait +/// - Returns: true if completed within timeout, false if timed out +func waitForMutationQueue(timeout: TimeInterval) -> Bool { + let semaphore = DispatchSemaphore(value: 0) + iTermGCD.mutationQueue().async { + semaphore.signal() + } + return semaphore.wait(timeout: .now() + timeout) == .success +} + +/// Waits for main queue to process all pending work. +func waitForMainQueue() { + if Thread.isMainThread { + // Already on main, run a spin through the run loop + RunLoop.current.run(until: Date()) + } else { + DispatchQueue.main.sync {} + } +} + +#if ITERM_DEBUG +/// Waits for scheduler to become quiescent using iteration count instead of wall-clock time. +/// This is deterministic and avoids flaky timeout-based tests. +/// - Parameter maxIterations: Maximum number of queue sync iterations before giving up +/// - Returns: Number of iterations used, or -1 if max was hit without reaching quiescence +func waitForSchedulerQuiescence(maxIterations: Int = 100) -> Int { + return FairnessScheduler.shared.waitForQuiescenceIterative(maxIterations: maxIterations) +} +#endif + +// MARK: - XCTestCase Extensions + +extension XCTestCase { + + /// Creates an expectation that fulfills when the mutation queue processes a block. + func mutationQueueExpectation(description: String = "Mutation queue processed") -> XCTestExpectation { + let expectation = XCTestExpectation(description: description) + iTermGCD.mutationQueue().async { + expectation.fulfill() + } + return expectation + } + + /// Waits for a condition to become true, polling at intervals. + /// - Parameters: + /// - condition: Closure that returns true when condition is met + /// - timeout: Maximum time to wait + /// - pollInterval: Time between checks + /// - Returns: true if condition became true within timeout + func waitForCondition(_ condition: @escaping () -> Bool, + timeout: TimeInterval, + pollInterval: TimeInterval = 0.01) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if condition() { + return true + } + Thread.sleep(forTimeInterval: pollInterval) + } + return condition() + } +} + +// MARK: - FairnessScheduler Test Helpers + +#if ITERM_DEBUG +extension FairnessScheduler { + + /// Test helper: Wait for all scheduled executions to complete. + /// Polls busySessionCount until empty or timeout. + /// DEPRECATED: Use waitForQuiescenceIterative instead for deterministic tests. + func waitForQuiescence(timeout: TimeInterval) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if testBusySessionCount == 0 { + return true + } + Thread.sleep(forTimeInterval: 0.01) + } + return testBusySessionCount == 0 + } + + /// Test helper: Wait for scheduler quiescence using iteration count instead of wall-clock time. + /// - Parameter maxIterations: Maximum number of queue sync iterations before giving up + /// - Returns: Number of iterations used, or -1 if max was hit without reaching quiescence + func waitForQuiescenceIterative(maxIterations: Int = 100) -> Int { + var iterations = 0 + while testBusySessionCount > 0 && iterations < maxIterations { + waitForMutationQueue() + iterations += 1 + } + return testBusySessionCount == 0 ? iterations : -1 + } +} +#endif + +// MARK: - TokenExecutor Test Helpers + +extension TokenExecutor { + + /// Test helper: Add multiple token groups for testing backpressure. + /// - Parameters: + /// - count: Number of token arrays to add + /// - tokensPerArray: Tokens in each array + func addMultipleTokenArrays(count: Int, tokensPerArray: Int = 5) { + for _ in 0.. 40 buffer slots) - should never block + // even though consumption is blocked and we exceed capacity + for _ in 0..<100 { + let vector = createTestTokenVector(count: 10) + executor.addTokens(vector, lengthTotal: 100, lengthExcludingInBandSignaling: 100) + } + expectation.fulfill() + } + + // If addTokens blocks (old semaphore behavior), this will timeout. + // With non-blocking implementation, completes immediately despite + // blocked consumption and exceeded capacity. + wait(for: [expectation], timeout: 1.0) + + // Verify we actually exceeded capacity (proving the test conditions were met) + XCTAssertEqual(executor.backpressureLevel, .blocked, + "Should be at blocked backpressure after adding 100 tokens with consumption blocked") + } + + func testAddTokensDecrementsAvailableSlots() { + // REQUIREMENT: Each addTokens call must decrement availableSlots. + // This is needed for backpressure tracking. + + let initialLevel = executor.backpressureLevel + XCTAssertEqual(initialLevel, .none, "Fresh executor should have no backpressure") + + // Block token consumption so they accumulate + mockDelegate.shouldQueueTokens = true + + // With default bufferDepth of 40: + // - .none = >30 available (>75%) + // - .light = 20-30 available (50-75%) + // - .moderate = 10-20 available (25-50%) + // - .heavy = <10 available (<25%) + // Adding 11 tokens should move from .none to .light (29 remaining) + for _ in 0..<11 { + let vector = createTestTokenVector(count: 1) + executor.addTokens(vector, lengthTotal: 10, lengthExcludingInBandSignaling: 10) + } + + // Schedule to ensure pending work is processed (won't execute due to shouldQueueTokens) + executor.schedule() + + let afterLevel = executor.backpressureLevel + XCTAssertGreaterThan(afterLevel, initialLevel, + "Backpressure should increase after adding tokens without consumption") + XCTAssertGreaterThanOrEqual(afterLevel, .light, + "Adding 11 tokens should cause at least .light backpressure") + } + + func testHighPriorityTokensAlsoDecrementSlots() throws { + // REQUIREMENT: High-priority tokens must also count against availableSlots. + // This prevents API injection floods from overflowing the queue. + + let initialLevel = executor.backpressureLevel + XCTAssertEqual(initialLevel, .none, "Fresh executor should have no backpressure") + + // Block token consumption so they accumulate + mockDelegate.shouldQueueTokens = true + + // Add high-priority tokens (they also decrement availableSlots) + // With 40 total slots, adding 11+ should move from .none to .light + for _ in 0..<15 { + let vector = createTestTokenVector(count: 1) + executor.addTokens(vector, lengthTotal: 10, lengthExcludingInBandSignaling: 10, highPriority: true) + } + + // Schedule to ensure pending work is processed + executor.schedule() + + let afterLevel = executor.backpressureLevel + XCTAssertGreaterThan(afterLevel, initialLevel, + "Backpressure should increase after adding high-priority tokens") + XCTAssertGreaterThanOrEqual(afterLevel, .light, + "Adding 15 high-priority tokens should cause at least .light backpressure") + } + + // NEGATIVE TEST: Verify semaphore is NOT created after implementation + func testSemaphoreNotCreated() throws { + // REQUIREMENT: After Phase 2, no DispatchSemaphore should be created for token arrays. + // The semaphore-based blocking model is replaced by suspend/resume. + // + // Critical: We must BLOCK consumption to prove semaphores aren't used. + // Without this, tokens could drain fast enough that a semaphore-based + // implementation would never actually block. + + // Block token consumption so tokens accumulate + mockDelegate.shouldQueueTokens = true + + // Verify by checking that rapid token addition doesn't cause blocking behavior + // If semaphores were still in use, this would deadlock or timeout + let group = DispatchGroup() + + // Capture executor locally to prevent race with tearDown deallocation + let executor = self.executor! + + // Add 100 token arrays from concurrent threads (100 > 40 buffer slots) + // With blocked consumption, a semaphore-based implementation would deadlock + for _ in 0..<100 { + group.enter() + DispatchQueue.global().async { + let vector = createTestTokenVector(count: 5) + executor.addTokens(vector, lengthTotal: 50, lengthExcludingInBandSignaling: 50) + group.leave() + } + } + + // If semaphores were in use with blocked consumption, this would deadlock + // because semaphore.wait() would block waiting for permits that never come. + // Completion proves no blocking semaphores are used. + let result = group.wait(timeout: .now() + 2.0) + XCTAssertEqual(result, .success, + "Adding tokens should complete without blocking even with consumption blocked") + + // Verify we actually exceeded capacity + XCTAssertEqual(executor.backpressureLevel, .blocked, + "Should be at blocked backpressure after adding 100 tokens with consumption blocked") + } +} + +// MARK: - 2.2 Token Consumption Accounting Tests + +/// Tests for token consumption accounting correctness (2.2) +final class TokenExecutorAccountingTests: XCTestCase { + + var mockDelegate: MockTokenExecutorDelegate! + var mockTerminal: VT100Terminal! + var executor: TokenExecutor! + + override func setUp() { + super.setUp() + mockDelegate = MockTokenExecutorDelegate() + mockTerminal = VT100Terminal() + executor = TokenExecutor( + mockTerminal, + slownessDetector: SlownessDetector(), + queue: DispatchQueue.main + ) + executor.delegate = mockDelegate + } + + override func tearDown() { + executor = nil + mockTerminal = nil + mockDelegate = nil + super.tearDown() + } + + func testOnTokenArrayConsumedIncrementsSlots() { + // REQUIREMENT: When a TokenArray is fully consumed, availableSlots must increment. + + // Add and consume tokens + let vector = createTestTokenVector(count: 1) + executor.addTokens(vector, lengthTotal: 10, lengthExcludingInBandSignaling: 10) + executor.schedule() + + // Drain main queue to let execution complete + for _ in 0..<5 { + waitForMainQueue() + } + + XCTAssertEqual(executor.backpressureLevel, .none, + "Backpressure should return to none after consuming tokens") + } + + func testBackpressureReleaseHandlerCalled() throws { + // REQUIREMENT: backpressureReleaseHandler must be called when crossing from + // heavy backpressure to a lighter level. This triggers PTYTask to re-evaluate + // read source state. + // + // Test design: + // 1. Set up handler with thread-safe counter + // 2. Drive backpressure to heavy (add many tokens) + // 3. Register with scheduler and execute turns to consume tokens + // 4. Verify handler was called when crossing out of heavy + + // Thread-safe counter for handler calls + let handlerCallCount = MutableAtomicObject(0) + executor.backpressureReleaseHandler = { + _ = handlerCallCount.mutate { $0 + 1 } + } + + // Register with scheduler so executeTurn works properly + let sessionId = FairnessScheduler.shared.register(executor) + executor.fairnessSessionId = sessionId + defer { + FairnessScheduler.shared.unregister(sessionId: sessionId) + } + + // Step 1: Add enough tokens to reach heavy backpressure + // Heavy = < 25% slots available, so we need to consume > 75% of slots + // Default bufferDepth is 40, so we need > 30 token arrays + #if ITERM_DEBUG + let totalSlots = executor.testTotalSlots + let targetTokenArrays = Int(Double(totalSlots) * 0.80) // 80% to ensure heavy + #else + let targetTokenArrays = 35 // Safe default assuming 40 slots + #endif + + for _ in 0.. 10), should yield with more work pending + XCTAssertEqual(receivedResult, .yielded, + "Should yield because budget exceeded and more work remains") + } + + // NEGATIVE TEST: Second group should NOT execute if budget exceeded after first + func testSecondGroupSkippedWhenBudgetExceeded() throws { + // REQUIREMENT: After first group, if budget exceeded, yield to next session. + // This is the key "stop between groups" semantic. + // + // Strategy: Use high-priority and normal-priority tokens to create two + // guaranteed separate groups (they're in different queues). + + // Group 1: High-priority tokens (100 tokens, will exceed budget of 10) + let highPriVector = createTestTokenVector(count: 100) + executor.addTokens(highPriVector, lengthTotal: 1000, lengthExcludingInBandSignaling: 1000, highPriority: true) + + // Group 2: Normal-priority tokens (in separate queue = separate group) + let normalVector = createTestTokenVector(count: 50) + executor.addTokens(normalVector, lengthTotal: 500, lengthExcludingInBandSignaling: 500, highPriority: false) + + // Record execution count before first turn + let initialExecuteCount = mockDelegate.willExecuteCount + + let firstTurnExpectation = XCTestExpectation(description: "First turn completed") + var firstTurnResult: TurnResult? + executor.executeTurn(tokenBudget: 10) { result in + firstTurnResult = result + firstTurnExpectation.fulfill() + } + wait(for: [firstTurnExpectation], timeout: 1.0) + + let afterFirstTurnExecuteCount = mockDelegate.willExecuteCount + + // First turn should have executed exactly once (first group) + XCTAssertEqual(afterFirstTurnExecuteCount, initialExecuteCount + 1, + "First turn should execute only the first group") + XCTAssertEqual(firstTurnResult, .yielded, + "Should yield because second group is still pending") + + // Second turn should process the remaining group + let secondTurnExpectation = XCTestExpectation(description: "Second turn completed") + var secondTurnResult: TurnResult? + executor.executeTurn(tokenBudget: 500) { result in + secondTurnResult = result + secondTurnExpectation.fulfill() + } + wait(for: [secondTurnExpectation], timeout: 1.0) + + let afterSecondTurnExecuteCount = mockDelegate.willExecuteCount + + // Second turn should execute the remaining group + XCTAssertEqual(afterSecondTurnExecuteCount, afterFirstTurnExecuteCount + 1, + "Second turn should execute the remaining group") + XCTAssertEqual(secondTurnResult, .completed, + "Should complete after processing all remaining work") + } +} + +// MARK: - 2.5 Scheduler Entry Points Tests + +/// Tests for scheduler notification from all entry points (2.5) +/// +/// IMPORTANT: TokenExecutor's queue parameter must be the mutation queue. +/// - High-priority tokens: notifyScheduler() called synchronously (no async hop) +/// - Normal tokens: notifyScheduler() dispatched via queue.async +final class TokenExecutorSchedulerEntryPointTests: XCTestCase { + + var mockDelegate: MockTokenExecutorDelegate! + var mockTerminal: VT100Terminal! + var executor: TokenExecutor! + + override func setUp() { + super.setUp() + mockDelegate = MockTokenExecutorDelegate() + mockTerminal = VT100Terminal() + // CRITICAL: Use mutation queue, not main queue + // The executor dispatches scheduler notifications to this queue + executor = TokenExecutor( + mockTerminal, + slownessDetector: SlownessDetector(), + queue: iTermGCD.mutationQueue() + ) + executor.delegate = mockDelegate + } + + override func tearDown() { + executor = nil + mockTerminal = nil + mockDelegate = nil + super.tearDown() + } + + func testAddTokensNotifiesScheduler() throws { + // REQUIREMENT: addTokens() must call notifyScheduler() to kick FairnessScheduler. + + // Register executor with scheduler + let sessionId = FairnessScheduler.shared.register(executor) + executor.fairnessSessionId = sessionId + defer { FairnessScheduler.shared.unregister(sessionId: sessionId) } + + mockDelegate.shouldQueueTokens = false + + let originalCount = mockDelegate.executedLengths.count + + // Add tokens - this should notify scheduler + let vector = createTestTokenVector(count: 5) + executor.addTokens(vector, lengthTotal: 50, lengthExcludingInBandSignaling: 50) + + // Drain mutation queue to let execution complete + for _ in 0..<5 { + waitForMutationQueue() + } + + XCTAssertGreaterThan(mockDelegate.executedLengths.count, originalCount, + "Adding tokens should notify scheduler and trigger execution") + } + + func testScheduleNotifiesScheduler() throws { + // REQUIREMENT: schedule() must call notifyScheduler(). + + // Register executor with scheduler + let sessionId = FairnessScheduler.shared.register(executor) + executor.fairnessSessionId = sessionId + defer { FairnessScheduler.shared.unregister(sessionId: sessionId) } + + // Block execution initially + mockDelegate.shouldQueueTokens = true + + // Add tokens first + let vector = createTestTokenVector(count: 5) + executor.addTokens(vector, lengthTotal: 50, lengthExcludingInBandSignaling: 50) + + waitForMutationQueue() + + let initialCount = mockDelegate.executedLengths.count + + // Unblock and call schedule() - should notify scheduler + mockDelegate.shouldQueueTokens = false + executor.schedule() + + // Drain mutation queue to let execution complete + for _ in 0..<5 { + waitForMutationQueue() + } + + XCTAssertGreaterThan(mockDelegate.executedLengths.count, initialCount, + "schedule() should trigger execution via scheduler") + } + + func testScheduleHighPriorityTaskNotifiesScheduler() throws { + // REQUIREMENT: scheduleHighPriorityTask() must call notifyScheduler(). + + // Register executor with scheduler + let sessionId = FairnessScheduler.shared.register(executor) + executor.fairnessSessionId = sessionId + defer { FairnessScheduler.shared.unregister(sessionId: sessionId) } + + var taskExecuted = false + executor.scheduleHighPriorityTask { + taskExecuted = true + } + + // Drain mutation queue to let execution complete + for _ in 0..<5 { + waitForMutationQueue() + } + + XCTAssertTrue(taskExecuted, "scheduleHighPriorityTask should notify scheduler and execute") + } + + // NEGATIVE TEST: No duplicate notifications for already-busy session + func testNoDuplicateNotificationsForBusySession() throws { + // REQUIREMENT: If session already in busy list, don't add duplicate entry. + + // Register executor with scheduler + let sessionId = FairnessScheduler.shared.register(executor) + executor.fairnessSessionId = sessionId + defer { FairnessScheduler.shared.unregister(sessionId: sessionId) } + + mockDelegate.shouldQueueTokens = false + + // Add tokens multiple times rapidly + for _ in 0..<5 { + let vector = createTestTokenVector(count: 1) + executor.addTokens(vector, lengthTotal: 10, lengthExcludingInBandSignaling: 10) + } + + // Drain mutation queue to let all tokens be processed + for _ in 0..<10 { + waitForMutationQueue() + } + + let executionCount = mockDelegate.executedLengths.count + + // Should have processed tokens but not created duplicate busy list entries + // (verified by not having 5x the expected executions) + XCTAssertGreaterThan(executionCount, 0, "Tokens should be processed") + XCTAssertLessThanOrEqual(executionCount, 5, "Should not create duplicate busy list entries") + } + + func testHighPriorityAddTokensOnMutationQueueTriggersExecution() throws { + // REQUIREMENT: addTokens(highPriority: true) when called from mutation queue context + // should call notifyScheduler() synchronously (not via queue.async like normal tokens). + // This ensures the scheduler is notified without an extra async hop. + // + // The key difference vs normal priority: + // - High priority: reallyAddTokens() + notifyScheduler() - both synchronous + // - Normal priority: reallyAddTokens() + queue.async { notifyScheduler() } + + // Register executor with scheduler + let sessionId = FairnessScheduler.shared.register(executor) + executor.fairnessSessionId = sessionId + defer { FairnessScheduler.shared.unregister(sessionId: sessionId) } + + mockDelegate.shouldQueueTokens = false + + let initialExecuteCount = mockDelegate.executedLengths.count + + // Add high-priority tokens from mutation queue context + iTermGCD.mutationQueue().async { + let vector = createTestTokenVector(count: 1) + self.executor.addTokens(vector, lengthTotal: 10, lengthExcludingInBandSignaling: 10, highPriority: true) + } + + // Drain mutation queue to let execution complete + for _ in 0..<5 { + waitForMutationQueue() + } + + XCTAssertGreaterThan(mockDelegate.executedLengths.count, initialExecuteCount, + "High-priority tokens added from mutation queue should trigger execution") + } + + func testHighPriorityTokensNotifySchedulerSynchronously() throws { + // REQUIREMENT: Verify high-priority addTokens notifies scheduler without extra async hop. + // The scheduler is notified synchronously when high-priority tokens are added on + // mutation queue, vs normal tokens which dispatch notification via queue.async. + + // Register executor with scheduler + let sessionId = FairnessScheduler.shared.register(executor) + executor.fairnessSessionId = sessionId + defer { FairnessScheduler.shared.unregister(sessionId: sessionId) } + + mockDelegate.shouldQueueTokens = false + + var executionOccurred = false + mockDelegate.onWillExecute = { + executionOccurred = true + } + + // Add high-priority tokens from mutation queue + iTermGCD.mutationQueue().async { + let vector = createTestTokenVector(count: 1) + self.executor.addTokens(vector, lengthTotal: 10, lengthExcludingInBandSignaling: 10, highPriority: true) + } + + // Drain mutation queue to let execution complete + for _ in 0..<5 { + waitForMutationQueue() + } + + XCTAssertTrue(executionOccurred, + "High-priority tokens should trigger execution via scheduler") + } + + // MARK: - Gap 4: Cross-Queue addTokens Test + + func testAddTokensFromBackgroundQueueNotifiesScheduler() throws { + // GAP 4: Verify addTokens() notifies scheduler when called from a non-mutation queue. + // The implementation dispatches to queue.async { notifyScheduler() } for normal priority, + // so this tests that cross-queue calls still trigger execution. + + // Register executor with scheduler + let sessionId = FairnessScheduler.shared.register(executor) + executor.fairnessSessionId = sessionId + defer { FairnessScheduler.shared.unregister(sessionId: sessionId) } + + mockDelegate.shouldQueueTokens = false + + let initialCount = mockDelegate.executedLengths.count + + // Capture executor in local variable to prevent race with tearDown + // (background thread holds strong reference) + let capturedExecutor = self.executor! + + // Dispatch to a background queue (NOT the mutation queue) + let backgroundQueue = DispatchQueue(label: "test.background.queue") + let addCompleted = DispatchSemaphore(value: 0) + + backgroundQueue.async { + let vector = createTestTokenVector(count: 5) + capturedExecutor.addTokens(vector, lengthTotal: 50, lengthExcludingInBandSignaling: 50) + addCompleted.signal() + } + + // Wait for addTokens to complete + _ = addCompleted.wait(timeout: .now() + 1.0) + + // Drain mutation queue to let scheduler notification and execution complete + // Use iteration-based polling instead of wall-clock timeout + var success = false + for _ in 0..<20 { + waitForMutationQueue() + if mockDelegate.executedLengths.count > initialCount { + success = true + break + } + } + + XCTAssertTrue(success, + "addTokens from background queue should notify scheduler and trigger execution") + } + + func testAddTokensFromMultipleBackgroundQueuesAllExecute() throws { + // GAP 4 (extended): Multiple concurrent addTokens from different background queues + // should all result in scheduler notification and token execution. + + // Register executor with scheduler + let sessionId = FairnessScheduler.shared.register(executor) + executor.fairnessSessionId = sessionId + defer { FairnessScheduler.shared.unregister(sessionId: sessionId) } + + mockDelegate.shouldQueueTokens = false + + let initialCount = mockDelegate.executedLengths.count + + // Capture executor + let capturedExecutor = self.executor! + + // Create multiple background queues + let queue1 = DispatchQueue(label: "test.bg1") + let queue2 = DispatchQueue(label: "test.bg2") + let queue3 = DispatchQueue(label: "test.bg3") + + let group = DispatchGroup() + + // Add tokens from all three queues concurrently + for queue in [queue1, queue2, queue3] { + group.enter() + queue.async { + let vector = createTestTokenVector(count: 3) + capturedExecutor.addTokens(vector, lengthTotal: 30, lengthExcludingInBandSignaling: 30) + group.leave() + } + } + + // Wait for all addTokens calls to complete + _ = group.wait(timeout: .now() + 2.0) + + // Drain mutation queue to let all executions complete + var finalCount = 0 + for _ in 0..<30 { + waitForMutationQueue() + finalCount = mockDelegate.executedLengths.count + // We expect 3 batches of tokens to be added and eventually executed + if finalCount >= initialCount + 3 { + break + } + } + + // All tokens from all queues should eventually be executed + // At minimum, we should see more executions than before + XCTAssertGreaterThan(finalCount, initialCount, + "Tokens from multiple background queues should all be executed") + } +} + +// MARK: - 2.6 Legacy Removal Tests + +/// Tests verifying legacy foreground preemption code is removed (2.6) +final class TokenExecutorLegacyRemovalTests: XCTestCase { + + var mockDelegate: MockTokenExecutorDelegate! + var mockTerminal: VT100Terminal! + + override func setUp() { + super.setUp() + mockDelegate = MockTokenExecutorDelegate() + mockTerminal = VT100Terminal() + } + + override func tearDown() { + mockTerminal = nil + mockDelegate = nil + super.tearDown() + } + + func testActiveSessionsWithTokensRemoved() throws { + // REQUIREMENT: The static activeSessionsWithTokens set must be removed. + // FairnessScheduler replaces this ad-hoc preemption mechanism. + + // Use Objective-C runtime to verify the property/method doesn't exist + let executorClass: AnyClass = TokenExecutor.self + + // Check that no class method or property named activeSessionsWithTokens exists + let selector = NSSelectorFromString("activeSessionsWithTokens") + let hasClassMethod = class_getClassMethod(executorClass, selector) != nil + let hasInstanceMethod = class_getInstanceMethod(executorClass, selector) != nil + + XCTAssertFalse(hasClassMethod, + "TokenExecutor should not have activeSessionsWithTokens class method - legacy preemption removed") + XCTAssertFalse(hasInstanceMethod, + "TokenExecutor should not have activeSessionsWithTokens instance method - legacy preemption removed") + } + + // NEGATIVE TEST: Background sessions should NOT be preempted by foreground + func testBackgroundSessionNotPreemptedByForeground() throws { + // REQUIREMENT: Under fairness model, all sessions get equal turns. + // Background sessions should NOT yield to foreground mid-turn. + // + // Test design: + // 1. Create both background and foreground executors with tokens + // 2. Add tokens to BOTH (foreground having tokens is what could cause preemption) + // 3. Verify both sessions get execution turns (proving no preemption/starvation) + + // Create background executor with its own delegate for tracking + let bgDelegate = MockTokenExecutorDelegate() + let bgExecutor = TokenExecutor(mockTerminal, slownessDetector: SlownessDetector(), queue: iTermGCD.mutationQueue()) + bgExecutor.delegate = bgDelegate + bgExecutor.isBackgroundSession = true + + // Create foreground executor with its own delegate + let fgDelegate = MockTokenExecutorDelegate() + let fgExecutor = TokenExecutor(mockTerminal, slownessDetector: SlownessDetector(), queue: iTermGCD.mutationQueue()) + fgExecutor.delegate = fgDelegate + fgExecutor.isBackgroundSession = false + + // Register both with scheduler + let bgId = FairnessScheduler.shared.register(bgExecutor) + let fgId = FairnessScheduler.shared.register(fgExecutor) + bgExecutor.fairnessSessionId = bgId + fgExecutor.fairnessSessionId = fgId + + defer { + FairnessScheduler.shared.unregister(sessionId: bgId) + FairnessScheduler.shared.unregister(sessionId: fgId) + } + + // Add tokens to BOTH executors - this is crucial for testing preemption + // If foreground has tokens, the old activeSessionsWithTokens logic would + // have preempted background. Under fairness, both should get turns. + let bgVector = createTestTokenVector(count: 5) + bgExecutor.addTokens(bgVector, lengthTotal: 50, lengthExcludingInBandSignaling: 50) + + let fgVector = createTestTokenVector(count: 5) + fgExecutor.addTokens(fgVector, lengthTotal: 50, lengthExcludingInBandSignaling: 50) + + // Wait for both executors to process tokens using iteration-based loop. + // We drain both mutation and main queues since token execution may involve both. + var bothExecuted = false + for _ in 0..<100 { + waitForMutationQueue() + waitForMainQueue() + if bgDelegate.executedLengths.count > 0 && fgDelegate.executedLengths.count > 0 { + bothExecuted = true + break + } + } + XCTAssertTrue(bothExecuted, + "Both background and foreground should process under fairness. " + + "Background executions: \(bgDelegate.executedLengths.count), " + + "Foreground executions: \(fgDelegate.executedLengths.count)") + + // Additional verification: background specifically got a turn + XCTAssertGreaterThan(bgDelegate.executedLengths.count, 0, + "Background session should not be preempted by foreground") + } + + func testBackgroundSessionGetsEqualTurns() { + // Test that background sessions process tokens under fairness model + + let executor = TokenExecutor( + mockTerminal, + slownessDetector: SlownessDetector(), + queue: iTermGCD.mutationQueue() + ) + executor.delegate = mockDelegate + executor.isBackgroundSession = true + + // Register with FairnessScheduler (required for schedule() to work) + let sessionId = FairnessScheduler.shared.register(executor) + executor.fairnessSessionId = sessionId + + // Add and process tokens + let vector = createTestTokenVector(count: 5) + executor.addTokens(vector, lengthTotal: 50, lengthExcludingInBandSignaling: 50) + + // Drain mutation queue to let execution complete + for _ in 0..<5 { + waitForMutationQueue() + } + + XCTAssertGreaterThan(mockDelegate.executedLengths.count, 0, + "Background session should process tokens") + + // Clean up + FairnessScheduler.shared.unregister(sessionId: sessionId) + } + + func testRoundRobinFairnessInvariant() throws { + // REQUIREMENT: Each session gets AT MOST one turn per round. + // This is the KEY FAIRNESS INVARIANT: no session gets a second turn + // until all other busy sessions have had their first turn. + // + // Test design (DETERMINISTIC - no polling/timeouts): + // 1. Create sessions with delegates that BLOCK execution (shouldQueueTokens = true) + // 2. Add tokens to ALL sessions while blocked - they queue but don't execute + // 3. Sync to mutation queue - all sessions now in busy list + // 4. Clear execution history + // 5. Unblock all sessions and kick scheduler + // 6. Sync to mutation queue multiple times to let execution complete + // 7. Verify the execution order shows proper round-robin + try XCTSkipUnless(isDebugBuild, "Test requires ITERM_DEBUG hooks for execution history tracking") + + #if ITERM_DEBUG + // Create 3 sessions with delegates that initially BLOCK execution + var executors: [(executor: TokenExecutor, delegate: MockTokenExecutorDelegate, id: UInt64)] = [] + + for i in 0..<3 { + let delegate = MockTokenExecutorDelegate() + delegate.shouldQueueTokens = true // BLOCK execution initially + let terminal = VT100Terminal() + let executor = TokenExecutor(terminal, slownessDetector: SlownessDetector(), queue: iTermGCD.mutationQueue()) + executor.delegate = delegate + executor.isBackgroundSession = (i > 0) + + let sessionId = FairnessScheduler.shared.register(executor) + executor.fairnessSessionId = sessionId + + executors.append((executor: executor, delegate: delegate, id: sessionId)) + } + + defer { + for e in executors { + FairnessScheduler.shared.unregister(sessionId: e.id) + } + } + + // Add tokens to ALL sessions while they're blocked + // This ensures all sessions have work queued BEFORE any execution starts + for e in executors { + // Add enough tokens to require multiple rounds (budget is 500 tokens) + // Each call adds 100 tokens worth, so 10 calls = 1000 tokens = 2 turns + for _ in 0..<10 { + let vector = createTestTokenVector(count: 100) + e.executor.addTokens(vector, lengthTotal: 1000, lengthExcludingInBandSignaling: 1000) + } + } + + // Sync to mutation queue - all tokens are queued, scheduler has been notified + // but execution returns .blocked because shouldQueueTokens = true + waitForMutationQueue() + + // Clear any history from the blocked execution attempts + FairnessScheduler.shared.testClearExecutionHistory() + + // Unblock all sessions on mutation queue and kick scheduler + iTermGCD.mutationQueue().sync { + for e in executors { + e.delegate.shouldQueueTokens = false + } + } + + // Kick scheduler for each session to notify there's work + for e in executors { + e.executor.schedule() + } + + // Sync multiple times to allow execution rounds to complete + // Each sync drains the queue, allowing pending execution completions to trigger next turns + for _ in 0..<20 { + waitForMutationQueue() + } + + // Get the execution history + let history = FairnessScheduler.shared.testGetAndClearExecutionHistory() + + // Basic sanity check - we should have some executions + XCTAssertGreaterThanOrEqual(history.count, 3, + "Should have at least one round of execution. History: \(history)") + + // VERIFY THE ROUND-ROBIN FAIRNESS INVARIANT: + // No session should execute twice in a row when other sessions have work. + var violations: [String] = [] + for i in 1..= sessionCount { + let firstRound = Array(history.prefix(sessionCount)) + let uniqueInFirstRound = Set(firstRound) + XCTAssertEqual(uniqueInFirstRound.count, sessionCount, + "First round should include all \(sessionCount) sessions. First \(sessionCount): \(firstRound)") + } + #endif + } +} + +// MARK: - 2.7 Cleanup Tests + +/// Tests for cleanup when session is unregistered (2.7) +final class TokenExecutorCleanupTests: XCTestCase { + + var mockDelegate: MockTokenExecutorDelegate! + var mockTerminal: VT100Terminal! + + override func setUp() { + super.setUp() + mockDelegate = MockTokenExecutorDelegate() + mockTerminal = VT100Terminal() + } + + override func tearDown() { + mockTerminal = nil + mockDelegate = nil + super.tearDown() + } + + func testCleanupForUnregistrationExists() throws { + // REQUIREMENT: cleanupForUnregistration() must exist and handle unconsumed tokens. + + let executor = TokenExecutor(mockTerminal, slownessDetector: SlownessDetector(), queue: DispatchQueue.main) + executor.delegate = mockDelegate + + // Verify the method exists by calling it + executor.cleanupForUnregistration() + + // Should not crash - test passes if we get here + } + + func testCleanupIncrementsAvailableSlots() throws { + // REQUIREMENT: For each unconsumed TokenArray, increment availableSlots. + + let executor = TokenExecutor(mockTerminal, slownessDetector: SlownessDetector(), queue: DispatchQueue.main) + executor.delegate = mockDelegate + + // Add tokens without processing + for _ in 0..<10 { + let vector = createTestTokenVector(count: 5) + executor.addTokens(vector, lengthTotal: 50, lengthExcludingInBandSignaling: 50) + } + + // Cleanup should restore slots + executor.cleanupForUnregistration() + + // After cleanup, backpressure should be released + XCTAssertEqual(executor.backpressureLevel, .none, + "Cleanup should restore available slots") + } + + // NEGATIVE TEST: Cleanup should NOT double-increment for already-consumed tokens + func testCleanupNoDoubleIncrement() throws { + // REQUIREMENT: Only increment for truly unconsumed tokens. + + let executor = TokenExecutor(mockTerminal, slownessDetector: SlownessDetector(), queue: DispatchQueue.main) + executor.delegate = mockDelegate + + // Add and consume tokens + let vector = createTestTokenVector(count: 5) + executor.addTokens(vector, lengthTotal: 50, lengthExcludingInBandSignaling: 50) + executor.schedule() + + // Drain main queue to let tokens be consumed + for _ in 0..<5 { + waitForMainQueue() + } + + XCTAssertEqual(executor.backpressureLevel, .none, + "Tokens should be consumed (backpressure none)") + + // Now cleanup - should not over-increment + executor.cleanupForUnregistration() + + XCTAssertEqual(executor.backpressureLevel, .none, + "Cleanup should not over-increment slots") + } + + func testCleanupRestoresExactSlotCount() throws { + // REQUIREMENT: Verify cleanup restores slots by checking backpressure behavior. + // We verify the exact restoration by testing that we can add the same number + // of arrays again after cleanup without exceeding capacity. + + let executor = TokenExecutor(mockTerminal, slownessDetector: SlownessDetector(), queue: DispatchQueue.main) + executor.delegate = mockDelegate + + // Verify initial state - no backpressure + XCTAssertEqual(executor.backpressureLevel, .none, + "Fresh executor should have no backpressure") + + // Add enough token arrays to exceed capacity (200 with 40 slots = blocked) + let arraysToAdd = 200 + for _ in 0.. budget of 100 + // - Only Group1 would execute, result = .yielded + // + // If budget uses token count (correct), this test passes because: + // - Group1 (50 tokens) + Group2 (5 tokens) = 55 < budget of 100 + // - Both groups execute, result = .completed + + // Group 1: Many tokens, small lengthTotal (high-priority for separate group) + let manyTokensVector = createTestTokenVector(count: 50) + executor.addTokens(manyTokensVector, lengthTotal: 50, lengthExcludingInBandSignaling: 50, highPriority: true) + + // Group 2: Few tokens, large lengthTotal (normal-priority for separate group) + let fewTokensVector = createTestTokenVector(count: 5) + executor.addTokens(fewTokensVector, lengthTotal: 5000, lengthExcludingInBandSignaling: 5000, highPriority: false) + + let initialWillExecuteCount = mockDelegate.willExecuteCount + + let expectation = XCTestExpectation(description: "ExecuteTurn completed") + var receivedResult: TurnResult? + // Budget of 100: if using token count, 50+5=55 fits. If using lengthTotal, 50+5000 doesn't fit. + executor.executeTurn(tokenBudget: 100) { result in + receivedResult = result + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + + // Both groups should execute because token count (55) fits within budget (100) + // This would fail if implementation incorrectly used lengthTotal (5050 > 100) + XCTAssertEqual(receivedResult, .completed, + "Both groups should execute when TOKEN COUNT fits budget (budget uses token count, not lengthTotal)") + + // Verify both groups executed (willExecuteTokens called, then both groups' lengths reported) + XCTAssertGreaterThan(mockDelegate.willExecuteCount, initialWillExecuteCount, + "At least one execution should have occurred") + + // Verify both groups' lengths were reported (50 + 5000 = 5050 total) + let totalReportedLength = mockDelegate.executedLengths.reduce(0) { $0 + $1.total } + XCTAssertEqual(totalReportedLength, 5050, + "Both groups should have reported their lengths (50 + 5000)") + } + + func testBudgetExceedanceUsesTokenCountNotLengthTotal() { + // REQUIREMENT: Budget exceedance check must use TOKEN COUNT, not lengthTotal. + // This is the inverse test - verifies that large token counts cause yielding + // even when lengthTotal is small. + // + // If budget used lengthTotal (bug), this test would fail because: + // - Group1 (5 lengthTotal) + Group2 (50 lengthTotal) = 55 < budget of 100 + // - Both groups would execute, result = .completed + // + // If budget uses token count (correct), this test passes because: + // - Group1 (50 tokens) exceeds budget of 10, but executes due to progress guarantee + // - Group2 (5 tokens): 50 + 5 = 55 > 10, so yield before executing Group2 + // - Only Group1 executes, result = .yielded + + // Group 1: Many tokens, tiny lengthTotal (high-priority for separate group) + let manyTokensVector = createTestTokenVector(count: 50) + executor.addTokens(manyTokensVector, lengthTotal: 5, lengthExcludingInBandSignaling: 5, highPriority: true) + + // Group 2: Few tokens, small lengthTotal (normal-priority for separate group) + let fewTokensVector = createTestTokenVector(count: 5) + executor.addTokens(fewTokensVector, lengthTotal: 50, lengthExcludingInBandSignaling: 50, highPriority: false) + + let expectation = XCTestExpectation(description: "ExecuteTurn completed") + var receivedResult: TurnResult? + // Budget of 10: token count of Group1 (50) already exceeds it + executor.executeTurn(tokenBudget: 10) { result in + receivedResult = result + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + + // Should yield because token count (50) exceeds budget (10), even though lengthTotal is tiny + // Group1 executes due to progress guarantee, then yields before Group2 + XCTAssertEqual(receivedResult, .yielded, + "Should yield when TOKEN COUNT exceeds budget (budget uses token count, not lengthTotal)") + + // Verify only first group's length was reported (5, not 5+50=55) + let totalReportedLength = mockDelegate.executedLengths.reduce(0) { $0 + $1.total } + XCTAssertEqual(totalReportedLength, 5, + "Only first group should have executed (length 5, not 55)") + } + +} + +// MARK: - Same-Queue Group Boundary Tests + +/// Tests for budget enforcement with multiple groups in the SAME priority queue. +/// These tests verify that enumerateTokenArrayGroups correctly identifies group +/// boundaries based on token coalesceability, and that budget is checked between +/// these groups (not just between high-priority and normal-priority queues). +/// +/// Key invariant: VT100_UNKNOWNCHAR tokens are non-coalescable, so each TokenArray +/// with such tokens forms its own group, even when added to the same queue. +final class TokenExecutorSameQueueGroupBoundaryTests: XCTestCase { + + var mockDelegate: MockTokenExecutorDelegate! + var mockTerminal: VT100Terminal! + var executor: TokenExecutor! + + override func setUp() { + super.setUp() + mockDelegate = MockTokenExecutorDelegate() + mockTerminal = VT100Terminal() + executor = TokenExecutor( + mockTerminal, + slownessDetector: SlownessDetector(), + queue: DispatchQueue.main + ) + executor.delegate = mockDelegate + + let sessionId = FairnessScheduler.shared.register(executor) + executor.fairnessSessionId = sessionId + } + + override func tearDown() { + if let id = executor?.fairnessSessionId { + FairnessScheduler.shared.unregister(sessionId: id) + } + executor = nil + mockTerminal = nil + mockDelegate = nil + super.tearDown() + } + + func testBudgetEnforcementBetweenGroupsInSameQueue() { + // REQUIREMENT: Budget should be checked BETWEEN groups in the same queue, + // not just between different priority queues. + // This test adds multiple TokenArrays to the NORMAL priority queue only. + // + // NOTE: Budget is measured in TOKEN COUNT, not byte length. + // VT100_UNKNOWNCHAR tokens are non-coalescable, so each array is its own group. + + // Add 3 groups of 100 TOKENS each to normal priority queue + for _ in 0..<3 { + let vector = createTestTokenVector(count: 100) // 100 tokens per group + executor.addTokens(vector, lengthTotal: 1000, lengthExcludingInBandSignaling: 1000) + } + + let initialWillExecuteCount = mockDelegate.willExecuteCount + + let expectation = XCTestExpectation(description: "ExecuteTurn completed") + var receivedResult: TurnResult? + executor.executeTurn(tokenBudget: 50) { result in // Budget of 50 tokens + receivedResult = result + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + + // First group (100 tokens) exceeds budget (50), should yield with more work + XCTAssertEqual(receivedResult, .yielded, + "Should yield because first group exceeds budget and more groups remain") + + // Only ONE group should have executed (progress guarantee + budget stop) + XCTAssertEqual(mockDelegate.willExecuteCount, initialWillExecuteCount + 1, + "Only first group should execute when it exceeds budget") + } + + func testSecondGroupInSameQueueSkippedWhenBudgetExceeded() { + // REQUIREMENT: Second group in same queue should NOT execute if budget + // was exceeded by first group. This verifies the budget check between + // groups within the same priority queue. + // + // NOTE: Budget is measured in TOKEN COUNT, not byte length. + + // Add 2 groups to normal priority queue with different TOKEN counts + let firstGroupTokens = 100 + let secondGroupTokens = 50 + + let vector1 = createTestTokenVector(count: firstGroupTokens) + executor.addTokens(vector1, lengthTotal: firstGroupTokens * 10, lengthExcludingInBandSignaling: firstGroupTokens * 10) + + let vector2 = createTestTokenVector(count: secondGroupTokens) + executor.addTokens(vector2, lengthTotal: secondGroupTokens * 10, lengthExcludingInBandSignaling: secondGroupTokens * 10) + + // First turn: budget 10, first group is 100 tokens - should execute only first + let firstExpectation = XCTestExpectation(description: "First turn") + var firstResult: TurnResult? + executor.executeTurn(tokenBudget: 10) { result in + firstResult = result + firstExpectation.fulfill() + } + wait(for: [firstExpectation], timeout: 1.0) + + XCTAssertEqual(firstResult, .yielded, + "First turn should yield (more work remains)") + XCTAssertEqual(mockDelegate.willExecuteCount, 1, + "Only first group should execute in first turn") + + // Second turn: should process remaining group + let secondExpectation = XCTestExpectation(description: "Second turn") + var secondResult: TurnResult? + executor.executeTurn(tokenBudget: 100) { result in + secondResult = result + secondExpectation.fulfill() + } + wait(for: [secondExpectation], timeout: 1.0) + + XCTAssertEqual(secondResult, .completed, + "Second turn should complete (all work done)") + XCTAssertEqual(mockDelegate.willExecuteCount, 2, + "Second group should execute in second turn") + } + + func testMultipleGroupsProcessedWithinBudget() { + // REQUIREMENT: Multiple groups should all execute if they fit within budget. + // This verifies that budget check allows continuation when budget not exceeded. + // + // NOTE: Budget is measured in TOKEN COUNT, not byte length. + // NOTE: willExecuteCount and executedLengths are per-TURN metrics, not per-group. + + // Add 3 small groups (10 TOKENS each) to normal priority queue + // Each has lengthTotal of 100 bytes + for _ in 0..<3 { + let vector = createTestTokenVector(count: 10) // 10 tokens per group + executor.addTokens(vector, lengthTotal: 100, lengthExcludingInBandSignaling: 100) + } + + let expectation = XCTestExpectation(description: "ExecuteTurn completed") + var receivedResult: TurnResult? + executor.executeTurn(tokenBudget: 500) { result in // Budget of 500 tokens + receivedResult = result + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + + // All 3 groups (total 30 tokens) fit within budget (500) + XCTAssertEqual(receivedResult, .completed, + "Should complete when all groups fit within budget") + + // Verify all 3 groups were processed by checking total byte length + // Each group has lengthTotal=100, so 3 groups = 300 bytes total + XCTAssertEqual(mockDelegate.executedLengths.count, 1, + "Should have one execution record per turn") + if let execution = mockDelegate.executedLengths.first { + XCTAssertEqual(execution.total, 300, + "Total length should be 300 (3 groups * 100 bytes each)") + } + } + + func testBudgetBoundaryExactMatch() { + // REQUIREMENT: When cumulative tokens exactly match budget, next group should NOT execute. + // (Budget check: tokensConsumed + nextGroup > budget, so exact match triggers stop) + // + // NOTE: Budget is measured in TOKEN COUNT, not byte length. + + // Add 2 groups: first exactly matches budget (100 tokens), second should NOT execute + let budget = 100 + + let vector1 = createTestTokenVector(count: budget) // 100 tokens + executor.addTokens(vector1, lengthTotal: budget * 10, lengthExcludingInBandSignaling: budget * 10) + + let vector2 = createTestTokenVector(count: 50) // 50 tokens + executor.addTokens(vector2, lengthTotal: 500, lengthExcludingInBandSignaling: 500) + + let expectation = XCTestExpectation(description: "ExecuteTurn completed") + var receivedResult: TurnResult? + executor.executeTurn(tokenBudget: budget) { result in + receivedResult = result + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + + // First group exactly matches budget (100 tokens); second group (50) would exceed budget (100 + 50 > 100) + // So only first group should execute + XCTAssertEqual(receivedResult, .yielded, + "Should yield because adding second group would exceed budget") + XCTAssertEqual(mockDelegate.willExecuteCount, 1, + "Only first group should execute when it exactly matches budget") + } + + func testProgressGuaranteeWithSameQueueGroups() { + // REQUIREMENT: At least one group must execute per turn (progress guarantee), + // even if that group exceeds budget. Verifies this with same-queue groups. + // + // NOTE: Budget is measured in TOKEN COUNT, not byte length. + + // Add 1 large group (1000 tokens) that exceeds budget (1) + let vector = createTestTokenVector(count: 1000) + executor.addTokens(vector, lengthTotal: 10000, lengthExcludingInBandSignaling: 10000) + + let expectation = XCTestExpectation(description: "ExecuteTurn completed") + var receivedResult: TurnResult? + executor.executeTurn(tokenBudget: 1) { result in + receivedResult = result + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + + // Group should still execute (progress guarantee) + XCTAssertEqual(receivedResult, .completed, + "Single large group should complete (progress guarantee)") + XCTAssertEqual(mockDelegate.willExecuteCount, 1, + "Large group should execute despite exceeding budget") + } +} + +// MARK: - AvailableSlots Boundary Tests + +/// Tests for availableSlots boundary conditions. +/// These ensure accounting is balanced (no drift) and handles over-capacity correctly. +/// Note: availableSlots CAN go negative when high-priority tokens bypass backpressure. +final class TokenExecutorAvailableSlotsBoundaryTests: XCTestCase { + + var mockDelegate: MockTokenExecutorDelegate! + var mockTerminal: VT100Terminal! + + override func setUp() { + super.setUp() + mockDelegate = MockTokenExecutorDelegate() + mockTerminal = VT100Terminal() + } + + override func tearDown() { + mockTerminal = nil + mockDelegate = nil + super.tearDown() + } + + func testSlotsAccountingBalancedAfterFullDrain() { + // REQUIREMENT: availableSlots accounting must be balanced - after all tokens + // are consumed, slots must return to totalSlots (no drift). + // + // NOTE: This test bypasses PTYTask's backpressure check and adds tokens + // directly to TokenExecutor. In the real system: + // - PTYTask suspends reading when backpressureLevel >= .heavy (25% remaining) + // - Only high-priority tokens (API injection) can bypass this check + // - High-priority tokens are allowed to temporarily go negative by design + // (see implementation.md: "High-priority can temporarily go negative") + // + // This test verifies raw TokenExecutor accounting is correct, not the + // integrated PTYTask backpressure behavior. + + let executor = TokenExecutor( + mockTerminal, + slownessDetector: SlownessDetector(), + queue: DispatchQueue.main + ) + executor.delegate = mockDelegate + + // Register with scheduler so execution works + let sessionId = FairnessScheduler.shared.register(executor) + executor.fairnessSessionId = sessionId + + #if ITERM_DEBUG + let initialSlots = executor.testAvailableSlots + let totalSlots = executor.testTotalSlots + XCTAssertEqual(initialSlots, totalSlots, "Fresh executor should have all slots available") + #endif + + // Add more token groups than totalSlots (simulating high-priority bypass) + // In real usage, only high-priority tokens would do this; normal PTY tokens + // are blocked by PTYTask's backpressure check at 25% capacity. + let addCount = 50 + for _ in 0..consume->add cycles should not cause drift. + + let executor = TokenExecutor( + mockTerminal, + slownessDetector: SlownessDetector(), + queue: DispatchQueue.main + ) + executor.delegate = mockDelegate + + // Register so schedule() works + let sessionId = FairnessScheduler.shared.register(executor) + executor.fairnessSessionId = sessionId + + #if ITERM_DEBUG + let totalSlots = executor.testTotalSlots + let initialSlots = executor.testAvailableSlots + XCTAssertEqual(initialSlots, totalSlots, "Should start with all slots available") + #endif + + for cycle in 0..<20 { + // Add + let vector = createTestTokenVector(count: 5) + executor.addTokens(vector, lengthTotal: 50, lengthExcludingInBandSignaling: 50) + + #if ITERM_DEBUG + // After add, slots should decrease by 1 + let afterAdd = executor.testAvailableSlots + XCTAssertEqual(afterAdd, totalSlots - 1, + "After add in cycle \(cycle), should have one fewer slot") + #endif + + // Immediately trigger consume + let expectation = XCTestExpectation(description: "Cycle \(cycle)") + executor.executeTurn(tokenBudget: 500) { _ in + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + + #if ITERM_DEBUG + // After consume, slots should return to totalSlots + let afterConsume = executor.testAvailableSlots + XCTAssertEqual(afterConsume, totalSlots, + "After consume in cycle \(cycle), slots should return to max") + #endif + } + + FairnessScheduler.shared.unregister(sessionId: sessionId) + + #if ITERM_DEBUG + // Verify no drift after many cycles + let finalSlots = executor.testAvailableSlots + XCTAssertEqual(finalSlots, totalSlots, + "After \(20) add/consume cycles, slots should equal totalSlots (no drift)") + #endif + + // After many cycles, should be back to none + XCTAssertEqual(executor.backpressureLevel, .none, + "After many add/consume cycles, backpressure should be none") + } +} + +// MARK: - High-Priority Task Ordering Tests + +/// Tests for high-priority task execution ordering. +/// These verify that high-priority tasks run before normal tokens. +final class TokenExecutorHighPriorityOrderingTests: XCTestCase { + + var mockDelegate: MockTokenExecutorDelegate! + var mockTerminal: VT100Terminal! + var executor: TokenExecutor! + + override func setUp() { + super.setUp() + mockDelegate = MockTokenExecutorDelegate() + mockTerminal = VT100Terminal() + executor = TokenExecutor( + mockTerminal, + slownessDetector: SlownessDetector(), + queue: DispatchQueue.main + ) + executor.delegate = mockDelegate + } + + override func tearDown() { + executor = nil + mockTerminal = nil + mockDelegate = nil + super.tearDown() + } + + func testHighPriorityTasksExecuteBeforeTokens() { + // REQUIREMENT: High-priority tasks in taskQueue execute before tokens in tokenQueue. + + var executionOrder: [String] = [] + + // Add tokens first + let vector = createTestTokenVector(count: 5) + executor.addTokens(vector, lengthTotal: 50, lengthExcludingInBandSignaling: 50) + + // Then add high-priority task + executor.scheduleHighPriorityTask { + executionOrder.append("high-priority") + } + + // Track when willExecuteTokens is called (indicates token processing) + let originalWillExecute = mockDelegate.willExecuteCount + mockDelegate.reset() // Clear counts + + let expectation = XCTestExpectation(description: "ExecuteTurn completed") + executor.executeTurn(tokenBudget: 500) { _ in + // Check if high-priority ran before tokens were executed + // willExecuteCount > 0 means tokens were processed + if self.mockDelegate.willExecuteCount > 0 && executionOrder.isEmpty { + // Tokens ran but high-priority didn't - wrong order + executionOrder.append("tokens-first-ERROR") + } else if !executionOrder.isEmpty && self.mockDelegate.willExecuteCount > 0 { + executionOrder.append("tokens") + } + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + + // High-priority should have run + XCTAssertTrue(executionOrder.contains("high-priority"), + "High-priority task should have executed") + + // Should not have the error marker + XCTAssertFalse(executionOrder.contains("tokens-first-ERROR"), + "High-priority task should run before tokens") + } + + func testMultipleHighPriorityTasksAllExecute() { + // REQUIREMENT: All high-priority tasks execute during the turn. + + var taskResults: [Int] = [] + + // Schedule multiple high-priority tasks + for i in 0..<5 { + executor.scheduleHighPriorityTask { + taskResults.append(i) + } + } + + let expectation = XCTestExpectation(description: "ExecuteTurn completed") + executor.executeTurn(tokenBudget: 500) { _ in + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + + XCTAssertEqual(taskResults.count, 5, + "All high-priority tasks should have executed") + XCTAssertEqual(taskResults, [0, 1, 2, 3, 4], + "Tasks should execute in order they were scheduled") + } + + func testHighPriorityTokenArraysExecuteBeforeNormalTokenArrays() { + // REQUIREMENT: High-priority token arrays (queue[0]) must execute before + // normal-priority token arrays (queue[1]), even when normal is added first. + // + // NOTE: Ordering is verified by TwoTierTokenQueueGroupingTests/ + // testHighPriorityExecutesBeforeNormalEvenWhenAddedSecond which has + // direct access to enumeration order. This test verifies the integration: + // that TokenExecutor processes both priority levels correctly. + + var executedLengths: [Int] = [] + let trackingDelegate = OrderTrackingTokenExecutorDelegate() + trackingDelegate.onExecute = { length in + executedLengths.append(length) + } + executor.delegate = trackingDelegate + + // Add NORMAL-priority token array FIRST with length 200 + let normalVector = createTestTokenVector(count: 1) + executor.addTokens(normalVector, lengthTotal: 200, lengthExcludingInBandSignaling: 200, highPriority: false) + + // Add HIGH-priority token array SECOND with length 100 + let highPriVector = createTestTokenVector(count: 1) + executor.addTokens(highPriVector, lengthTotal: 100, lengthExcludingInBandSignaling: 100, highPriority: true) + + let expectation = XCTestExpectation(description: "ExecuteTurn completed") + executor.executeTurn(tokenBudget: 500) { _ in + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + + // Verify execution occurred + XCTAssertEqual(trackingDelegate.willExecuteCount, 1, + "Tokens should have been executed") + + // Verify the callback recorded the execution + XCTAssertEqual(executedLengths.count, 1, + "onExecute callback should have been invoked") + + // Total length should be 300 (100 high-pri + 200 normal) + XCTAssertEqual(trackingDelegate.totalExecutedLength, 300, + "Both token arrays should have executed (100 + 200 = 300)") + XCTAssertEqual(executedLengths.first, 300, + "Callback should report total length of both arrays") + } + + func testHighPriorityTaskAddedDuringExecutionRunsInSameTurn() { + // REQUIREMENT: High-priority task added during executeTurn should run in same turn. + + var innerTaskRan = false + + executor.scheduleHighPriorityTask { + // Schedule another task from within the first + self.executor.scheduleHighPriorityTask { + innerTaskRan = true + } + } + + let expectation = XCTestExpectation(description: "ExecuteTurn completed") + executor.executeTurn(tokenBudget: 500) { _ in + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + + // The inner task should have run in the same turn + XCTAssertTrue(innerTaskRan, + "Task scheduled during execution should run in same turn") + } + + func testHighPriorityDoesNotStarveTokens() { + // REQUIREMENT: Even with high-priority tasks, tokens should eventually process. + + var highPriorityCount = 0 + let maxHighPriority = 5 + + // Add tokens + let vector = createTestTokenVector(count: 5) + executor.addTokens(vector, lengthTotal: 50, lengthExcludingInBandSignaling: 50) + + // Add limited high-priority tasks (they don't re-add themselves infinitely) + for _ in 0.. TokenArray { + var vector = CVector() + CVectorCreate(&vector, Int32(tokenCount)) + + for _ in 0.. budget && groupsExecuted > 0 { + return false // Stop - budget exceeded + } + + // Execute group + _ = group.consume() + tokensConsumed += groupTokenCount + groupsExecuted += 1 + + return true + } + + // First group should execute (progress guarantee), but not second + XCTAssertEqual(groupsExecuted, 1, + "Only first group should execute when it exceeds budget") + XCTAssertEqual(tokensConsumed, tokensPerGroup, + "First group's tokens should be consumed") + XCTAssertFalse(queue.isEmpty, + "Remaining groups should still be in queue") + } + + // MARK: - Test Helpers + + /// Create a TokenArray with non-coalescable tokens (VT100_UNKNOWNCHAR). + private func createNonCoalescableTokenArray(tokenCount: Int, lengthPerToken: Int = 10) -> TokenArray { + var vector = CVector() + CVectorCreate(&vector, Int32(tokenCount)) + + for _ in 0.. BuildMachineOSBuild - 25C56 + 25A354 CFBundleDevelopmentRegion English CFBundleExecutable diff --git a/ThirdParty/CoreParse.framework/Versions/A/_CodeSignature/CodeResources b/ThirdParty/CoreParse.framework/Versions/A/_CodeSignature/CodeResources index 6ab196bcc8..e26bf4b5fb 100644 --- a/ThirdParty/CoreParse.framework/Versions/A/_CodeSignature/CodeResources +++ b/ThirdParty/CoreParse.framework/Versions/A/_CodeSignature/CodeResources @@ -10,7 +10,7 @@ Resources/Info.plist - KKNUg/RqqRELteLCY2ArrFGypMI= + gTnl9sIUfsskLr+wB+3eXL/cu3g= Resources/en.lproj/InfoPlist.strings @@ -231,7 +231,7 @@ hash2 - rpDWWXb1Dnl2NaGZY2RVe9rYtA55DoLP3/VkPE/SX8o= + k7K1RLK8XLubhkn+lLbcqZXqEuT9FBYq8hw9rG/9C44= Resources/en.lproj/InfoPlist.strings diff --git a/ThirdParty/SFSymbolEnum/SFSymbolEnum.h b/ThirdParty/SFSymbolEnum/SFSymbolEnum.h index 6050a7e260..62345b9f48 100644 --- a/ThirdParty/SFSymbolEnum/SFSymbolEnum.h +++ b/ThirdParty/SFSymbolEnum/SFSymbolEnum.h @@ -8590,7 +8590,656 @@ typedef NS_ENUM(NSInteger, SFSymbol) { SFSymbolBeatsPowerbeatsPro2Chargingcase API_AVAILABLE(ios(18.5), macos(15.5), tvos(18.5), visionos(2.5), watchos(11.5)) = 8535, SFSymbolBeatsPowerbeatsPro2ChargingcaseFill API_AVAILABLE(ios(18.5), macos(15.5), tvos(18.5), visionos(2.5), watchos(11.5)) = 8536, SFSymbolBeatsPowerbeatsPro2Left API_AVAILABLE(ios(18.5), macos(15.5), tvos(18.5), visionos(2.5), watchos(11.5)) = 8537, - SFSymbolBeatsPowerbeatsPro2Right API_AVAILABLE(ios(18.5), macos(15.5), tvos(18.5), visionos(2.5), watchos(11.5)) = 8538 + SFSymbolBeatsPowerbeatsPro2Right API_AVAILABLE(ios(18.5), macos(15.5), tvos(18.5), visionos(2.5), watchos(11.5)) = 8538, + + // Symbols introduced in 2025 + SFSymbol1Calendar API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8539, + SFSymbol1CalendarAr API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8540, + SFSymbol1CalendarHi API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8541, + SFSymbol10Calendar API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8542, + SFSymbol10CalendarAr API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8543, + SFSymbol10CalendarHi API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8544, + SFSymbol11Calendar API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8545, + SFSymbol11CalendarAr API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8546, + SFSymbol11CalendarHi API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8547, + SFSymbol12Calendar API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8548, + SFSymbol12CalendarAr API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8549, + SFSymbol12CalendarHi API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8550, + SFSymbol13Calendar API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8551, + SFSymbol13CalendarAr API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8552, + SFSymbol13CalendarHi API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8553, + SFSymbol14Calendar API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8554, + SFSymbol14CalendarAr API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8555, + SFSymbol14CalendarHi API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8556, + SFSymbol15Calendar API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8557, + SFSymbol15CalendarAr API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8558, + SFSymbol15CalendarHi API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8559, + SFSymbol16Calendar API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8560, + SFSymbol16CalendarAr API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8561, + SFSymbol16CalendarHi API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8562, + SFSymbol17Calendar API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8563, + SFSymbol17CalendarAr API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8564, + SFSymbol17CalendarHi API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8565, + SFSymbol18Calendar API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8566, + SFSymbol18CalendarAr API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8567, + SFSymbol18CalendarHi API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8568, + SFSymbol19Calendar API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8569, + SFSymbol19CalendarAr API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8570, + SFSymbol19CalendarHi API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8571, + SFSymbol2Calendar API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8572, + SFSymbol2CalendarAr API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8573, + SFSymbol2CalendarHi API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8574, + SFSymbol20Calendar API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8575, + SFSymbol20CalendarAr API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8576, + SFSymbol20CalendarHi API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8577, + SFSymbol21Calendar API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8578, + SFSymbol21CalendarAr API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8579, + SFSymbol21CalendarHi API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8580, + SFSymbol22Calendar API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8581, + SFSymbol22CalendarAr API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8582, + SFSymbol22CalendarHi API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8583, + SFSymbol23Calendar API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8584, + SFSymbol23CalendarAr API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8585, + SFSymbol23CalendarHi API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8586, + SFSymbol24Calendar API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8587, + SFSymbol24CalendarAr API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8588, + SFSymbol24CalendarHi API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8589, + SFSymbol25Calendar API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8590, + SFSymbol25CalendarAr API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8591, + SFSymbol25CalendarHi API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8592, + SFSymbol26Calendar API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8593, + SFSymbol26CalendarAr API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8594, + SFSymbol26CalendarHi API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8595, + SFSymbol27Calendar API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8596, + SFSymbol27CalendarAr API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8597, + SFSymbol27CalendarHi API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8598, + SFSymbol28Calendar API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8599, + SFSymbol28CalendarAr API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8600, + SFSymbol28CalendarHi API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8601, + SFSymbol29Calendar API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8602, + SFSymbol29CalendarAr API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8603, + SFSymbol29CalendarHi API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8604, + SFSymbol3Calendar API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8605, + SFSymbol3CalendarAr API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8606, + SFSymbol3CalendarHi API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8607, + SFSymbol30Calendar API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8608, + SFSymbol30CalendarAr API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8609, + SFSymbol30CalendarHi API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8610, + SFSymbol31Calendar API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8611, + SFSymbol31CalendarAr API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8612, + SFSymbol31CalendarHi API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8613, + SFSymbol4Calendar API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8614, + SFSymbol4CalendarAr API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8615, + SFSymbol4CalendarHi API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8616, + SFSymbol5Calendar API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8617, + SFSymbol5CalendarAr API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8618, + SFSymbol5CalendarHi API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8619, + SFSymbol6Calendar API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8620, + SFSymbol6CalendarAr API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8621, + SFSymbol6CalendarHi API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8622, + SFSymbol7Calendar API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8623, + SFSymbol7CalendarAr API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8624, + SFSymbol7CalendarHi API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8625, + SFSymbol8Calendar API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8626, + SFSymbol8CalendarAr API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8627, + SFSymbol8CalendarHi API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8628, + SFSymbol9Calendar API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8629, + SFSymbol9CalendarAr API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8630, + SFSymbol9CalendarHi API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8631, + SFSymbolAc API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8632, + SFSymbolAcSlash API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8633, + SFSymbolAirplaneCloud API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8634, + SFSymbolAirplaneLanded API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8635, + SFSymbolAirplanePathDotted API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8636, + SFSymbolAirplaneTicket API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8637, + SFSymbolAirplaneTicketFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8638, + SFSymbolAirplaneUpForward API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8639, + SFSymbolAirplaneUpForwardApp API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8640, + SFSymbolAirplaneUpForwardAppFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8641, + SFSymbolAirplaneUpRight API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8642, + SFSymbolAirplaneUpRightApp API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8643, + SFSymbolAirplaneUpRightAppFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8644, + SFSymbolAirplaneseat API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8645, + SFSymbolAppBackgroundDotted API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8646, + SFSymbolAppGrid API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8647, + SFSymbolAppShadow API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8648, + SFSymbolAppSpecular API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8649, + SFSymbolAppTranslucent API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8650, + SFSymbolAppleBooksPages API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8651, + SFSymbolAppleBooksPagesFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8652, + SFSymbolAppleClassicalPages API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8653, + SFSymbolAppleClassicalPagesFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8654, + SFSymbolAppleHomekit API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8655, + SFSymbolApplePodcastsPages API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8656, + SFSymbolApplePodcastsPagesFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8657, + SFSymbolAppletvBadgeCheckmark API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8658, + SFSymbolAppletvBadgeCheckmarkFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8659, + SFSymbolAppletvBadgeExclamationmark API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8660, + SFSymbolAppletvBadgeExclamationmarkFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8661, + SFSymbolApplewatchBadgeCheckmark API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8662, + SFSymbolApplewatchBadgeExclamationmark API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8663, + SFSymbolAppsIpadBadgeCheckmark API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8664, + SFSymbolAppsIpadBadgeCheckmarkRtl API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8665, + SFSymbolAppsIpadBadgePlus API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8666, + SFSymbolAppsIpadOnRectanglePortraitDashed API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8667, + SFSymbolAppsIpadOnRectanglePortraitDashedRtl API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8668, + SFSymbolAppsIphoneBadgeCheckmark API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8669, + SFSymbolAppsIphoneBadgeCheckmarkRtl API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8670, + SFSymbolAqiMediumGaugeOpen API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8671, + SFSymbolArrowDownCircleBadgePause API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8672, + SFSymbolArrowDownCircleBadgePauseFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8673, + SFSymbolArrowDownCircleBadgeXmark API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8674, + SFSymbolArrowDownCircleBadgeXmarkFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8675, + SFSymbolArrowForwardFolder API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8676, + SFSymbolArrowForwardFolderFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8677, + SFSymbolArrowForwardFolderFillRtl API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8678, + SFSymbolArrowForwardFolderRtl API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8679, + SFSymbolArrowTriangleheadTurnUpRight API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8680, + SFSymbolArrowUpFolder API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8681, + SFSymbolArrowUpFolderFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8682, + SFSymbolBackpackSensorTagRadiowavesLeftAndRight API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8683, + SFSymbolBackpackSensorTagRadiowavesLeftAndRightFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8684, + SFSymbolBedDoubleBadgeCheckmark API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8685, + SFSymbolBedDoubleBadgeCheckmarkFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8686, + SFSymbolBellBadgeWaveformSlash API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8687, + SFSymbolBellBadgeWaveformSlashFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8688, + SFSymbolBicycleSensorTagRadiowavesLeftAndRight API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8689, + SFSymbolBicycleSensorTagRadiowavesLeftAndRightFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8690, + SFSymbolBloodPressureCuff API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8691, + SFSymbolBloodPressureCuffBadgeGaugeWithNeedle API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8692, + SFSymbolBloodPressureCuffBadgeGaugeWithNeedleFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8693, + SFSymbolBloodPressureCuffFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8694, + SFSymbolBookBadgePlus API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8695, + SFSymbolBookBadgePlusFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8696, + SFSymbolBriefcaseSensorTagRadiowavesLeftAndRight API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8697, + SFSymbolBriefcaseSensorTagRadiowavesLeftAndRightFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8698, + SFSymbolCalendarBadge API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8699, + SFSymbolCalendarBadgeLock API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8700, + SFSymbolCalendarDayTimelineLeadingCircle API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8701, + SFSymbolCalendarDayTimelineLeadingCircleFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8702, + SFSymbolCalendarDayTimelineLeftCircle API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8703, + SFSymbolCalendarDayTimelineLeftCircleFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8704, + SFSymbolCalendarDayTimelineRightCircle API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8705, + SFSymbolCalendarDayTimelineRightCircleFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8706, + SFSymbolCalendarDayTimelineTrailingCircle API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8707, + SFSymbolCalendarDayTimelineTrailingCircleFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8708, + SFSymbolCameraSensorTagRadiowavesLeftAndRight API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8709, + SFSymbolCameraSensorTagRadiowavesLeftAndRightFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8710, + SFSymbolCarRearRoadLaneDashedArrowtriangle2Outward API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8711, + SFSymbolCarWindowLeftBadgeLock API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8712, + SFSymbolCarWindowRightBadgeLock API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8713, + SFSymbolCellularbarsCircle API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8714, + SFSymbolCellularbarsCircleFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8715, + SFSymbolCharacterBookClosedBn API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8716, + SFSymbolCharacterBookClosedFillBn API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8717, + SFSymbolCharacterBookClosedFillGu API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8718, + SFSymbolCharacterBookClosedFillKn API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8719, + SFSymbolCharacterBookClosedFillMl API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8720, + SFSymbolCharacterBookClosedFillMni API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8721, + SFSymbolCharacterBookClosedFillMr API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8722, + SFSymbolCharacterBookClosedFillOr API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8723, + SFSymbolCharacterBookClosedFillPa API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8724, + SFSymbolCharacterBookClosedFillSat API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8725, + SFSymbolCharacterBookClosedFillSi API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8726, + SFSymbolCharacterBookClosedFillTa API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8727, + SFSymbolCharacterBookClosedFillTe API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8728, + SFSymbolCharacterBookClosedGu API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8729, + SFSymbolCharacterBookClosedKn API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8730, + SFSymbolCharacterBookClosedMl API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8731, + SFSymbolCharacterBookClosedMni API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8732, + SFSymbolCharacterBookClosedMr API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8733, + SFSymbolCharacterBookClosedOr API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8734, + SFSymbolCharacterBookClosedPa API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8735, + SFSymbolCharacterBookClosedSat API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8736, + SFSymbolCharacterBookClosedSi API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8737, + SFSymbolCharacterBookClosedTa API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8738, + SFSymbolCharacterBookClosedTe API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8739, + SFSymbolCharacterBubbleBn API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8740, + SFSymbolCharacterBubbleFillBn API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8741, + SFSymbolCharacterBubbleFillGu API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8742, + SFSymbolCharacterBubbleFillKn API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8743, + SFSymbolCharacterBubbleFillMl API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8744, + SFSymbolCharacterBubbleFillMni API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8745, + SFSymbolCharacterBubbleFillMr API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8746, + SFSymbolCharacterBubbleFillOr API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8747, + SFSymbolCharacterBubbleFillPa API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8748, + SFSymbolCharacterBubbleFillSat API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8749, + SFSymbolCharacterBubbleFillSi API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8750, + SFSymbolCharacterBubbleFillTa API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8751, + SFSymbolCharacterBubbleFillTe API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8752, + SFSymbolCharacterBubbleGu API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8753, + SFSymbolCharacterBubbleKn API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8754, + SFSymbolCharacterBubbleMl API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8755, + SFSymbolCharacterBubbleMni API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8756, + SFSymbolCharacterBubbleMr API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8757, + SFSymbolCharacterBubbleOr API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8758, + SFSymbolCharacterBubblePa API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8759, + SFSymbolCharacterBubbleSat API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8760, + SFSymbolCharacterBubbleSi API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8761, + SFSymbolCharacterBubbleTa API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8762, + SFSymbolCharacterBubbleTe API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8763, + SFSymbolCharacterTextJustify API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8764, + SFSymbolCharacterTextJustifyAr API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8765, + SFSymbolCharacterTextJustifyBn API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8766, + SFSymbolCharacterTextJustifyGu API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8767, + SFSymbolCharacterTextJustifyHe API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8768, + SFSymbolCharacterTextJustifyHi API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8769, + SFSymbolCharacterTextJustifyJa API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8770, + SFSymbolCharacterTextJustifyKn API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8771, + SFSymbolCharacterTextJustifyKo API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8772, + SFSymbolCharacterTextJustifyMl API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8773, + SFSymbolCharacterTextJustifyMni API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8774, + SFSymbolCharacterTextJustifyMr API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8775, + SFSymbolCharacterTextJustifyOr API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8776, + SFSymbolCharacterTextJustifyPa API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8777, + SFSymbolCharacterTextJustifySat API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8778, + SFSymbolCharacterTextJustifySi API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8779, + SFSymbolCharacterTextJustifyTa API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8780, + SFSymbolCharacterTextJustifyTe API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8781, + SFSymbolCharacterTextJustifyTh API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8782, + SFSymbolCharacterTextJustifyZh API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8783, + SFSymbolChartBarXaxisDescending API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8784, + SFSymbolCheckmarkApp API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8785, + SFSymbolCheckmarkAppFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8786, + SFSymbolCheckmarkArrowTriangleheadClockwise API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8787, + SFSymbolCheckmarkCircleBadgeAirplane API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8788, + SFSymbolCheckmarkCircleBadgeAirplaneFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8789, + SFSymbolCheckmarkCircleBadgePlus API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8790, + SFSymbolCheckmarkCircleBadgePlusFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8791, + SFSymbolCheckmarkCircleDotted API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8792, + SFSymbolCheckmarkCircleTrianglebadgeExclamationmarkFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8793, + SFSymbolCircleGrid2x2TopleftCheckmarkFilled API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8794, + SFSymbolCircleOnSquare API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8795, + SFSymbolCircleOnSquareIntersectionDotted API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8796, + SFSymbolCircleOnSquareMerge API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8797, + SFSymbolClockArrowTriangleheadClockwiseRotate90PathDotted API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8798, + SFSymbolClockBadgeAirplane API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8799, + SFSymbolClockBadgeAirplaneFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8800, + SFSymbolCoatCircle API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8801, + SFSymbolCoatCircleFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8802, + SFSymbolContextualmenuAndPointerArrow API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8803, + SFSymbolCreditcardAndNumbers API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8804, + SFSymbolCreditcardArrowTrianglehead2ClockwiseRotate90 API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8805, + SFSymbolCreditcardRewards API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8806, + SFSymbolCreditcardRewardsFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8807, + SFSymbolCubeCircle API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8808, + SFSymbolCubeCircleFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8809, + SFSymbolDesktopcomputerBadgeCheckmark API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8810, + SFSymbolDesktopcomputerBadgeShieldCheckmark API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8811, + SFSymbolDisplayAndScrewdriver API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8812, + SFSymbolDocumentOnTrash API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8813, + SFSymbolDocumentOnTrashFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8814, + SFSymbolDotCircleAndPointerArrow API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8815, + SFSymbolDotCrosshair API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8816, + SFSymbolDotsAndLineVerticalAndPointerArrowRectangle API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8817, + SFSymbolEarbudLeft API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8818, + SFSymbolEarbudRight API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8819, + SFSymbolEarbudsBoneConduction API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8820, + SFSymbolEarbudsBoneConductionLeft API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8821, + SFSymbolEarbudsBoneConductionRight API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8822, + SFSymbolEarbudsInEar API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8823, + SFSymbolEarbudsInEarLeft API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8824, + SFSymbolEarbudsInEarRight API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8825, + SFSymbolEarbudsStemless API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8826, + SFSymbolEarbudsStemlessLeft API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8827, + SFSymbolEarbudsStemlessRight API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8828, + SFSymbolEllipsisCalendar API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8829, + SFSymbolEllipsisCircleBadge API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8830, + SFSymbolEllipsisCircleBadgeFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8831, + SFSymbolEnvelopeAndHandRaised API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8832, + SFSymbolEnvelopeAndHandRaisedFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8833, + SFSymbolEnvelopeBadgeMinus API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8834, + SFSymbolEnvelopeBadgeMinusFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8835, + SFSymbolEnvelopeBadgePlus API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8836, + SFSymbolEnvelopeBadgePlusFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8837, + SFSymbolEnvelopeOpenBadgeClockFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8838, + SFSymbolEnvironments API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8839, + SFSymbolEnvironmentsCircle API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8840, + SFSymbolEnvironmentsCircleFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8841, + SFSymbolEnvironmentsFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8842, + SFSymbolEnvironmentsSlash API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8843, + SFSymbolEnvironmentsSlashCircle API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8844, + SFSymbolEnvironmentsSlashCircleFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8845, + SFSymbolEnvironmentsSlashFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8846, + SFSymbolEraserBadgeXmark API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8847, + SFSymbolEraserBadgeXmarkFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8848, + SFSymbolEraserSlash API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8849, + SFSymbolEraserSlashFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8850, + SFSymbolEraserTrianglebadgeExclamationmark API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8851, + SFSymbolEraserTrianglebadgeExclamationmarkFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8852, + SFSymbolEyeHalfClosed API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8853, + SFSymbolEyeHalfClosedFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8854, + SFSymbolFCursiveSlash API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8855, + SFSymbolFanBadgeArrowUpAndDownAndArrowLeftAndRight API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8856, + SFSymbolFanBadgeArrowUpAndDownAndArrowLeftAndRightFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8857, + SFSymbolFanCircle API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8858, + SFSymbolFanCircleFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8859, + SFSymbolFanGaugeOpen API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8860, + SFSymbolFigureSeatedSideLeftAirDistributionIndirect API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8861, + SFSymbolFigureSeatedSideLeftAirDistributionLowerAngledAndUpperAngled API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8862, + SFSymbolFigureSeatedSideLeftAirDistributionUpperAngledAndDottedlineAndLowerAngled API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8863, + SFSymbolFigureSeatedSideRightAirDistributionIndirect API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8864, + SFSymbolFigureSeatedSideRightAirDistributionLowerAngledAndUpperAngled API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8865, + SFSymbolFigureSeatedSideRightAirDistributionUpperAngledAndDottedlineAndLowerAngled API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8866, + SFSymbolFigureSeatedSideRightChildLap API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8867, + SFSymbolFigureWalkSuitcaseRolling API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8868, + SFSymbolFigureWalkSuitcaseRollingCircle API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8869, + SFSymbolFigureWalkSuitcaseRollingCircleFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8870, + SFSymbolFilemenuAndPointerArrow API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8871, + SFSymbolFilemenuAndPointerArrowRtl API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8872, + SFSymbolFinder API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8873, + SFSymbolFlameGaugeOpen API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8874, + SFSymbolFuelpumpThermometer API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8875, + SFSymbolFuelpumpThermometerFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8876, + SFSymbolGaugeChartLefthalfRighthalf API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8877, + SFSymbolGaugeChartLeftthirdTopthirdRightthird API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8878, + SFSymbolGaugeOpen API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8879, + SFSymbolGaugeOpenRighthalfDottedWithNeedleAndArrowTriangleheadBackward API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8880, + SFSymbolGlobeBadgeClock API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8881, + SFSymbolGlobeBadgeClockFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8882, + SFSymbolGlobeFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8883, + SFSymbolGraph2d API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8884, + SFSymbolGraph3d API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8885, + SFSymbolGuidepointHorizontal API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8886, + SFSymbolGuidepointVertical API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8887, + SFSymbolGuidepointVerticalArrowtriangleForward API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8888, + SFSymbolGuidepointVerticalNumbers API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8889, + SFSymbolHandThumbsdownFilledHandThumbsup API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8890, + SFSymbolHandThumbsdownHandThumbsup API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8891, + SFSymbolHandThumbsdownHandThumbsupFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8892, + SFSymbolHandThumbsdownHandThumbsupFilled API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8893, + SFSymbolHandbagSensorTagRadiowavesLeftAndRight API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8894, + SFSymbolHandbagSensorTagRadiowavesLeftAndRightFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8895, + SFSymbolHeadphonesOverEar API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8896, + SFSymbolHeadphonesSensorTagRadiowavesLeftAndRight API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8897, + SFSymbolHeadphonesSensorTagRadiowavesLeftAndRightFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8898, + SFSymbolHeartBadgeBolt API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8899, + SFSymbolHeartBadgeBoltFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8900, + SFSymbolHeartBadgeBoltSlash API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8901, + SFSymbolHeartBadgeBoltSlashFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8902, + SFSymbolHeartGaugeOpen API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8903, + SFSymbolHeatWavesCircle API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8904, + SFSymbolHeatWavesCircleFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8905, + SFSymbolHeatWavesGaugeOpen API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8906, + SFSymbolHighlighterBadgeEllipsis API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8907, + SFSymbolHomepodBadgeCheckmark API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8908, + SFSymbolHomepodBadgeCheckmarkFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8909, + SFSymbolHomepodBadgeCheckmarkFillRtl API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8910, + SFSymbolHomepodBadgeCheckmarkRtl API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8911, + SFSymbolHomepodMiniAndAppletv API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8912, + SFSymbolHomepodMiniAndAppletvFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8913, + SFSymbolHomepodMiniAndAppletvFillRtl API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8914, + SFSymbolHomepodMiniAndAppletvRtl API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8915, + SFSymbolHomepodMiniBadgeCheckmark API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8916, + SFSymbolHomepodMiniBadgeCheckmarkFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8917, + SFSymbolHomepodMiniBadgeCheckmarkFillRtl API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8918, + SFSymbolHomepodMiniBadgeCheckmarkRtl API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8919, + SFSymbolHourglassBadgeLock API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8920, + SFSymbolHumidifierAndEllipsis API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8921, + SFSymbolHumidifierAndEllipsisFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8922, + SFSymbolIcloudDashed API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8923, + SFSymbolInsetFilledBottomleadingBottomtrailingRectangle API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8924, + SFSymbolInsetFilledBottomleftBottomrightRectangle API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8925, + SFSymbolInsetFilledCircleSlash API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8926, + SFSymbolInsetFilledLeftthirdMiddlethirdRightthirdRectangle API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8927, + SFSymbolInsetFilledPano API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8928, + SFSymbolInsetFilledRectangleAndPointerArrow API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8929, + SFSymbolInsetFilledTopthirdMiddlethirdBottomthirdRectangle API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8930, + SFSymbolIpadBadgeCheckmark API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8931, + SFSymbolIpadGen1CropHomebuttonCircle API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8932, + SFSymbolIpadGen1Sizes API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8933, + SFSymbolIpadGen2Sizes API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8934, + SFSymbolIpadLandscapeAndApplewatch API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8935, + SFSymbolIpadLandscapeAndIpod API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8936, + SFSymbolIphoneAndIpod API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8937, + SFSymbolIphoneAndVisionPro API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8938, + SFSymbolIphoneBadgeCheckmark API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8939, + SFSymbolIphoneGen1CropHomebuttonCircle API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8940, + SFSymbolIphoneGen1Sizes API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8941, + SFSymbolIphoneGen2Sizes API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8942, + SFSymbolIphoneGen3Sizes API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8943, + SFSymbolIphonePatternDiagonalline API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8944, + SFSymbolIphonePatternDiagonallineOnRectanglePortraitDashed API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8945, + SFSymbolIpodAndApplewatch API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8946, + SFSymbolIpodAndVisionPro API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8947, + SFSymbolJacketCircle API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8948, + SFSymbolJacketCircleFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8949, + SFSymbolJacketSensorTagRadiowavesLeftAndRight API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8950, + SFSymbolJacketSensorTagRadiowavesLeftAndRightFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8951, + SFSymbolKeyCircle API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8952, + SFSymbolKeyCircleFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8953, + SFSymbolKeySensorTagRadiowavesLeftAndRight API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8954, + SFSymbolKeySensorTagRadiowavesLeftAndRightFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8955, + SFSymbolKeyShield API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8956, + SFSymbolKeyShieldFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8957, + SFSymbolLaptopcomputerBadgeCheckmark API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8958, + SFSymbolLineDiagonalTriangleheadUpRight API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8959, + SFSymbolLineDiagonalTriangleheadUpRightLeftDown API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8960, + SFSymbolLinesMeasurementHorizontalAlignedBottom API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8961, + SFSymbolListBulletBadgeEllipsis API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8962, + SFSymbolListDashBadgeEllipsis API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8963, + SFSymbolListDashHeaderRectangleFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8964, + SFSymbolListNumberBadgeEllipsis API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8965, + SFSymbolListNumberBadgeEllipsisHi API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8966, + SFSymbolListNumberBadgeEllipsisRtl API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8967, + SFSymbolLockBadgeCheckmark API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8968, + SFSymbolLockBadgeCheckmarkFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8969, + SFSymbolLockBadgeXmark API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8970, + SFSymbolLockBadgeXmarkFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8971, + SFSymbolLockHeart API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8972, + SFSymbolLockHeartFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8973, + SFSymbolLockRectangleDashed API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8974, + SFSymbolLockSquareDashed API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8975, + SFSymbolMacbookAndIpod API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8976, + SFSymbolMacbookBadgeCheckmark API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8977, + SFSymbolMacbookBadgeExclamationmark API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8978, + SFSymbolMacbookBadgeShieldCheckmark API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8979, + SFSymbolMacbookGen1Sizes API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8980, + SFSymbolMacbookGen2Sizes API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8981, + SFSymbolMacbookSizes API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8982, + SFSymbolMacbookTrianglebadgeExclamationmark API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8983, + SFSymbolMacminiBadgeCheckmark API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8984, + SFSymbolMacminiBadgeCheckmarkFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8985, + SFSymbolMacproGen3BadgeCkeckmark API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8986, + SFSymbolMacproGen3BadgeCkeckmarkFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8987, + SFSymbolMacstudioBadgeCheckmark API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8988, + SFSymbolMacstudioBadgeCheckmarkFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8989, + SFSymbolMacwindowAndPointerArrow API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8990, + SFSymbolMacwindowAndPointerArrowRtl API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8991, + SFSymbolMacwindowStack API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8992, + SFSymbolMinusArrowTriangleheadClockwise API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8993, + SFSymbolMinusPlusLinesMeasurementHorizontalAlignedBottom API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8994, + SFSymbolMusicNoteArrowTriangleheadClockwise API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8995, + SFSymbolMusicNoteSlash API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8996, + SFSymbolMusicNoteSquareStack API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8997, + SFSymbolMusicNoteSquareStackFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8998, + SFSymbolMusicPages API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 8999, + SFSymbolMusicPagesFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9000, + SFSymbolNumbersBn API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9001, + SFSymbolNumbersGu API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9002, + SFSymbolNumbersKm API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9003, + SFSymbolNumbersKn API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9004, + SFSymbolNumbersMl API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9005, + SFSymbolNumbersMni API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9006, + SFSymbolNumbersMr API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9007, + SFSymbolNumbersMy API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9008, + SFSymbolNumbersOr API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9009, + SFSymbolNumbersPa API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9010, + SFSymbolNumbersSat API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9011, + SFSymbolNumbersTe API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9012, + SFSymbolPadHeader API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9013, + SFSymbolPaintBucketClassic API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9014, + SFSymbolPedestrianGateClosedTrianglebadgeExclamationmark API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9015, + SFSymbolPedestrianGateOpenTrianglebadgeExclamationmark API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9016, + SFSymbolPerson2Badge API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9017, + SFSymbolPerson2BadgeFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9018, + SFSymbolPerson2Shield API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9019, + SFSymbolPerson2ShieldFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9020, + SFSymbolPersonCropCircleBadgeEllipsis API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9021, + SFSymbolPersonCropCircleBadgeEllipsisFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9022, + SFSymbolPersonSpatialaudio3dFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9023, + SFSymbolPersonSpatialaudioFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9024, + SFSymbolPersonSpatialaudioStereo3dFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9025, + SFSymbolPersonSpatialaudioStereoFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9026, + SFSymbolPersonTextRectangleTrianglebadgeExclamationmark API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9027, + SFSymbolPersonTextRectangleTrianglebadgeExclamationmarkFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9028, + SFSymbolPetCarrier API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9029, + SFSymbolPetCarrierCircle API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9030, + SFSymbolPetCarrierCircleFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9031, + SFSymbolPetCarrierFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9032, + SFSymbolPhonePause API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9033, + SFSymbolPhonePauseCircle API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9034, + SFSymbolPhonePauseCircleFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9035, + SFSymbolPhonePauseFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9036, + SFSymbolPlayDiamond API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9037, + SFSymbolPlayDiamondFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9038, + SFSymbolPlusArrowTriangleheadCounterclockwise API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9039, + SFSymbolPlusCapsule API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9040, + SFSymbolPlusCapsuleFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9041, + SFSymbolPointerArrow API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9042, + SFSymbolPointerArrowAndSquareOnSquareDashed API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9043, + SFSymbolPointerArrowClick API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9044, + SFSymbolPointerArrowClick2 API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9045, + SFSymbolPointerArrowClickBadgeClock API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9046, + SFSymbolPointerArrowIpad API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9047, + SFSymbolPointerArrowIpadAndSquareOnSquareDashed API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9048, + SFSymbolPointerArrowIpadRays API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9049, + SFSymbolPointerArrowIpadSlash API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9050, + SFSymbolPointerArrowIpadSlashSquare API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9051, + SFSymbolPointerArrowIpadSlashSquareFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9052, + SFSymbolPointerArrowIpadSquare API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9053, + SFSymbolPointerArrowIpadSquareFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9054, + SFSymbolPointerArrowMotionlines API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9055, + SFSymbolPointerArrowMotionlinesClick API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9056, + SFSymbolPointerArrowRays API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9057, + SFSymbolPointerArrowSlash API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9058, + SFSymbolPointerArrowSlashSquare API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9059, + SFSymbolPointerArrowSlashSquareFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9060, + SFSymbolPointerArrowSquare API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9061, + SFSymbolPointerArrowSquareFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9062, + SFSymbolRectangle3GroupDashed API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9063, + SFSymbolRectangleGrid1x3 API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9064, + SFSymbolRectangleGrid1x3Fill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9065, + SFSymbolRectangleLandscapeRotateSlash API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9066, + SFSymbolRectanglePortraitRotateSlash API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9067, + SFSymbolRectangleStackSlash API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9068, + SFSymbolRectangleStackSlashFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9069, + SFSymbolRepeatBadgeXmark API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9070, + SFSymbolRing API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9071, + SFSymbolRingDashed API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9072, + SFSymbolSensorRadiowavesLeftAndRight API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9073, + SFSymbolSensorRadiowavesLeftAndRightFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9074, + SFSymbolServiceDog API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9075, + SFSymbolServiceDogFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9076, + SFSymbolShoeArrowTriangleheadUpAndDown API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9077, + SFSymbolShoeArrowTriangleheadUpAndDownFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9078, + SFSymbolShoeArrowTriangleheadUpRight API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9079, + SFSymbolShoeArrowTriangleheadUpRightCircle API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9080, + SFSymbolShoeArrowTriangleheadUpRightCircleFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9081, + SFSymbolShoeArrowTriangleheadUpRightFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9082, + SFSymbolSiri API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9083, + SFSymbolSliderHorizontalBelowCircleLefthalfFilled API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9084, + SFSymbolSliderHorizontalBelowCircleLefthalfFilledInverse API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9085, + SFSymbolSliderHorizontalBelowCircleRighthalfFilled API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9086, + SFSymbolSliderHorizontalBelowCircleRighthalfFilledInverse API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9087, + SFSymbolSparkleTextClipboard API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9088, + SFSymbolSparkleTextClipboardFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9089, + SFSymbolSparkles2 API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9090, + SFSymbolSpatialCapture API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9091, + SFSymbolSpatialCaptureFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9092, + SFSymbolSpatialCaptureOnHexagon API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9093, + SFSymbolSpatialCaptureOnHexagonFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9094, + SFSymbolSpatialCaptureSlash API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9095, + SFSymbolSpatialCaptureSlashFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9096, + SFSymbolSpeakerTrianglebadgeExclamationmark API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9097, + SFSymbolSpeakerTrianglebadgeExclamationmarkFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9098, + SFSymbolSteeringwheelBadgeLock API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9099, + SFSymbolStrikethroughDouble API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9100, + SFSymbolStrokeLineDiagonal API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9101, + SFSymbolStrokeLineDiagonalSlash API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9102, + SFSymbolSuitcaseCircle API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9103, + SFSymbolSuitcaseCircleFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9104, + SFSymbolSuitcaseRollingAndFilm API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9105, + SFSymbolSuitcaseRollingAndFilmCircle API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9106, + SFSymbolSuitcaseRollingAndFilmCircleFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9107, + SFSymbolSuitcaseRollingAndFilmFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9108, + SFSymbolSuitcaseRollingAndSuitcase API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9109, + SFSymbolSuitcaseRollingAndSuitcaseCircle API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9110, + SFSymbolSuitcaseRollingAndSuitcaseCircleFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9111, + SFSymbolSuitcaseRollingAndSuitcaseFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9112, + SFSymbolSuitcaseRollingCircle API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9113, + SFSymbolSuitcaseRollingCircleFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9114, + SFSymbolTextBelowFolder API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9115, + SFSymbolTextBelowFolderFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9116, + SFSymbolTextLine2Summary API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9117, + SFSymbolTextLine2SummaryBadgeXmark API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9118, + SFSymbolTextLine3Summary API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9119, + SFSymbolTextPadHeader API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9120, + SFSymbolTextPadHeaderBadgeClock API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9121, + SFSymbolTextPadHeaderBadgeClockRtl API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9122, + SFSymbolTextPadHeaderBadgePlus API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9123, + SFSymbolTextRectangle API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9124, + SFSymbolTextRectangleFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9125, + SFSymbolTextSquareFilled API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9126, + SFSymbolTextformatNumbersMr API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9127, + SFSymbolThermometerAndEllipsis API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9128, + SFSymbolThermometerGaugeOpen API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9129, + SFSymbolThermometerTirepressure API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9130, + SFSymbolThermometerVariableBadgeClock API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9131, + SFSymbolThermometerVariableBadgePlay API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9132, + SFSymbolTicketCircle API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9133, + SFSymbolTicketCircleFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9134, + SFSymbolTramCard API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9135, + SFSymbolTramCardFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9136, + SFSymbolTrayBadge API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9137, + SFSymbolTrayBadgeFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9138, + SFSymbolUmbrellaCircle API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9139, + SFSymbolUmbrellaCircleFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9140, + SFSymbolUmbrellaGaugeOpen API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9141, + SFSymbolUmbrellaSensorTagRadiowavesLeftAndRight API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9142, + SFSymbolUmbrellaSensorTagRadiowavesLeftAndRightFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9143, + SFSymbolUnderlineDouble API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9144, + SFSymbolVentHeatWavesUpward API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9145, + SFSymbolVisionProBadgeCheckmark API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9146, + SFSymbolVisionProBadgeCheckmarkFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9147, + SFSymbolWalletSensorTagRadiowavesLeftAndRight API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9148, + SFSymbolWalletSensorTagRadiowavesLeftAndRightFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9149, + SFSymbolWaveformLow API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9150, + SFSymbolWaveformMid API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9151, + SFSymbolWifiBadgeLock API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9152, + SFSymbolXmarkCircleBadgeAirplane API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9153, + SFSymbolXmarkCircleBadgeAirplaneFill API_AVAILABLE(ios(26.0), macos(26.0), tvos(26.0), visionos(26.0), watchos(26.0)) = 9154, + + // Symbols introduced in 2025.1 + SFSymbolAirConditioner API_AVAILABLE(ios(26.1), macos(26.1), tvos(26.1), visionos(26.1), watchos(26.1)) = 9155, + SFSymbolAirConditionerSlash API_AVAILABLE(ios(26.1), macos(26.1), tvos(26.1), visionos(26.1), watchos(26.1)) = 9156, + SFSymbolArrowtriangleBackwardInsetFilledTrailingthirdRectangle API_AVAILABLE(ios(26.1), macos(26.1), tvos(26.1), visionos(26.1), watchos(26.1)) = 9157, + SFSymbolArrowtriangleDown2 API_AVAILABLE(ios(26.1), macos(26.1), tvos(26.1), visionos(26.1), watchos(26.1)) = 9158, + SFSymbolArrowtriangleDown2Fill API_AVAILABLE(ios(26.1), macos(26.1), tvos(26.1), visionos(26.1), watchos(26.1)) = 9159, + SFSymbolArrowtriangleForwardInsetFilledTrailingthirdRectangle API_AVAILABLE(ios(26.1), macos(26.1), tvos(26.1), visionos(26.1), watchos(26.1)) = 9160, + SFSymbolArrowtriangleUp2 API_AVAILABLE(ios(26.1), macos(26.1), tvos(26.1), visionos(26.1), watchos(26.1)) = 9161, + SFSymbolArrowtriangleUp2Fill API_AVAILABLE(ios(26.1), macos(26.1), tvos(26.1), visionos(26.1), watchos(26.1)) = 9162, + SFSymbolButtonHorizontalTop API_AVAILABLE(ios(26.1), macos(26.1), tvos(26.1), visionos(26.1), watchos(26.1)) = 9163, + SFSymbolButtonHorizontalTopFill API_AVAILABLE(ios(26.1), macos(26.1), tvos(26.1), visionos(26.1), watchos(26.1)) = 9164, + SFSymbolButtonVerticalLeft API_AVAILABLE(ios(26.1), macos(26.1), tvos(26.1), visionos(26.1), watchos(26.1)) = 9165, + SFSymbolButtonVerticalLeftFill API_AVAILABLE(ios(26.1), macos(26.1), tvos(26.1), visionos(26.1), watchos(26.1)) = 9166, + SFSymbolButtonVerticalRight API_AVAILABLE(ios(26.1), macos(26.1), tvos(26.1), visionos(26.1), watchos(26.1)) = 9167, + SFSymbolButtonVerticalRightFill API_AVAILABLE(ios(26.1), macos(26.1), tvos(26.1), visionos(26.1), watchos(26.1)) = 9168, + SFSymbolCameraViewfinderBadgeAutomatic API_AVAILABLE(ios(26.1), macos(26.1), tvos(26.1), visionos(26.1), watchos(26.1)) = 9169, + SFSymbolDigitalcrown API_AVAILABLE(ios(26.1), macos(26.1), tvos(26.1), visionos(26.1), watchos(26.1)) = 9170, + SFSymbolDigitalcrownFill API_AVAILABLE(ios(26.1), macos(26.1), tvos(26.1), visionos(26.1), watchos(26.1)) = 9171, + SFSymbolDigitalcrownHorizontal API_AVAILABLE(ios(26.1), macos(26.1), tvos(26.1), visionos(26.1), watchos(26.1)) = 9172, + SFSymbolDigitalcrownHorizontalFill API_AVAILABLE(ios(26.1), macos(26.1), tvos(26.1), visionos(26.1), watchos(26.1)) = 9173, + SFSymbolHeadProfileVisionProRemove API_AVAILABLE(ios(26.1), macos(26.1), tvos(26.1), visionos(26.1), watchos(26.1)) = 9174, + SFSymbolInsetFilledRectangleAndPersonFilledSlash API_AVAILABLE(ios(26.1), macos(26.1), tvos(26.1), visionos(26.1), watchos(26.1)) = 9175, + SFSymbolInsetFilledRectangleAndPersonFilledSlashRtl API_AVAILABLE(ios(26.1), macos(26.1), tvos(26.1), visionos(26.1), watchos(26.1)) = 9176, + SFSymbolRadicandSquareroot API_AVAILABLE(ios(26.1), macos(26.1), tvos(26.1), visionos(26.1), watchos(26.1)) = 9177, + SFSymbolRadicandSquarerootAr API_AVAILABLE(ios(26.1), macos(26.1), tvos(26.1), visionos(26.1), watchos(26.1)) = 9178, + SFSymbolRectangleBadgeSparkles API_AVAILABLE(ios(26.1), macos(26.1), tvos(26.1), visionos(26.1), watchos(26.1)) = 9179, + SFSymbolRectangleBadgeSparklesFill API_AVAILABLE(ios(26.1), macos(26.1), tvos(26.1), visionos(26.1), watchos(26.1)) = 9180, + SFSymbolSliderHorizontalBelowSunMin API_AVAILABLE(ios(26.1), macos(26.1), tvos(26.1), visionos(26.1), watchos(26.1)) = 9181, + SFSymbolStarRectangle API_AVAILABLE(ios(26.1), macos(26.1), tvos(26.1), visionos(26.1), watchos(26.1)) = 9182, + SFSymbolStarRectangleFill API_AVAILABLE(ios(26.1), macos(26.1), tvos(26.1), visionos(26.1), watchos(26.1)) = 9183 }; // Get string representation of SFSymbol diff --git a/ThirdParty/SFSymbolEnum/SFSymbolEnum.m b/ThirdParty/SFSymbolEnum/SFSymbolEnum.m index 8ef71eb915..d990d636bb 100644 --- a/ThirdParty/SFSymbolEnum/SFSymbolEnum.m +++ b/ThirdParty/SFSymbolEnum/SFSymbolEnum.m @@ -8546,55 +8546,704 @@ case SFSymbolBeatsPowerbeatsPro2ChargingcaseFill: return @"beats.powerbeats.pro.2.chargingcase.fill"; case SFSymbolBeatsPowerbeatsPro2Left: return @"beats.powerbeats.pro.2.left"; case SFSymbolBeatsPowerbeatsPro2Right: return @"beats.powerbeats.pro.2.right"; + case SFSymbol1Calendar: return @"1.calendar"; + case SFSymbol1CalendarAr: return @"1.calendar.ar"; + case SFSymbol1CalendarHi: return @"1.calendar.hi"; + case SFSymbol10Calendar: return @"10.calendar"; + case SFSymbol10CalendarAr: return @"10.calendar.ar"; + case SFSymbol10CalendarHi: return @"10.calendar.hi"; + case SFSymbol11Calendar: return @"11.calendar"; + case SFSymbol11CalendarAr: return @"11.calendar.ar"; + case SFSymbol11CalendarHi: return @"11.calendar.hi"; + case SFSymbol12Calendar: return @"12.calendar"; + case SFSymbol12CalendarAr: return @"12.calendar.ar"; + case SFSymbol12CalendarHi: return @"12.calendar.hi"; + case SFSymbol13Calendar: return @"13.calendar"; + case SFSymbol13CalendarAr: return @"13.calendar.ar"; + case SFSymbol13CalendarHi: return @"13.calendar.hi"; + case SFSymbol14Calendar: return @"14.calendar"; + case SFSymbol14CalendarAr: return @"14.calendar.ar"; + case SFSymbol14CalendarHi: return @"14.calendar.hi"; + case SFSymbol15Calendar: return @"15.calendar"; + case SFSymbol15CalendarAr: return @"15.calendar.ar"; + case SFSymbol15CalendarHi: return @"15.calendar.hi"; + case SFSymbol16Calendar: return @"16.calendar"; + case SFSymbol16CalendarAr: return @"16.calendar.ar"; + case SFSymbol16CalendarHi: return @"16.calendar.hi"; + case SFSymbol17Calendar: return @"17.calendar"; + case SFSymbol17CalendarAr: return @"17.calendar.ar"; + case SFSymbol17CalendarHi: return @"17.calendar.hi"; + case SFSymbol18Calendar: return @"18.calendar"; + case SFSymbol18CalendarAr: return @"18.calendar.ar"; + case SFSymbol18CalendarHi: return @"18.calendar.hi"; + case SFSymbol19Calendar: return @"19.calendar"; + case SFSymbol19CalendarAr: return @"19.calendar.ar"; + case SFSymbol19CalendarHi: return @"19.calendar.hi"; + case SFSymbol2Calendar: return @"2.calendar"; + case SFSymbol2CalendarAr: return @"2.calendar.ar"; + case SFSymbol2CalendarHi: return @"2.calendar.hi"; + case SFSymbol20Calendar: return @"20.calendar"; + case SFSymbol20CalendarAr: return @"20.calendar.ar"; + case SFSymbol20CalendarHi: return @"20.calendar.hi"; + case SFSymbol21Calendar: return @"21.calendar"; + case SFSymbol21CalendarAr: return @"21.calendar.ar"; + case SFSymbol21CalendarHi: return @"21.calendar.hi"; + case SFSymbol22Calendar: return @"22.calendar"; + case SFSymbol22CalendarAr: return @"22.calendar.ar"; + case SFSymbol22CalendarHi: return @"22.calendar.hi"; + case SFSymbol23Calendar: return @"23.calendar"; + case SFSymbol23CalendarAr: return @"23.calendar.ar"; + case SFSymbol23CalendarHi: return @"23.calendar.hi"; + case SFSymbol24Calendar: return @"24.calendar"; + case SFSymbol24CalendarAr: return @"24.calendar.ar"; + case SFSymbol24CalendarHi: return @"24.calendar.hi"; + case SFSymbol25Calendar: return @"25.calendar"; + case SFSymbol25CalendarAr: return @"25.calendar.ar"; + case SFSymbol25CalendarHi: return @"25.calendar.hi"; + case SFSymbol26Calendar: return @"26.calendar"; + case SFSymbol26CalendarAr: return @"26.calendar.ar"; + case SFSymbol26CalendarHi: return @"26.calendar.hi"; + case SFSymbol27Calendar: return @"27.calendar"; + case SFSymbol27CalendarAr: return @"27.calendar.ar"; + case SFSymbol27CalendarHi: return @"27.calendar.hi"; + case SFSymbol28Calendar: return @"28.calendar"; + case SFSymbol28CalendarAr: return @"28.calendar.ar"; + case SFSymbol28CalendarHi: return @"28.calendar.hi"; + case SFSymbol29Calendar: return @"29.calendar"; + case SFSymbol29CalendarAr: return @"29.calendar.ar"; + case SFSymbol29CalendarHi: return @"29.calendar.hi"; + case SFSymbol3Calendar: return @"3.calendar"; + case SFSymbol3CalendarAr: return @"3.calendar.ar"; + case SFSymbol3CalendarHi: return @"3.calendar.hi"; + case SFSymbol30Calendar: return @"30.calendar"; + case SFSymbol30CalendarAr: return @"30.calendar.ar"; + case SFSymbol30CalendarHi: return @"30.calendar.hi"; + case SFSymbol31Calendar: return @"31.calendar"; + case SFSymbol31CalendarAr: return @"31.calendar.ar"; + case SFSymbol31CalendarHi: return @"31.calendar.hi"; + case SFSymbol4Calendar: return @"4.calendar"; + case SFSymbol4CalendarAr: return @"4.calendar.ar"; + case SFSymbol4CalendarHi: return @"4.calendar.hi"; + case SFSymbol5Calendar: return @"5.calendar"; + case SFSymbol5CalendarAr: return @"5.calendar.ar"; + case SFSymbol5CalendarHi: return @"5.calendar.hi"; + case SFSymbol6Calendar: return @"6.calendar"; + case SFSymbol6CalendarAr: return @"6.calendar.ar"; + case SFSymbol6CalendarHi: return @"6.calendar.hi"; + case SFSymbol7Calendar: return @"7.calendar"; + case SFSymbol7CalendarAr: return @"7.calendar.ar"; + case SFSymbol7CalendarHi: return @"7.calendar.hi"; + case SFSymbol8Calendar: return @"8.calendar"; + case SFSymbol8CalendarAr: return @"8.calendar.ar"; + case SFSymbol8CalendarHi: return @"8.calendar.hi"; + case SFSymbol9Calendar: return @"9.calendar"; + case SFSymbol9CalendarAr: return @"9.calendar.ar"; + case SFSymbol9CalendarHi: return @"9.calendar.hi"; + case SFSymbolAc: return @"ac"; + case SFSymbolAcSlash: return @"ac.slash"; + case SFSymbolAirplaneCloud: return @"airplane.cloud"; + case SFSymbolAirplaneLanded: return @"airplane.landed"; + case SFSymbolAirplanePathDotted: return @"airplane.path.dotted"; + case SFSymbolAirplaneTicket: return @"airplane.ticket"; + case SFSymbolAirplaneTicketFill: return @"airplane.ticket.fill"; + case SFSymbolAirplaneUpForward: return @"airplane.up.forward"; + case SFSymbolAirplaneUpForwardApp: return @"airplane.up.forward.app"; + case SFSymbolAirplaneUpForwardAppFill: return @"airplane.up.forward.app.fill"; + case SFSymbolAirplaneUpRight: return @"airplane.up.right"; + case SFSymbolAirplaneUpRightApp: return @"airplane.up.right.app"; + case SFSymbolAirplaneUpRightAppFill: return @"airplane.up.right.app.fill"; + case SFSymbolAirplaneseat: return @"airplaneseat"; + case SFSymbolAppBackgroundDotted: return @"app.background.dotted"; + case SFSymbolAppGrid: return @"app.grid"; + case SFSymbolAppShadow: return @"app.shadow"; + case SFSymbolAppSpecular: return @"app.specular"; + case SFSymbolAppTranslucent: return @"app.translucent"; + case SFSymbolAppleBooksPages: return @"apple.books.pages"; + case SFSymbolAppleBooksPagesFill: return @"apple.books.pages.fill"; + case SFSymbolAppleClassicalPages: return @"apple.classical.pages"; + case SFSymbolAppleClassicalPagesFill: return @"apple.classical.pages.fill"; + case SFSymbolAppleHomekit: return @"apple.homekit"; + case SFSymbolApplePodcastsPages: return @"apple.podcasts.pages"; + case SFSymbolApplePodcastsPagesFill: return @"apple.podcasts.pages.fill"; + case SFSymbolAppletvBadgeCheckmark: return @"appletv.badge.checkmark"; + case SFSymbolAppletvBadgeCheckmarkFill: return @"appletv.badge.checkmark.fill"; + case SFSymbolAppletvBadgeExclamationmark: return @"appletv.badge.exclamationmark"; + case SFSymbolAppletvBadgeExclamationmarkFill: return @"appletv.badge.exclamationmark.fill"; + case SFSymbolApplewatchBadgeCheckmark: return @"applewatch.badge.checkmark"; + case SFSymbolApplewatchBadgeExclamationmark: return @"applewatch.badge.exclamationmark"; + case SFSymbolAppsIpadBadgeCheckmark: return @"apps.ipad.badge.checkmark"; + case SFSymbolAppsIpadBadgeCheckmarkRtl: return @"apps.ipad.badge.checkmark.rtl"; + case SFSymbolAppsIpadBadgePlus: return @"apps.ipad.badge.plus"; + case SFSymbolAppsIpadOnRectanglePortraitDashed: return @"apps.ipad.on.rectangle.portrait.dashed"; + case SFSymbolAppsIpadOnRectanglePortraitDashedRtl: return @"apps.ipad.on.rectangle.portrait.dashed.rtl"; + case SFSymbolAppsIphoneBadgeCheckmark: return @"apps.iphone.badge.checkmark"; + case SFSymbolAppsIphoneBadgeCheckmarkRtl: return @"apps.iphone.badge.checkmark.rtl"; + case SFSymbolAqiMediumGaugeOpen: return @"aqi.medium.gauge.open"; + case SFSymbolArrowDownCircleBadgePause: return @"arrow.down.circle.badge.pause"; + case SFSymbolArrowDownCircleBadgePauseFill: return @"arrow.down.circle.badge.pause.fill"; + case SFSymbolArrowDownCircleBadgeXmark: return @"arrow.down.circle.badge.xmark"; + case SFSymbolArrowDownCircleBadgeXmarkFill: return @"arrow.down.circle.badge.xmark.fill"; + case SFSymbolArrowForwardFolder: return @"arrow.forward.folder"; + case SFSymbolArrowForwardFolderFill: return @"arrow.forward.folder.fill"; + case SFSymbolArrowForwardFolderFillRtl: return @"arrow.forward.folder.fill.rtl"; + case SFSymbolArrowForwardFolderRtl: return @"arrow.forward.folder.rtl"; + case SFSymbolArrowTriangleheadTurnUpRight: return @"arrow.trianglehead.turn.up.right"; + case SFSymbolArrowUpFolder: return @"arrow.up.folder"; + case SFSymbolArrowUpFolderFill: return @"arrow.up.folder.fill"; + case SFSymbolBackpackSensorTagRadiowavesLeftAndRight: return @"backpack.sensor.tag.radiowaves.left.and.right"; + case SFSymbolBackpackSensorTagRadiowavesLeftAndRightFill: return @"backpack.sensor.tag.radiowaves.left.and.right.fill"; + case SFSymbolBedDoubleBadgeCheckmark: return @"bed.double.badge.checkmark"; + case SFSymbolBedDoubleBadgeCheckmarkFill: return @"bed.double.badge.checkmark.fill"; + case SFSymbolBellBadgeWaveformSlash: return @"bell.badge.waveform.slash"; + case SFSymbolBellBadgeWaveformSlashFill: return @"bell.badge.waveform.slash.fill"; + case SFSymbolBicycleSensorTagRadiowavesLeftAndRight: return @"bicycle.sensor.tag.radiowaves.left.and.right"; + case SFSymbolBicycleSensorTagRadiowavesLeftAndRightFill: return @"bicycle.sensor.tag.radiowaves.left.and.right.fill"; + case SFSymbolBloodPressureCuff: return @"blood.pressure.cuff"; + case SFSymbolBloodPressureCuffBadgeGaugeWithNeedle: return @"blood.pressure.cuff.badge.gauge.with.needle"; + case SFSymbolBloodPressureCuffBadgeGaugeWithNeedleFill: return @"blood.pressure.cuff.badge.gauge.with.needle.fill"; + case SFSymbolBloodPressureCuffFill: return @"blood.pressure.cuff.fill"; + case SFSymbolBookBadgePlus: return @"book.badge.plus"; + case SFSymbolBookBadgePlusFill: return @"book.badge.plus.fill"; + case SFSymbolBriefcaseSensorTagRadiowavesLeftAndRight: return @"briefcase.sensor.tag.radiowaves.left.and.right"; + case SFSymbolBriefcaseSensorTagRadiowavesLeftAndRightFill: return @"briefcase.sensor.tag.radiowaves.left.and.right.fill"; + case SFSymbolCalendarBadge: return @"calendar.badge"; + case SFSymbolCalendarBadgeLock: return @"calendar.badge.lock"; + case SFSymbolCalendarDayTimelineLeadingCircle: return @"calendar.day.timeline.leading.circle"; + case SFSymbolCalendarDayTimelineLeadingCircleFill: return @"calendar.day.timeline.leading.circle.fill"; + case SFSymbolCalendarDayTimelineLeftCircle: return @"calendar.day.timeline.left.circle"; + case SFSymbolCalendarDayTimelineLeftCircleFill: return @"calendar.day.timeline.left.circle.fill"; + case SFSymbolCalendarDayTimelineRightCircle: return @"calendar.day.timeline.right.circle"; + case SFSymbolCalendarDayTimelineRightCircleFill: return @"calendar.day.timeline.right.circle.fill"; + case SFSymbolCalendarDayTimelineTrailingCircle: return @"calendar.day.timeline.trailing.circle"; + case SFSymbolCalendarDayTimelineTrailingCircleFill: return @"calendar.day.timeline.trailing.circle.fill"; + case SFSymbolCameraSensorTagRadiowavesLeftAndRight: return @"camera.sensor.tag.radiowaves.left.and.right"; + case SFSymbolCameraSensorTagRadiowavesLeftAndRightFill: return @"camera.sensor.tag.radiowaves.left.and.right.fill"; + case SFSymbolCarRearRoadLaneDashedArrowtriangle2Outward: return @"car.rear.road.lane.dashed.arrowtriangle.2.outward"; + case SFSymbolCarWindowLeftBadgeLock: return @"car.window.left.badge.lock"; + case SFSymbolCarWindowRightBadgeLock: return @"car.window.right.badge.lock"; + case SFSymbolCellularbarsCircle: return @"cellularbars.circle"; + case SFSymbolCellularbarsCircleFill: return @"cellularbars.circle.fill"; + case SFSymbolCharacterBookClosedBn: return @"character.book.closed.bn"; + case SFSymbolCharacterBookClosedFillBn: return @"character.book.closed.fill.bn"; + case SFSymbolCharacterBookClosedFillGu: return @"character.book.closed.fill.gu"; + case SFSymbolCharacterBookClosedFillKn: return @"character.book.closed.fill.kn"; + case SFSymbolCharacterBookClosedFillMl: return @"character.book.closed.fill.ml"; + case SFSymbolCharacterBookClosedFillMni: return @"character.book.closed.fill.mni"; + case SFSymbolCharacterBookClosedFillMr: return @"character.book.closed.fill.mr"; + case SFSymbolCharacterBookClosedFillOr: return @"character.book.closed.fill.or"; + case SFSymbolCharacterBookClosedFillPa: return @"character.book.closed.fill.pa"; + case SFSymbolCharacterBookClosedFillSat: return @"character.book.closed.fill.sat"; + case SFSymbolCharacterBookClosedFillSi: return @"character.book.closed.fill.si"; + case SFSymbolCharacterBookClosedFillTa: return @"character.book.closed.fill.ta"; + case SFSymbolCharacterBookClosedFillTe: return @"character.book.closed.fill.te"; + case SFSymbolCharacterBookClosedGu: return @"character.book.closed.gu"; + case SFSymbolCharacterBookClosedKn: return @"character.book.closed.kn"; + case SFSymbolCharacterBookClosedMl: return @"character.book.closed.ml"; + case SFSymbolCharacterBookClosedMni: return @"character.book.closed.mni"; + case SFSymbolCharacterBookClosedMr: return @"character.book.closed.mr"; + case SFSymbolCharacterBookClosedOr: return @"character.book.closed.or"; + case SFSymbolCharacterBookClosedPa: return @"character.book.closed.pa"; + case SFSymbolCharacterBookClosedSat: return @"character.book.closed.sat"; + case SFSymbolCharacterBookClosedSi: return @"character.book.closed.si"; + case SFSymbolCharacterBookClosedTa: return @"character.book.closed.ta"; + case SFSymbolCharacterBookClosedTe: return @"character.book.closed.te"; + case SFSymbolCharacterBubbleBn: return @"character.bubble.bn"; + case SFSymbolCharacterBubbleFillBn: return @"character.bubble.fill.bn"; + case SFSymbolCharacterBubbleFillGu: return @"character.bubble.fill.gu"; + case SFSymbolCharacterBubbleFillKn: return @"character.bubble.fill.kn"; + case SFSymbolCharacterBubbleFillMl: return @"character.bubble.fill.ml"; + case SFSymbolCharacterBubbleFillMni: return @"character.bubble.fill.mni"; + case SFSymbolCharacterBubbleFillMr: return @"character.bubble.fill.mr"; + case SFSymbolCharacterBubbleFillOr: return @"character.bubble.fill.or"; + case SFSymbolCharacterBubbleFillPa: return @"character.bubble.fill.pa"; + case SFSymbolCharacterBubbleFillSat: return @"character.bubble.fill.sat"; + case SFSymbolCharacterBubbleFillSi: return @"character.bubble.fill.si"; + case SFSymbolCharacterBubbleFillTa: return @"character.bubble.fill.ta"; + case SFSymbolCharacterBubbleFillTe: return @"character.bubble.fill.te"; + case SFSymbolCharacterBubbleGu: return @"character.bubble.gu"; + case SFSymbolCharacterBubbleKn: return @"character.bubble.kn"; + case SFSymbolCharacterBubbleMl: return @"character.bubble.ml"; + case SFSymbolCharacterBubbleMni: return @"character.bubble.mni"; + case SFSymbolCharacterBubbleMr: return @"character.bubble.mr"; + case SFSymbolCharacterBubbleOr: return @"character.bubble.or"; + case SFSymbolCharacterBubblePa: return @"character.bubble.pa"; + case SFSymbolCharacterBubbleSat: return @"character.bubble.sat"; + case SFSymbolCharacterBubbleSi: return @"character.bubble.si"; + case SFSymbolCharacterBubbleTa: return @"character.bubble.ta"; + case SFSymbolCharacterBubbleTe: return @"character.bubble.te"; + case SFSymbolCharacterTextJustify: return @"character.text.justify"; + case SFSymbolCharacterTextJustifyAr: return @"character.text.justify.ar"; + case SFSymbolCharacterTextJustifyBn: return @"character.text.justify.bn"; + case SFSymbolCharacterTextJustifyGu: return @"character.text.justify.gu"; + case SFSymbolCharacterTextJustifyHe: return @"character.text.justify.he"; + case SFSymbolCharacterTextJustifyHi: return @"character.text.justify.hi"; + case SFSymbolCharacterTextJustifyJa: return @"character.text.justify.ja"; + case SFSymbolCharacterTextJustifyKn: return @"character.text.justify.kn"; + case SFSymbolCharacterTextJustifyKo: return @"character.text.justify.ko"; + case SFSymbolCharacterTextJustifyMl: return @"character.text.justify.ml"; + case SFSymbolCharacterTextJustifyMni: return @"character.text.justify.mni"; + case SFSymbolCharacterTextJustifyMr: return @"character.text.justify.mr"; + case SFSymbolCharacterTextJustifyOr: return @"character.text.justify.or"; + case SFSymbolCharacterTextJustifyPa: return @"character.text.justify.pa"; + case SFSymbolCharacterTextJustifySat: return @"character.text.justify.sat"; + case SFSymbolCharacterTextJustifySi: return @"character.text.justify.si"; + case SFSymbolCharacterTextJustifyTa: return @"character.text.justify.ta"; + case SFSymbolCharacterTextJustifyTe: return @"character.text.justify.te"; + case SFSymbolCharacterTextJustifyTh: return @"character.text.justify.th"; + case SFSymbolCharacterTextJustifyZh: return @"character.text.justify.zh"; + case SFSymbolChartBarXaxisDescending: return @"chart.bar.xaxis.descending"; + case SFSymbolCheckmarkApp: return @"checkmark.app"; + case SFSymbolCheckmarkAppFill: return @"checkmark.app.fill"; + case SFSymbolCheckmarkArrowTriangleheadClockwise: return @"checkmark.arrow.trianglehead.clockwise"; + case SFSymbolCheckmarkCircleBadgeAirplane: return @"checkmark.circle.badge.airplane"; + case SFSymbolCheckmarkCircleBadgeAirplaneFill: return @"checkmark.circle.badge.airplane.fill"; + case SFSymbolCheckmarkCircleBadgePlus: return @"checkmark.circle.badge.plus"; + case SFSymbolCheckmarkCircleBadgePlusFill: return @"checkmark.circle.badge.plus.fill"; + case SFSymbolCheckmarkCircleDotted: return @"checkmark.circle.dotted"; + case SFSymbolCheckmarkCircleTrianglebadgeExclamationmarkFill: return @"checkmark.circle.trianglebadge.exclamationmark.fill"; + case SFSymbolCircleGrid2x2TopleftCheckmarkFilled: return @"circle.grid.2x2.topleft.checkmark.filled"; + case SFSymbolCircleOnSquare: return @"circle.on.square"; + case SFSymbolCircleOnSquareIntersectionDotted: return @"circle.on.square.intersection.dotted"; + case SFSymbolCircleOnSquareMerge: return @"circle.on.square.merge"; + case SFSymbolClockArrowTriangleheadClockwiseRotate90PathDotted: return @"clock.arrow.trianglehead.clockwise.rotate.90.path.dotted"; + case SFSymbolClockBadgeAirplane: return @"clock.badge.airplane"; + case SFSymbolClockBadgeAirplaneFill: return @"clock.badge.airplane.fill"; + case SFSymbolCoatCircle: return @"coat.circle"; + case SFSymbolCoatCircleFill: return @"coat.circle.fill"; + case SFSymbolContextualmenuAndPointerArrow: return @"contextualmenu.and.pointer.arrow"; + case SFSymbolCreditcardAndNumbers: return @"creditcard.and.numbers"; + case SFSymbolCreditcardArrowTrianglehead2ClockwiseRotate90: return @"creditcard.arrow.trianglehead.2.clockwise.rotate.90"; + case SFSymbolCreditcardRewards: return @"creditcard.rewards"; + case SFSymbolCreditcardRewardsFill: return @"creditcard.rewards.fill"; + case SFSymbolCubeCircle: return @"cube.circle"; + case SFSymbolCubeCircleFill: return @"cube.circle.fill"; + case SFSymbolDesktopcomputerBadgeCheckmark: return @"desktopcomputer.badge.checkmark"; + case SFSymbolDesktopcomputerBadgeShieldCheckmark: return @"desktopcomputer.badge.shield.checkmark"; + case SFSymbolDisplayAndScrewdriver: return @"display.and.screwdriver"; + case SFSymbolDocumentOnTrash: return @"document.on.trash"; + case SFSymbolDocumentOnTrashFill: return @"document.on.trash.fill"; + case SFSymbolDotCircleAndPointerArrow: return @"dot.circle.and.pointer.arrow"; + case SFSymbolDotCrosshair: return @"dot.crosshair"; + case SFSymbolDotsAndLineVerticalAndPointerArrowRectangle: return @"dots.and.line.vertical.and.pointer.arrow.rectangle"; + case SFSymbolEarbudLeft: return @"earbud.left"; + case SFSymbolEarbudRight: return @"earbud.right"; + case SFSymbolEarbudsBoneConduction: return @"earbuds.bone.conduction"; + case SFSymbolEarbudsBoneConductionLeft: return @"earbuds.bone.conduction.left"; + case SFSymbolEarbudsBoneConductionRight: return @"earbuds.bone.conduction.right"; + case SFSymbolEarbudsInEar: return @"earbuds.in.ear"; + case SFSymbolEarbudsInEarLeft: return @"earbuds.in.ear.left"; + case SFSymbolEarbudsInEarRight: return @"earbuds.in.ear.right"; + case SFSymbolEarbudsStemless: return @"earbuds.stemless"; + case SFSymbolEarbudsStemlessLeft: return @"earbuds.stemless.left"; + case SFSymbolEarbudsStemlessRight: return @"earbuds.stemless.right"; + case SFSymbolEllipsisCalendar: return @"ellipsis.calendar"; + case SFSymbolEllipsisCircleBadge: return @"ellipsis.circle.badge"; + case SFSymbolEllipsisCircleBadgeFill: return @"ellipsis.circle.badge.fill"; + case SFSymbolEnvelopeAndHandRaised: return @"envelope.and.hand.raised"; + case SFSymbolEnvelopeAndHandRaisedFill: return @"envelope.and.hand.raised.fill"; + case SFSymbolEnvelopeBadgeMinus: return @"envelope.badge.minus"; + case SFSymbolEnvelopeBadgeMinusFill: return @"envelope.badge.minus.fill"; + case SFSymbolEnvelopeBadgePlus: return @"envelope.badge.plus"; + case SFSymbolEnvelopeBadgePlusFill: return @"envelope.badge.plus.fill"; + case SFSymbolEnvelopeOpenBadgeClockFill: return @"envelope.open.badge.clock.fill"; + case SFSymbolEnvironments: return @"environments"; + case SFSymbolEnvironmentsCircle: return @"environments.circle"; + case SFSymbolEnvironmentsCircleFill: return @"environments.circle.fill"; + case SFSymbolEnvironmentsFill: return @"environments.fill"; + case SFSymbolEnvironmentsSlash: return @"environments.slash"; + case SFSymbolEnvironmentsSlashCircle: return @"environments.slash.circle"; + case SFSymbolEnvironmentsSlashCircleFill: return @"environments.slash.circle.fill"; + case SFSymbolEnvironmentsSlashFill: return @"environments.slash.fill"; + case SFSymbolEraserBadgeXmark: return @"eraser.badge.xmark"; + case SFSymbolEraserBadgeXmarkFill: return @"eraser.badge.xmark.fill"; + case SFSymbolEraserSlash: return @"eraser.slash"; + case SFSymbolEraserSlashFill: return @"eraser.slash.fill"; + case SFSymbolEraserTrianglebadgeExclamationmark: return @"eraser.trianglebadge.exclamationmark"; + case SFSymbolEraserTrianglebadgeExclamationmarkFill: return @"eraser.trianglebadge.exclamationmark.fill"; + case SFSymbolEyeHalfClosed: return @"eye.half.closed"; + case SFSymbolEyeHalfClosedFill: return @"eye.half.closed.fill"; + case SFSymbolFCursiveSlash: return @"f.cursive.slash"; + case SFSymbolFanBadgeArrowUpAndDownAndArrowLeftAndRight: return @"fan.badge.arrow.up.and.down.and.arrow.left.and.right"; + case SFSymbolFanBadgeArrowUpAndDownAndArrowLeftAndRightFill: return @"fan.badge.arrow.up.and.down.and.arrow.left.and.right.fill"; + case SFSymbolFanCircle: return @"fan.circle"; + case SFSymbolFanCircleFill: return @"fan.circle.fill"; + case SFSymbolFanGaugeOpen: return @"fan.gauge.open"; + case SFSymbolFigureSeatedSideLeftAirDistributionIndirect: return @"figure.seated.side.left.air.distribution.indirect"; + case SFSymbolFigureSeatedSideLeftAirDistributionLowerAngledAndUpperAngled: return @"figure.seated.side.left.air.distribution.lower.angled.and.upper.angled"; + case SFSymbolFigureSeatedSideLeftAirDistributionUpperAngledAndDottedlineAndLowerAngled: return @"figure.seated.side.left.air.distribution.upper.angled.and.dottedline.and.lower.angled"; + case SFSymbolFigureSeatedSideRightAirDistributionIndirect: return @"figure.seated.side.right.air.distribution.indirect"; + case SFSymbolFigureSeatedSideRightAirDistributionLowerAngledAndUpperAngled: return @"figure.seated.side.right.air.distribution.lower.angled.and.upper.angled"; + case SFSymbolFigureSeatedSideRightAirDistributionUpperAngledAndDottedlineAndLowerAngled: return @"figure.seated.side.right.air.distribution.upper.angled.and.dottedline.and.lower.angled"; + case SFSymbolFigureSeatedSideRightChildLap: return @"figure.seated.side.right.child.lap"; + case SFSymbolFigureWalkSuitcaseRolling: return @"figure.walk.suitcase.rolling"; + case SFSymbolFigureWalkSuitcaseRollingCircle: return @"figure.walk.suitcase.rolling.circle"; + case SFSymbolFigureWalkSuitcaseRollingCircleFill: return @"figure.walk.suitcase.rolling.circle.fill"; + case SFSymbolFilemenuAndPointerArrow: return @"filemenu.and.pointer.arrow"; + case SFSymbolFilemenuAndPointerArrowRtl: return @"filemenu.and.pointer.arrow.rtl"; + case SFSymbolFinder: return @"finder"; + case SFSymbolFlameGaugeOpen: return @"flame.gauge.open"; + case SFSymbolFuelpumpThermometer: return @"fuelpump.thermometer"; + case SFSymbolFuelpumpThermometerFill: return @"fuelpump.thermometer.fill"; + case SFSymbolGaugeChartLefthalfRighthalf: return @"gauge.chart.lefthalf.righthalf"; + case SFSymbolGaugeChartLeftthirdTopthirdRightthird: return @"gauge.chart.leftthird.topthird.rightthird"; + case SFSymbolGaugeOpen: return @"gauge.open"; + case SFSymbolGaugeOpenRighthalfDottedWithNeedleAndArrowTriangleheadBackward: return @"gauge.open.righthalf.dotted.with.needle.and.arrow.trianglehead.backward"; + case SFSymbolGlobeBadgeClock: return @"globe.badge.clock"; + case SFSymbolGlobeBadgeClockFill: return @"globe.badge.clock.fill"; + case SFSymbolGlobeFill: return @"globe.fill"; + case SFSymbolGraph2d: return @"graph.2d"; + case SFSymbolGraph3d: return @"graph.3d"; + case SFSymbolGuidepointHorizontal: return @"guidepoint.horizontal"; + case SFSymbolGuidepointVertical: return @"guidepoint.vertical"; + case SFSymbolGuidepointVerticalArrowtriangleForward: return @"guidepoint.vertical.arrowtriangle.forward"; + case SFSymbolGuidepointVerticalNumbers: return @"guidepoint.vertical.numbers"; + case SFSymbolHandThumbsdownFilledHandThumbsup: return @"hand.thumbsdown.filled.hand.thumbsup"; + case SFSymbolHandThumbsdownHandThumbsup: return @"hand.thumbsdown.hand.thumbsup"; + case SFSymbolHandThumbsdownHandThumbsupFill: return @"hand.thumbsdown.hand.thumbsup.fill"; + case SFSymbolHandThumbsdownHandThumbsupFilled: return @"hand.thumbsdown.hand.thumbsup.filled"; + case SFSymbolHandbagSensorTagRadiowavesLeftAndRight: return @"handbag.sensor.tag.radiowaves.left.and.right"; + case SFSymbolHandbagSensorTagRadiowavesLeftAndRightFill: return @"handbag.sensor.tag.radiowaves.left.and.right.fill"; + case SFSymbolHeadphonesOverEar: return @"headphones.over.ear"; + case SFSymbolHeadphonesSensorTagRadiowavesLeftAndRight: return @"headphones.sensor.tag.radiowaves.left.and.right"; + case SFSymbolHeadphonesSensorTagRadiowavesLeftAndRightFill: return @"headphones.sensor.tag.radiowaves.left.and.right.fill"; + case SFSymbolHeartBadgeBolt: return @"heart.badge.bolt"; + case SFSymbolHeartBadgeBoltFill: return @"heart.badge.bolt.fill"; + case SFSymbolHeartBadgeBoltSlash: return @"heart.badge.bolt.slash"; + case SFSymbolHeartBadgeBoltSlashFill: return @"heart.badge.bolt.slash.fill"; + case SFSymbolHeartGaugeOpen: return @"heart.gauge.open"; + case SFSymbolHeatWavesCircle: return @"heat.waves.circle"; + case SFSymbolHeatWavesCircleFill: return @"heat.waves.circle.fill"; + case SFSymbolHeatWavesGaugeOpen: return @"heat.waves.gauge.open"; + case SFSymbolHighlighterBadgeEllipsis: return @"highlighter.badge.ellipsis"; + case SFSymbolHomepodBadgeCheckmark: return @"homepod.badge.checkmark"; + case SFSymbolHomepodBadgeCheckmarkFill: return @"homepod.badge.checkmark.fill"; + case SFSymbolHomepodBadgeCheckmarkFillRtl: return @"homepod.badge.checkmark.fill.rtl"; + case SFSymbolHomepodBadgeCheckmarkRtl: return @"homepod.badge.checkmark.rtl"; + case SFSymbolHomepodMiniAndAppletv: return @"homepod.mini.and.appletv"; + case SFSymbolHomepodMiniAndAppletvFill: return @"homepod.mini.and.appletv.fill"; + case SFSymbolHomepodMiniAndAppletvFillRtl: return @"homepod.mini.and.appletv.fill.rtl"; + case SFSymbolHomepodMiniAndAppletvRtl: return @"homepod.mini.and.appletv.rtl"; + case SFSymbolHomepodMiniBadgeCheckmark: return @"homepod.mini.badge.checkmark"; + case SFSymbolHomepodMiniBadgeCheckmarkFill: return @"homepod.mini.badge.checkmark.fill"; + case SFSymbolHomepodMiniBadgeCheckmarkFillRtl: return @"homepod.mini.badge.checkmark.fill.rtl"; + case SFSymbolHomepodMiniBadgeCheckmarkRtl: return @"homepod.mini.badge.checkmark.rtl"; + case SFSymbolHourglassBadgeLock: return @"hourglass.badge.lock"; + case SFSymbolHumidifierAndEllipsis: return @"humidifier.and.ellipsis"; + case SFSymbolHumidifierAndEllipsisFill: return @"humidifier.and.ellipsis.fill"; + case SFSymbolIcloudDashed: return @"icloud.dashed"; + case SFSymbolInsetFilledBottomleadingBottomtrailingRectangle: return @"inset.filled.bottomleading.bottomtrailing.rectangle"; + case SFSymbolInsetFilledBottomleftBottomrightRectangle: return @"inset.filled.bottomleft.bottomright.rectangle"; + case SFSymbolInsetFilledCircleSlash: return @"inset.filled.circle.slash"; + case SFSymbolInsetFilledLeftthirdMiddlethirdRightthirdRectangle: return @"inset.filled.leftthird.middlethird.rightthird.rectangle"; + case SFSymbolInsetFilledPano: return @"inset.filled.pano"; + case SFSymbolInsetFilledRectangleAndPointerArrow: return @"inset.filled.rectangle.and.pointer.arrow"; + case SFSymbolInsetFilledTopthirdMiddlethirdBottomthirdRectangle: return @"inset.filled.topthird.middlethird.bottomthird.rectangle"; + case SFSymbolIpadBadgeCheckmark: return @"ipad.badge.checkmark"; + case SFSymbolIpadGen1CropHomebuttonCircle: return @"ipad.gen1.crop.homebutton.circle"; + case SFSymbolIpadGen1Sizes: return @"ipad.gen1.sizes"; + case SFSymbolIpadGen2Sizes: return @"ipad.gen2.sizes"; + case SFSymbolIpadLandscapeAndApplewatch: return @"ipad.landscape.and.applewatch"; + case SFSymbolIpadLandscapeAndIpod: return @"ipad.landscape.and.ipod"; + case SFSymbolIphoneAndIpod: return @"iphone.and.ipod"; + case SFSymbolIphoneAndVisionPro: return @"iphone.and.vision.pro"; + case SFSymbolIphoneBadgeCheckmark: return @"iphone.badge.checkmark"; + case SFSymbolIphoneGen1CropHomebuttonCircle: return @"iphone.gen1.crop.homebutton.circle"; + case SFSymbolIphoneGen1Sizes: return @"iphone.gen1.sizes"; + case SFSymbolIphoneGen2Sizes: return @"iphone.gen2.sizes"; + case SFSymbolIphoneGen3Sizes: return @"iphone.gen3.sizes"; + case SFSymbolIphonePatternDiagonalline: return @"iphone.pattern.diagonalline"; + case SFSymbolIphonePatternDiagonallineOnRectanglePortraitDashed: return @"iphone.pattern.diagonalline.on.rectangle.portrait.dashed"; + case SFSymbolIpodAndApplewatch: return @"ipod.and.applewatch"; + case SFSymbolIpodAndVisionPro: return @"ipod.and.vision.pro"; + case SFSymbolJacketCircle: return @"jacket.circle"; + case SFSymbolJacketCircleFill: return @"jacket.circle.fill"; + case SFSymbolJacketSensorTagRadiowavesLeftAndRight: return @"jacket.sensor.tag.radiowaves.left.and.right"; + case SFSymbolJacketSensorTagRadiowavesLeftAndRightFill: return @"jacket.sensor.tag.radiowaves.left.and.right.fill"; + case SFSymbolKeyCircle: return @"key.circle"; + case SFSymbolKeyCircleFill: return @"key.circle.fill"; + case SFSymbolKeySensorTagRadiowavesLeftAndRight: return @"key.sensor.tag.radiowaves.left.and.right"; + case SFSymbolKeySensorTagRadiowavesLeftAndRightFill: return @"key.sensor.tag.radiowaves.left.and.right.fill"; + case SFSymbolKeyShield: return @"key.shield"; + case SFSymbolKeyShieldFill: return @"key.shield.fill"; + case SFSymbolLaptopcomputerBadgeCheckmark: return @"laptopcomputer.badge.checkmark"; + case SFSymbolLineDiagonalTriangleheadUpRight: return @"line.diagonal.trianglehead.up.right"; + case SFSymbolLineDiagonalTriangleheadUpRightLeftDown: return @"line.diagonal.trianglehead.up.right.left.down"; + case SFSymbolLinesMeasurementHorizontalAlignedBottom: return @"lines.measurement.horizontal.aligned.bottom"; + case SFSymbolListBulletBadgeEllipsis: return @"list.bullet.badge.ellipsis"; + case SFSymbolListDashBadgeEllipsis: return @"list.dash.badge.ellipsis"; + case SFSymbolListDashHeaderRectangleFill: return @"list.dash.header.rectangle.fill"; + case SFSymbolListNumberBadgeEllipsis: return @"list.number.badge.ellipsis"; + case SFSymbolListNumberBadgeEllipsisHi: return @"list.number.badge.ellipsis.hi"; + case SFSymbolListNumberBadgeEllipsisRtl: return @"list.number.badge.ellipsis.rtl"; + case SFSymbolLockBadgeCheckmark: return @"lock.badge.checkmark"; + case SFSymbolLockBadgeCheckmarkFill: return @"lock.badge.checkmark.fill"; + case SFSymbolLockBadgeXmark: return @"lock.badge.xmark"; + case SFSymbolLockBadgeXmarkFill: return @"lock.badge.xmark.fill"; + case SFSymbolLockHeart: return @"lock.heart"; + case SFSymbolLockHeartFill: return @"lock.heart.fill"; + case SFSymbolLockRectangleDashed: return @"lock.rectangle.dashed"; + case SFSymbolLockSquareDashed: return @"lock.square.dashed"; + case SFSymbolMacbookAndIpod: return @"macbook.and.ipod"; + case SFSymbolMacbookBadgeCheckmark: return @"macbook.badge.checkmark"; + case SFSymbolMacbookBadgeExclamationmark: return @"macbook.badge.exclamationmark"; + case SFSymbolMacbookBadgeShieldCheckmark: return @"macbook.badge.shield.checkmark"; + case SFSymbolMacbookGen1Sizes: return @"macbook.gen1.sizes"; + case SFSymbolMacbookGen2Sizes: return @"macbook.gen2.sizes"; + case SFSymbolMacbookSizes: return @"macbook.sizes"; + case SFSymbolMacbookTrianglebadgeExclamationmark: return @"macbook.trianglebadge.exclamationmark"; + case SFSymbolMacminiBadgeCheckmark: return @"macmini.badge.checkmark"; + case SFSymbolMacminiBadgeCheckmarkFill: return @"macmini.badge.checkmark.fill"; + case SFSymbolMacproGen3BadgeCkeckmark: return @"macpro.gen3.badge.ckeckmark"; + case SFSymbolMacproGen3BadgeCkeckmarkFill: return @"macpro.gen3.badge.ckeckmark.fill"; + case SFSymbolMacstudioBadgeCheckmark: return @"macstudio.badge.checkmark"; + case SFSymbolMacstudioBadgeCheckmarkFill: return @"macstudio.badge.checkmark.fill"; + case SFSymbolMacwindowAndPointerArrow: return @"macwindow.and.pointer.arrow"; + case SFSymbolMacwindowAndPointerArrowRtl: return @"macwindow.and.pointer.arrow.rtl"; + case SFSymbolMacwindowStack: return @"macwindow.stack"; + case SFSymbolMinusArrowTriangleheadClockwise: return @"minus.arrow.trianglehead.clockwise"; + case SFSymbolMinusPlusLinesMeasurementHorizontalAlignedBottom: return @"minus.plus.lines.measurement.horizontal.aligned.bottom"; + case SFSymbolMusicNoteArrowTriangleheadClockwise: return @"music.note.arrow.trianglehead.clockwise"; + case SFSymbolMusicNoteSlash: return @"music.note.slash"; + case SFSymbolMusicNoteSquareStack: return @"music.note.square.stack"; + case SFSymbolMusicNoteSquareStackFill: return @"music.note.square.stack.fill"; + case SFSymbolMusicPages: return @"music.pages"; + case SFSymbolMusicPagesFill: return @"music.pages.fill"; + case SFSymbolNumbersBn: return @"numbers.bn"; + case SFSymbolNumbersGu: return @"numbers.gu"; + case SFSymbolNumbersKm: return @"numbers.km"; + case SFSymbolNumbersKn: return @"numbers.kn"; + case SFSymbolNumbersMl: return @"numbers.ml"; + case SFSymbolNumbersMni: return @"numbers.mni"; + case SFSymbolNumbersMr: return @"numbers.mr"; + case SFSymbolNumbersMy: return @"numbers.my"; + case SFSymbolNumbersOr: return @"numbers.or"; + case SFSymbolNumbersPa: return @"numbers.pa"; + case SFSymbolNumbersSat: return @"numbers.sat"; + case SFSymbolNumbersTe: return @"numbers.te"; + case SFSymbolPadHeader: return @"pad.header"; + case SFSymbolPaintBucketClassic: return @"paint.bucket.classic"; + case SFSymbolPedestrianGateClosedTrianglebadgeExclamationmark: return @"pedestrian.gate.closed.trianglebadge.exclamationmark"; + case SFSymbolPedestrianGateOpenTrianglebadgeExclamationmark: return @"pedestrian.gate.open.trianglebadge.exclamationmark"; + case SFSymbolPerson2Badge: return @"person.2.badge"; + case SFSymbolPerson2BadgeFill: return @"person.2.badge.fill"; + case SFSymbolPerson2Shield: return @"person.2.shield"; + case SFSymbolPerson2ShieldFill: return @"person.2.shield.fill"; + case SFSymbolPersonCropCircleBadgeEllipsis: return @"person.crop.circle.badge.ellipsis"; + case SFSymbolPersonCropCircleBadgeEllipsisFill: return @"person.crop.circle.badge.ellipsis.fill"; + case SFSymbolPersonSpatialaudio3dFill: return @"person.spatialaudio.3d.fill"; + case SFSymbolPersonSpatialaudioFill: return @"person.spatialaudio.fill"; + case SFSymbolPersonSpatialaudioStereo3dFill: return @"person.spatialaudio.stereo.3d.fill"; + case SFSymbolPersonSpatialaudioStereoFill: return @"person.spatialaudio.stereo.fill"; + case SFSymbolPersonTextRectangleTrianglebadgeExclamationmark: return @"person.text.rectangle.trianglebadge.exclamationmark"; + case SFSymbolPersonTextRectangleTrianglebadgeExclamationmarkFill: return @"person.text.rectangle.trianglebadge.exclamationmark.fill"; + case SFSymbolPetCarrier: return @"pet.carrier"; + case SFSymbolPetCarrierCircle: return @"pet.carrier.circle"; + case SFSymbolPetCarrierCircleFill: return @"pet.carrier.circle.fill"; + case SFSymbolPetCarrierFill: return @"pet.carrier.fill"; + case SFSymbolPhonePause: return @"phone.pause"; + case SFSymbolPhonePauseCircle: return @"phone.pause.circle"; + case SFSymbolPhonePauseCircleFill: return @"phone.pause.circle.fill"; + case SFSymbolPhonePauseFill: return @"phone.pause.fill"; + case SFSymbolPlayDiamond: return @"play.diamond"; + case SFSymbolPlayDiamondFill: return @"play.diamond.fill"; + case SFSymbolPlusArrowTriangleheadCounterclockwise: return @"plus.arrow.trianglehead.counterclockwise"; + case SFSymbolPlusCapsule: return @"plus.capsule"; + case SFSymbolPlusCapsuleFill: return @"plus.capsule.fill"; + case SFSymbolPointerArrow: return @"pointer.arrow"; + case SFSymbolPointerArrowAndSquareOnSquareDashed: return @"pointer.arrow.and.square.on.square.dashed"; + case SFSymbolPointerArrowClick: return @"pointer.arrow.click"; + case SFSymbolPointerArrowClick2: return @"pointer.arrow.click.2"; + case SFSymbolPointerArrowClickBadgeClock: return @"pointer.arrow.click.badge.clock"; + case SFSymbolPointerArrowIpad: return @"pointer.arrow.ipad"; + case SFSymbolPointerArrowIpadAndSquareOnSquareDashed: return @"pointer.arrow.ipad.and.square.on.square.dashed"; + case SFSymbolPointerArrowIpadRays: return @"pointer.arrow.ipad.rays"; + case SFSymbolPointerArrowIpadSlash: return @"pointer.arrow.ipad.slash"; + case SFSymbolPointerArrowIpadSlashSquare: return @"pointer.arrow.ipad.slash.square"; + case SFSymbolPointerArrowIpadSlashSquareFill: return @"pointer.arrow.ipad.slash.square.fill"; + case SFSymbolPointerArrowIpadSquare: return @"pointer.arrow.ipad.square"; + case SFSymbolPointerArrowIpadSquareFill: return @"pointer.arrow.ipad.square.fill"; + case SFSymbolPointerArrowMotionlines: return @"pointer.arrow.motionlines"; + case SFSymbolPointerArrowMotionlinesClick: return @"pointer.arrow.motionlines.click"; + case SFSymbolPointerArrowRays: return @"pointer.arrow.rays"; + case SFSymbolPointerArrowSlash: return @"pointer.arrow.slash"; + case SFSymbolPointerArrowSlashSquare: return @"pointer.arrow.slash.square"; + case SFSymbolPointerArrowSlashSquareFill: return @"pointer.arrow.slash.square.fill"; + case SFSymbolPointerArrowSquare: return @"pointer.arrow.square"; + case SFSymbolPointerArrowSquareFill: return @"pointer.arrow.square.fill"; + case SFSymbolRectangle3GroupDashed: return @"rectangle.3.group.dashed"; + case SFSymbolRectangleGrid1x3: return @"rectangle.grid.1x3"; + case SFSymbolRectangleGrid1x3Fill: return @"rectangle.grid.1x3.fill"; + case SFSymbolRectangleLandscapeRotateSlash: return @"rectangle.landscape.rotate.slash"; + case SFSymbolRectanglePortraitRotateSlash: return @"rectangle.portrait.rotate.slash"; + case SFSymbolRectangleStackSlash: return @"rectangle.stack.slash"; + case SFSymbolRectangleStackSlashFill: return @"rectangle.stack.slash.fill"; + case SFSymbolRepeatBadgeXmark: return @"repeat.badge.xmark"; + case SFSymbolRing: return @"ring"; + case SFSymbolRingDashed: return @"ring.dashed"; + case SFSymbolSensorRadiowavesLeftAndRight: return @"sensor.radiowaves.left.and.right"; + case SFSymbolSensorRadiowavesLeftAndRightFill: return @"sensor.radiowaves.left.and.right.fill"; + case SFSymbolServiceDog: return @"service.dog"; + case SFSymbolServiceDogFill: return @"service.dog.fill"; + case SFSymbolShoeArrowTriangleheadUpAndDown: return @"shoe.arrow.trianglehead.up.and.down"; + case SFSymbolShoeArrowTriangleheadUpAndDownFill: return @"shoe.arrow.trianglehead.up.and.down.fill"; + case SFSymbolShoeArrowTriangleheadUpRight: return @"shoe.arrow.trianglehead.up.right"; + case SFSymbolShoeArrowTriangleheadUpRightCircle: return @"shoe.arrow.trianglehead.up.right.circle"; + case SFSymbolShoeArrowTriangleheadUpRightCircleFill: return @"shoe.arrow.trianglehead.up.right.circle.fill"; + case SFSymbolShoeArrowTriangleheadUpRightFill: return @"shoe.arrow.trianglehead.up.right.fill"; + case SFSymbolSiri: return @"siri"; + case SFSymbolSliderHorizontalBelowCircleLefthalfFilled: return @"slider.horizontal.below.circle.lefthalf.filled"; + case SFSymbolSliderHorizontalBelowCircleLefthalfFilledInverse: return @"slider.horizontal.below.circle.lefthalf.filled.inverse"; + case SFSymbolSliderHorizontalBelowCircleRighthalfFilled: return @"slider.horizontal.below.circle.righthalf.filled"; + case SFSymbolSliderHorizontalBelowCircleRighthalfFilledInverse: return @"slider.horizontal.below.circle.righthalf.filled.inverse"; + case SFSymbolSparkleTextClipboard: return @"sparkle.text.clipboard"; + case SFSymbolSparkleTextClipboardFill: return @"sparkle.text.clipboard.fill"; + case SFSymbolSparkles2: return @"sparkles.2"; + case SFSymbolSpatialCapture: return @"spatial.capture"; + case SFSymbolSpatialCaptureFill: return @"spatial.capture.fill"; + case SFSymbolSpatialCaptureOnHexagon: return @"spatial.capture.on.hexagon"; + case SFSymbolSpatialCaptureOnHexagonFill: return @"spatial.capture.on.hexagon.fill"; + case SFSymbolSpatialCaptureSlash: return @"spatial.capture.slash"; + case SFSymbolSpatialCaptureSlashFill: return @"spatial.capture.slash.fill"; + case SFSymbolSpeakerTrianglebadgeExclamationmark: return @"speaker.trianglebadge.exclamationmark"; + case SFSymbolSpeakerTrianglebadgeExclamationmarkFill: return @"speaker.trianglebadge.exclamationmark.fill"; + case SFSymbolSteeringwheelBadgeLock: return @"steeringwheel.badge.lock"; + case SFSymbolStrikethroughDouble: return @"strikethrough.double"; + case SFSymbolStrokeLineDiagonal: return @"stroke.line.diagonal"; + case SFSymbolStrokeLineDiagonalSlash: return @"stroke.line.diagonal.slash"; + case SFSymbolSuitcaseCircle: return @"suitcase.circle"; + case SFSymbolSuitcaseCircleFill: return @"suitcase.circle.fill"; + case SFSymbolSuitcaseRollingAndFilm: return @"suitcase.rolling.and.film"; + case SFSymbolSuitcaseRollingAndFilmCircle: return @"suitcase.rolling.and.film.circle"; + case SFSymbolSuitcaseRollingAndFilmCircleFill: return @"suitcase.rolling.and.film.circle.fill"; + case SFSymbolSuitcaseRollingAndFilmFill: return @"suitcase.rolling.and.film.fill"; + case SFSymbolSuitcaseRollingAndSuitcase: return @"suitcase.rolling.and.suitcase"; + case SFSymbolSuitcaseRollingAndSuitcaseCircle: return @"suitcase.rolling.and.suitcase.circle"; + case SFSymbolSuitcaseRollingAndSuitcaseCircleFill: return @"suitcase.rolling.and.suitcase.circle.fill"; + case SFSymbolSuitcaseRollingAndSuitcaseFill: return @"suitcase.rolling.and.suitcase.fill"; + case SFSymbolSuitcaseRollingCircle: return @"suitcase.rolling.circle"; + case SFSymbolSuitcaseRollingCircleFill: return @"suitcase.rolling.circle.fill"; + case SFSymbolTextBelowFolder: return @"text.below.folder"; + case SFSymbolTextBelowFolderFill: return @"text.below.folder.fill"; + case SFSymbolTextLine2Summary: return @"text.line.2.summary"; + case SFSymbolTextLine2SummaryBadgeXmark: return @"text.line.2.summary.badge.xmark"; + case SFSymbolTextLine3Summary: return @"text.line.3.summary"; + case SFSymbolTextPadHeader: return @"text.pad.header"; + case SFSymbolTextPadHeaderBadgeClock: return @"text.pad.header.badge.clock"; + case SFSymbolTextPadHeaderBadgeClockRtl: return @"text.pad.header.badge.clock.rtl"; + case SFSymbolTextPadHeaderBadgePlus: return @"text.pad.header.badge.plus"; + case SFSymbolTextRectangle: return @"text.rectangle"; + case SFSymbolTextRectangleFill: return @"text.rectangle.fill"; + case SFSymbolTextSquareFilled: return @"text.square.filled"; + case SFSymbolTextformatNumbersMr: return @"textformat.numbers.mr"; + case SFSymbolThermometerAndEllipsis: return @"thermometer.and.ellipsis"; + case SFSymbolThermometerGaugeOpen: return @"thermometer.gauge.open"; + case SFSymbolThermometerTirepressure: return @"thermometer.tirepressure"; + case SFSymbolThermometerVariableBadgeClock: return @"thermometer.variable.badge.clock"; + case SFSymbolThermometerVariableBadgePlay: return @"thermometer.variable.badge.play"; + case SFSymbolTicketCircle: return @"ticket.circle"; + case SFSymbolTicketCircleFill: return @"ticket.circle.fill"; + case SFSymbolTramCard: return @"tram.card"; + case SFSymbolTramCardFill: return @"tram.card.fill"; + case SFSymbolTrayBadge: return @"tray.badge"; + case SFSymbolTrayBadgeFill: return @"tray.badge.fill"; + case SFSymbolUmbrellaCircle: return @"umbrella.circle"; + case SFSymbolUmbrellaCircleFill: return @"umbrella.circle.fill"; + case SFSymbolUmbrellaGaugeOpen: return @"umbrella.gauge.open"; + case SFSymbolUmbrellaSensorTagRadiowavesLeftAndRight: return @"umbrella.sensor.tag.radiowaves.left.and.right"; + case SFSymbolUmbrellaSensorTagRadiowavesLeftAndRightFill: return @"umbrella.sensor.tag.radiowaves.left.and.right.fill"; + case SFSymbolUnderlineDouble: return @"underline.double"; + case SFSymbolVentHeatWavesUpward: return @"vent.heat.waves.upward"; + case SFSymbolVisionProBadgeCheckmark: return @"vision.pro.badge.checkmark"; + case SFSymbolVisionProBadgeCheckmarkFill: return @"vision.pro.badge.checkmark.fill"; + case SFSymbolWalletSensorTagRadiowavesLeftAndRight: return @"wallet.sensor.tag.radiowaves.left.and.right"; + case SFSymbolWalletSensorTagRadiowavesLeftAndRightFill: return @"wallet.sensor.tag.radiowaves.left.and.right.fill"; + case SFSymbolWaveformLow: return @"waveform.low"; + case SFSymbolWaveformMid: return @"waveform.mid"; + case SFSymbolWifiBadgeLock: return @"wifi.badge.lock"; + case SFSymbolXmarkCircleBadgeAirplane: return @"xmark.circle.badge.airplane"; + case SFSymbolXmarkCircleBadgeAirplaneFill: return @"xmark.circle.badge.airplane.fill"; + case SFSymbolAirConditioner: return @"air.conditioner"; + case SFSymbolAirConditionerSlash: return @"air.conditioner.slash"; + case SFSymbolArrowtriangleBackwardInsetFilledTrailingthirdRectangle: return @"arrowtriangle.backward.inset.filled.trailingthird.rectangle"; + case SFSymbolArrowtriangleDown2: return @"arrowtriangle.down.2"; + case SFSymbolArrowtriangleDown2Fill: return @"arrowtriangle.down.2.fill"; + case SFSymbolArrowtriangleForwardInsetFilledTrailingthirdRectangle: return @"arrowtriangle.forward.inset.filled.trailingthird.rectangle"; + case SFSymbolArrowtriangleUp2: return @"arrowtriangle.up.2"; + case SFSymbolArrowtriangleUp2Fill: return @"arrowtriangle.up.2.fill"; + case SFSymbolButtonHorizontalTop: return @"button.horizontal.top"; + case SFSymbolButtonHorizontalTopFill: return @"button.horizontal.top.fill"; + case SFSymbolButtonVerticalLeft: return @"button.vertical.left"; + case SFSymbolButtonVerticalLeftFill: return @"button.vertical.left.fill"; + case SFSymbolButtonVerticalRight: return @"button.vertical.right"; + case SFSymbolButtonVerticalRightFill: return @"button.vertical.right.fill"; + case SFSymbolCameraViewfinderBadgeAutomatic: return @"camera.viewfinder.badge.automatic"; + case SFSymbolDigitalcrown: return @"digitalcrown"; + case SFSymbolDigitalcrownFill: return @"digitalcrown.fill"; + case SFSymbolDigitalcrownHorizontal: return @"digitalcrown.horizontal"; + case SFSymbolDigitalcrownHorizontalFill: return @"digitalcrown.horizontal.fill"; + case SFSymbolHeadProfileVisionProRemove: return @"head.profile.vision.pro.remove"; + case SFSymbolInsetFilledRectangleAndPersonFilledSlash: return @"inset.filled.rectangle.and.person.filled.slash"; + case SFSymbolInsetFilledRectangleAndPersonFilledSlashRtl: return @"inset.filled.rectangle.and.person.filled.slash.rtl"; + case SFSymbolRadicandSquareroot: return @"radicand.squareroot"; + case SFSymbolRadicandSquarerootAr: return @"radicand.squareroot.ar"; + case SFSymbolRectangleBadgeSparkles: return @"rectangle.badge.sparkles"; + case SFSymbolRectangleBadgeSparklesFill: return @"rectangle.badge.sparkles.fill"; + case SFSymbolSliderHorizontalBelowSunMin: return @"slider.horizontal.below.sun.min"; + case SFSymbolStarRectangle: return @"star.rectangle"; + case SFSymbolStarRectangleFill: return @"star.rectangle.fill"; default: return nil; } } BOOL SFSymbolIsAvailable(SFSymbol symbol) { - if (@available(iOS 18.5, macOS 15.5, tvOS 18.5, watchOS 11.5, visionOS 2.5, *)) { - return (symbol >= 0 && symbol <= 5); + if (@available(iOS 26.1, macOS 26.1, tvOS 26.1, watchOS 26.1, visionOS 26.1, *)) { + return (symbol >= 0 && symbol <= 28); + } else if (@available(iOS 26.0, macOS 26.0, tvOS 26.0, watchOS 26.0, visionOS 26.0, *)) { + return (symbol >= 29 && symbol <= 644); + } else if (@available(iOS 18.5, macOS 15.5, tvOS 18.5, watchOS 11.5, visionOS 2.5, *)) { + return (symbol >= 645 && symbol <= 650); } else if (@available(iOS 18.4, macOS 15.4, tvOS 18.4, watchOS 11.4, visionOS 2.4, *)) { - return (symbol >= 6 && symbol <= 243); + return (symbol >= 651 && symbol <= 888); } else if (@available(iOS 18.2, macOS 15.2, tvOS 18.2, watchOS 11.2, visionOS 2.2, *)) { - return (symbol >= 244 && symbol <= 266); + return (symbol >= 889 && symbol <= 911); } else if (@available(iOS 18.1, macOS 15.1, tvOS 18.1, watchOS 11.1, visionOS 2.1, *)) { - return (symbol >= 267 && symbol <= 357); + return (symbol >= 912 && symbol <= 1002); } else if (@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *)) { - return (symbol >= 358 && symbol <= 1954); + return (symbol >= 1003 && symbol <= 2599); } else if (@available(iOS 17.6, macOS 14.6, tvOS 17.6, watchOS 10.6, visionOS 1.3, *)) { - return (symbol >= 1955 && symbol <= 1961); + return (symbol >= 2600 && symbol <= 2606); } else if (@available(iOS 17.4, macOS 14.4, tvOS 17.4, watchOS 10.4, visionOS 1.1, *)) { - return (symbol >= 1962 && symbol <= 1984); + return (symbol >= 2607 && symbol <= 2629); } else if (@available(iOS 17.2, macOS 14.2, tvOS 17.2, watchOS 10.2, visionOS 1.1, *)) { - return (symbol >= 1985 && symbol <= 2434); + return (symbol >= 2630 && symbol <= 3079); } else if (@available(iOS 17.1, macOS 14.1, tvOS 17.1, watchOS 10.1, visionOS 1.0, *)) { - return (symbol >= 2435 && symbol <= 2440); + return (symbol >= 3080 && symbol <= 3085); } else if (@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *)) { - return (symbol >= 2441 && symbol <= 3440); + return (symbol >= 3086 && symbol <= 4085); } else if (@available(iOS 16.4, macOS 13.3, tvOS 16.4, watchOS 9.4, visionOS 1.0, *)) { - return (symbol >= 3441 && symbol <= 3472); + return (symbol >= 4086 && symbol <= 4117); } else if (@available(iOS 16.1, macOS 13.0, tvOS 16.1, watchOS 9.1, visionOS 1.0, *)) { - return (symbol >= 3473 && symbol <= 3845); + return (symbol >= 4118 && symbol <= 4490); } else if (@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, visionOS 1.0, *)) { - return (symbol >= 3846 && symbol <= 4777); + return (symbol >= 4491 && symbol <= 5422); } else if (@available(iOS 15.4, macOS 12.3, tvOS 15.4, watchOS 8.5, visionOS 1.0, *)) { - return (symbol >= 4778 && symbol <= 4784); + return (symbol >= 5423 && symbol <= 5429); } else if (@available(iOS 15.2, macOS 12.1, tvOS 15.2, watchOS 8.3, visionOS 1.0, *)) { - return (symbol >= 4785 && symbol <= 4799); + return (symbol >= 5430 && symbol <= 5444); } else if (@available(iOS 15.1, macOS 12.0, tvOS 15.1, watchOS 8.1, visionOS 1.0, *)) { - return (symbol >= 4800 && symbol <= 4812); + return (symbol >= 5445 && symbol <= 5457); } else if (@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *)) { - return (symbol >= 4813 && symbol <= 5652); + return (symbol >= 5458 && symbol <= 6297); } else if (@available(iOS 14.5, macOS 11.3, tvOS 14.5, watchOS 7.4, visionOS 1.0, *)) { - return (symbol >= 5653 && symbol <= 5685); + return (symbol >= 6298 && symbol <= 6330); } else if (@available(iOS 14.2, macOS 11.0, tvOS 14.2, watchOS 7.1, visionOS 1.0, *)) { - return (symbol >= 5686 && symbol <= 5789); + return (symbol >= 6331 && symbol <= 6434); } else if (@available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, visionOS 1.0, *)) { - return (symbol >= 5790 && symbol <= 6859); + return (symbol >= 6435 && symbol <= 7504); } else if (@available(iOS 13.1, macOS 10.15, tvOS 13.0, watchOS 6.1, visionOS 1.0, *)) { - return (symbol >= 6860 && symbol <= 6877); + return (symbol >= 7505 && symbol <= 7522); } else if (@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, visionOS 1.0, *)) { - return (symbol >= 6878 && symbol <= 8538); + return (symbol >= 7523 && symbol <= 9183); } else { return NO; } diff --git a/iTerm2.xcodeproj/project.pbxproj b/iTerm2.xcodeproj/project.pbxproj index d74561751d..6b359db0f5 100644 --- a/iTerm2.xcodeproj/project.pbxproj +++ b/iTerm2.xcodeproj/project.pbxproj @@ -2815,6 +2815,10 @@ A693C0BD29D0B15900AED4E4 /* DeadlineMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A693C0BC29D0B15900AED4E4 /* DeadlineMonitor.swift */; }; A693C0BF29D0B1F100AED4E4 /* MemoryAddressHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A693C0BE29D0B1F100AED4E4 /* MemoryAddressHelper.swift */; }; A69553892DD1AA7F002E694D /* TokenArray.swift in Sources */ = {isa = PBXBuildFile; fileRef = A69553882DD1AA7B002E694D /* TokenArray.swift */; }; + AF0994AA2BF992398250200F /* MockCoprocess.m in Sources */ = {isa = PBXBuildFile; fileRef = 0667ECA7CDA4D18F0DECC2C2 /* MockCoprocess.m */; }; + 3BB35CCF037D48AB2C479283 /* FairnessScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF9D9FE0E680CEC64728F3AE /* FairnessScheduler.swift */; }; + MOCK0001TEST00M200000001 /* MockTaskNotifierTask.m in Sources */ = {isa = PBXBuildFile; fileRef = MOCK0002TEST00M100000002 /* MockTaskNotifierTask.m */; }; + MOCK0003TEST00S200000003 /* MockTaskNotifierTask+Swift.swift in Sources */ = {isa = PBXBuildFile; fileRef = MOCK0004TEST00S100000004 /* MockTaskNotifierTask+Swift.swift */; }; A695CA7F213DAA8500486440 /* NSHost+iTerm.h in Headers */ = {isa = PBXBuildFile; fileRef = A695CA7D213DAA8500486440 /* NSHost+iTerm.h */; }; A695CA80213DAA8500486440 /* NSHost+iTerm.m in Sources */ = {isa = PBXBuildFile; fileRef = A695CA7E213DAA8500486440 /* NSHost+iTerm.m */; }; A697100618D82E79007E901D /* iTermAdvancedSettingsViewController.h in Headers */ = {isa = PBXBuildFile; fileRef = A697100418D82E79007E901D /* iTermAdvancedSettingsViewController.h */; }; @@ -7726,6 +7730,11 @@ A693C0BC29D0B15900AED4E4 /* DeadlineMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeadlineMonitor.swift; sourceTree = ""; }; A693C0BE29D0B1F100AED4E4 /* MemoryAddressHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryAddressHelper.swift; sourceTree = ""; }; A69553882DD1AA7B002E694D /* TokenArray.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenArray.swift; sourceTree = ""; }; + 0667ECA7CDA4D18F0DECC2C2 /* MockCoprocess.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MockCoprocess.m; sourceTree = ""; }; + EF9D9FE0E680CEC64728F3AE /* FairnessScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FairnessScheduler.swift; sourceTree = ""; }; + MOCK0000TEST00H100000000 /* MockTaskNotifierTask.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MockTaskNotifierTask.h; sourceTree = ""; }; + MOCK0002TEST00M100000002 /* MockTaskNotifierTask.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MockTaskNotifierTask.m; sourceTree = ""; }; + MOCK0004TEST00S100000004 /* MockTaskNotifierTask+Swift.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MockTaskNotifierTask+Swift.swift"; sourceTree = ""; }; A695CA7D213DAA8500486440 /* NSHost+iTerm.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSHost+iTerm.h"; sourceTree = ""; }; A695CA7E213DAA8500486440 /* NSHost+iTerm.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSHost+iTerm.m"; sourceTree = ""; }; A696AB3921603A3F000023C3 /* iTermMetalUnavailableReason.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = iTermMetalUnavailableReason.h; sourceTree = ""; }; @@ -11468,6 +11477,11 @@ A61220E22E10489000F48E64 /* iTermFocusFollowsMouse.swift */, A6F7D4CE2E038B540065D09C /* PTYSession+Browser.swift */, A69553882DD1AA7B002E694D /* TokenArray.swift */, + 0667ECA7CDA4D18F0DECC2C2 /* MockCoprocess.m */, + EF9D9FE0E680CEC64728F3AE /* FairnessScheduler.swift */, + MOCK0000TEST00H100000000 /* MockTaskNotifierTask.h */, + MOCK0002TEST00M100000002 /* MockTaskNotifierTask.m */, + MOCK0004TEST00S100000004 /* MockTaskNotifierTask+Swift.swift */, A6C811F82DD1A7850088E628 /* TwoTierTokenQueue.swift */, A697CD702D973E370031583F /* iTermApplicationDelegate.swift */, A61E0C4F2D5ACD4C00D4633A /* PTYSession.swift */, @@ -18628,6 +18642,10 @@ A62132D12786109000B80724 /* CapturedOutput.m in Sources */, A667C26027791223006B4DEF /* PTYAnnotation.m in Sources */, A69553892DD1AA7F002E694D /* TokenArray.swift in Sources */, + AF0994AA2BF992398250200F /* MockCoprocess.m in Sources */, + 3BB35CCF037D48AB2C479283 /* FairnessScheduler.swift in Sources */, + MOCK0001TEST00M200000001 /* MockTaskNotifierTask.m in Sources */, + MOCK0003TEST00S200000003 /* MockTaskNotifierTask+Swift.swift in Sources */, A653F6BB24D4C9B50062377E /* iTermKeyLabels.m in Sources */, A6EC0B0F2C9C9AA800598D20 /* FoldMark.swift in Sources */, A6A3EDC62E0CDA9D00D711DA /* iTermBrowserActionPerforming.swift in Sources */, @@ -23644,6 +23662,7 @@ GCC_OPTIMIZATION_LEVEL = 0; GCC_PREPROCESSOR_DEFINITIONS = ( "DEBUG=1", + "ITERM_DEBUG=1", "$(inherited)", ); GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -23662,7 +23681,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.iterm2.ModernTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = macosx; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG ITERM_DEBUG $(inherited)"; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -23727,6 +23746,10 @@ ); GCC_C_LANGUAGE_STANDARD = gnu17; GCC_NO_COMMON_BLOCKS = YES; + GCC_PREPROCESSOR_DEFINITIONS = ( + "ITERM_DEBUG=1", + "$(inherited)", + ); GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; @@ -23742,6 +23765,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.iterm2.ModernTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "ITERM_DEBUG $(inherited)"; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; @@ -23806,6 +23830,10 @@ ); GCC_C_LANGUAGE_STANDARD = gnu17; GCC_NO_COMMON_BLOCKS = YES; + GCC_PREPROCESSOR_DEFINITIONS = ( + "ITERM_DEBUG=1", + "$(inherited)", + ); GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; @@ -23821,6 +23849,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.iterm2.ModernTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "ITERM_DEBUG $(inherited)"; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; @@ -23885,6 +23914,10 @@ ); GCC_C_LANGUAGE_STANDARD = gnu17; GCC_NO_COMMON_BLOCKS = YES; + GCC_PREPROCESSOR_DEFINITIONS = ( + "ITERM_DEBUG=1", + "$(inherited)", + ); GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; @@ -23900,6 +23933,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.iterm2.ModernTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "ITERM_DEBUG $(inherited)"; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; diff --git a/sources/FairnessScheduler.swift b/sources/FairnessScheduler.swift new file mode 100644 index 0000000000..d370e0c457 --- /dev/null +++ b/sources/FairnessScheduler.swift @@ -0,0 +1,275 @@ +// +// FairnessScheduler.swift +// iTerm2SharedARC +// +// Round-robin fair scheduler for token execution across PTY sessions. +// See implementation.md for design details. +// +// Thread Safety: All state access is synchronized via iTermGCD.mutationQueue. +// Public methods dispatch to mutationQueue; callers may invoke from any thread. +// + +import Foundation + +/// Result of executing a turn - returned by TokenExecutor.executeTurn() +@objc enum TurnResult: Int { + case completed = 0 // No more work in queue + case yielded = 1 // More work remains, re-add to busy list + case blocked = 2 // Can't make progress (paused, copy mode, etc.) +} + +/// Protocol that executors must conform to for FairnessScheduler integration. +/// TokenExecutor will conform to this protocol. +@objc(iTermFairnessSchedulerExecutor) +protocol FairnessSchedulerExecutor: AnyObject { + /// Execute tokens up to the given budget. Calls completion with result. + func executeTurn(tokenBudget: Int, completion: @escaping (TurnResult) -> Void) + + /// Called when session is unregistered to clean up pending tokens. + func cleanupForUnregistration() +} + +/// Coordinates round-robin fair scheduling of token execution across all PTY sessions. +@objc(iTermFairnessScheduler) +class FairnessScheduler: NSObject { + + /// Shared singleton instance + @objc static let shared = FairnessScheduler() + + /// Session ID type - monotonically increasing counter + typealias SessionID = UInt64 + + /// Default token budget per turn + static let defaultTokenBudget = 500 + + // MARK: - Private State + + private var nextSessionId: SessionID = 0 + private var sessions: [SessionID: SessionState] = [:] + private var busyList: [SessionID] = [] // Round-robin order + private var busySet: Set = [] // O(1) membership check + + #if ITERM_DEBUG + /// Test-only: Records session IDs in the order they executed, for verifying round-robin fairness. + private var _testExecutionHistory: [SessionID] = [] + #endif + private var executionScheduled = false + + private struct SessionState { + weak var executor: FairnessSchedulerExecutor? + var isExecuting: Bool = false + var workArrivedWhileExecuting: Bool = false + } + + // MARK: - Registration + + /// Register an executor with the scheduler. Returns a stable session ID. + /// Thread-safe: may be called from any thread, EXCEPT the mutation queue + /// (would deadlock due to sync dispatch). + @objc func register(_ executor: FairnessSchedulerExecutor) -> SessionID { + // Catch deadlock-prone pattern: calling sync from within mutation queue + dispatchPrecondition(condition: .notOnQueue(iTermGCD.mutationQueue())) + return iTermGCD.mutationQueue().sync { + let sessionId = nextSessionId + nextSessionId += 1 + sessions[sessionId] = SessionState(executor: executor) + return sessionId + } + } + + /// Unregister a session. + /// Thread-safe: may be called from any thread. + @objc func unregister(sessionId: SessionID) { + iTermGCD.mutationQueue().async { + if let state = self.sessions[sessionId], let executor = state.executor { + executor.cleanupForUnregistration() + } + self.sessions.removeValue(forKey: sessionId) + self.busySet.remove(sessionId) + // busyList cleaned lazily in executeNextTurn + } + } + + // MARK: - Work Notification + + /// Notify scheduler that a session has work to do. + /// Thread-safe: may be called from any thread. + @objc func sessionDidEnqueueWork(_ sessionId: SessionID) { + iTermGCD.mutationQueue().async { + self.sessionDidEnqueueWorkOnQueue(sessionId) + } + } + + /// Internal implementation - must be called on mutationQueue. + private func sessionDidEnqueueWorkOnQueue(_ sessionId: SessionID) { + guard var state = sessions[sessionId] else { return } + + if state.isExecuting { + state.workArrivedWhileExecuting = true + sessions[sessionId] = state + return + } + + if !busySet.contains(sessionId) { + busySet.insert(sessionId) + busyList.append(sessionId) + ensureExecutionScheduled() + } + } + + // MARK: - Execution + + /// Must be called on mutationQueue. + private func ensureExecutionScheduled() { + guard !busyList.isEmpty else { return } + guard !executionScheduled else { return } + + executionScheduled = true + + // Async dispatch to avoid deep recursion while staying on mutationQueue + iTermGCD.mutationQueue().async { [weak self] in + self?.executeNextTurn() + } + } + + /// Must be called on mutationQueue. + private func executeNextTurn() { + executionScheduled = false + + guard !busyList.isEmpty else { return } + + let sessionId = busyList.removeFirst() + busySet.remove(sessionId) + + guard var state = sessions[sessionId], + let executor = state.executor else { + // Dead session - clean up + sessions.removeValue(forKey: sessionId) + ensureExecutionScheduled() + return + } + + state.isExecuting = true + state.workArrivedWhileExecuting = false + sessions[sessionId] = state + + #if ITERM_DEBUG + _testExecutionHistory.append(sessionId) + #endif + + executor.executeTurn(tokenBudget: Self.defaultTokenBudget) { [weak self] result in + // Completion may be called from any thread; dispatch back to mutationQueue + iTermGCD.mutationQueue().async { + self?.sessionFinishedTurn(sessionId, result: result) + } + } + } + + /// Must be called on mutationQueue. + private func sessionFinishedTurn(_ sessionId: SessionID, result: TurnResult) { + guard var state = sessions[sessionId] else { return } + + state.isExecuting = false + let workArrived = state.workArrivedWhileExecuting + state.workArrivedWhileExecuting = false + + switch result { + case .completed: + if workArrived { + busySet.insert(sessionId) + busyList.append(sessionId) + } + case .yielded: + busySet.insert(sessionId) + busyList.append(sessionId) + case .blocked: + break // Don't reschedule + } + + sessions[sessionId] = state + ensureExecutionScheduled() + } +} + +// MARK: - Testing Hooks + +#if ITERM_DEBUG +extension FairnessScheduler { + /// Test-only: Returns whether a session ID is currently registered. + /// Must be called from mutationQueue or uses sync dispatch. + @objc func testIsSessionRegistered(_ sessionId: SessionID) -> Bool { + return iTermGCD.mutationQueue().sync { + return sessions[sessionId] != nil + } + } + + /// Test-only: Returns the count of sessions in the busy list. + @objc var testBusySessionCount: Int { + return iTermGCD.mutationQueue().sync { + return busyList.count + } + } + + /// Test-only: Returns the total count of registered sessions. + @objc var testRegisteredSessionCount: Int { + return iTermGCD.mutationQueue().sync { + return sessions.count + } + } + + /// Test-only: Returns whether a session is currently in the busy list. + @objc func testIsSessionInBusyList(_ sessionId: SessionID) -> Bool { + return iTermGCD.mutationQueue().sync { + return busySet.contains(sessionId) + } + } + + /// Test-only: Returns whether a session is currently executing. + @objc func testIsSessionExecuting(_ sessionId: SessionID) -> Bool { + return iTermGCD.mutationQueue().sync { + return sessions[sessionId]?.isExecuting ?? false + } + } + + /// Test-only: Reset state for clean test runs. + /// WARNING: Only call this in test teardown, never in production. + @objc func testReset() { + iTermGCD.mutationQueue().sync { + // Call cleanup on all registered executors + for state in sessions.values { + state.executor?.cleanupForUnregistration() + } + sessions.removeAll() + busyList.removeAll() + busySet.removeAll() + executionScheduled = false + nextSessionId = 0 + _testExecutionHistory.removeAll() + } + } + + /// Test-only: Returns the execution history (session IDs in execution order) and clears it. + /// Use this to verify round-robin fairness invariants. + @objc func testGetAndClearExecutionHistory() -> [UInt64] { + return iTermGCD.mutationQueue().sync { + let history = _testExecutionHistory + _testExecutionHistory.removeAll() + return history + } + } + + /// Test-only: Returns the current execution history without clearing it. + @objc func testGetExecutionHistory() -> [UInt64] { + return iTermGCD.mutationQueue().sync { + return _testExecutionHistory + } + } + + /// Test-only: Clears the execution history. + @objc func testClearExecutionHistory() { + iTermGCD.mutationQueue().sync { + _testExecutionHistory.removeAll() + } + } +} +#endif diff --git a/sources/MockCoprocess.h b/sources/MockCoprocess.h new file mode 100644 index 0000000000..fa304fde48 --- /dev/null +++ b/sources/MockCoprocess.h @@ -0,0 +1,36 @@ +// +// MockCoprocess.h +// iTerm2SharedARC +// +// Mock implementation of Coprocess for testing TaskNotifier coprocess handling. +// Subclass of Coprocess that uses pipes instead of spawning a subprocess. +// Only compiled when ITERM_DEBUG is defined. +// + +#import "Coprocess.h" + +NS_ASSUME_NONNULL_BEGIN + +/// Mock Coprocess subclass that uses pipes instead of a real subprocess. +/// Can be used anywhere a Coprocess is expected since it inherits from Coprocess. +/// NOTE: Only available when ITERM_DEBUG is defined at compile time. +@interface MockCoprocess : Coprocess + +/// The external write FD - test code writes here to simulate coprocess output. +/// This is the write end of the read pipe (TaskNotifier reads from inputFd/readFileDescriptor). +@property (nonatomic, readonly) int testWriteFd; + +/// The external read FD - test code reads here to see data written to coprocess. +/// This is the read end of the write pipe (TaskNotifier writes to outputFd/writeFileDescriptor). +@property (nonatomic, readonly) int testReadFd; + +/// Create a MockCoprocess with pipe FDs. +/// Returns nil on failure (pipe creation failed). ++ (nullable MockCoprocess *)createPipeCoprocess; + +/// Close the test FDs (call in addition to terminate to clean up test resources). +- (void)closeTestFds; + +@end + +NS_ASSUME_NONNULL_END diff --git a/sources/MockCoprocess.m b/sources/MockCoprocess.m new file mode 100644 index 0000000000..8ef77fe344 --- /dev/null +++ b/sources/MockCoprocess.m @@ -0,0 +1,99 @@ +// +// MockCoprocess.m +// iTerm2SharedARC +// +// Mock implementation of Coprocess for testing TaskNotifier coprocess handling. +// Subclass of Coprocess that uses pipes instead of spawning a subprocess. +// + +#import "MockCoprocess.h" +#import +#import + +@implementation MockCoprocess { + int _testWriteFd; // External: test code writes here to simulate coprocess output + int _testReadFd; // External: test code reads here to see writes to coprocess +} + ++ (MockCoprocess *)createPipeCoprocess { + int readPipe[2]; + int writePipe[2]; + + if (pipe(readPipe) != 0) { + return nil; + } + if (pipe(writePipe) != 0) { + close(readPipe[0]); + close(readPipe[1]); + return nil; + } + + // Set non-blocking on inputFd + int flags = fcntl(readPipe[0], F_GETFL); + fcntl(readPipe[0], F_SETFL, flags | O_NONBLOCK); + + // Set non-blocking on outputFd + flags = fcntl(writePipe[1], F_GETFL); + fcntl(writePipe[1], F_SETFL, flags | O_NONBLOCK); + + MockCoprocess *coprocess = [[MockCoprocess alloc] init]; + if (!coprocess) { + close(readPipe[0]); + close(readPipe[1]); + close(writePipe[0]); + close(writePipe[1]); + return nil; + } + + coprocess.inputFd = readPipe[0]; + coprocess.outputFd = writePipe[1]; + coprocess.pid = getpid(); + coprocess->_testWriteFd = readPipe[1]; + coprocess->_testReadFd = writePipe[0]; + + return coprocess; +} + +- (void)dealloc { + [self closeTestFds]; +} + +// Override to NOT send kill signal - MockCoprocess uses getpid() as pid +// and we don't want to kill the test process! +- (void)terminate { + // Close the FDs without killing (since pid = getpid() would kill ourselves) + if (self.outputFd >= 0) { + close(self.outputFd); + self.outputFd = -1; + } + if (self.inputFd >= 0) { + close(self.inputFd); + self.inputFd = -1; + } + self.pid = -1; +} + +#pragma mark - Properties + +- (int)testWriteFd { + return _testWriteFd; +} + +- (int)testReadFd { + return _testReadFd; +} + +#pragma mark - Test Helpers + +- (void)closeTestFds { + if (_testWriteFd >= 0) { + close(_testWriteFd); + _testWriteFd = -1; + } + if (_testReadFd >= 0) { + close(_testReadFd); + _testReadFd = -1; + } +} + +@end diff --git a/sources/MockTaskNotifierTask+Swift.swift b/sources/MockTaskNotifierTask+Swift.swift new file mode 100644 index 0000000000..25e2f3a9fc --- /dev/null +++ b/sources/MockTaskNotifierTask+Swift.swift @@ -0,0 +1,22 @@ +// +// MockTaskNotifierTask+Swift.swift +// iTerm2SharedARC +// +// Swift extensions for MockTaskNotifierTask to provide Swift-friendly API. +// + +import Foundation + +extension MockTaskNotifierTask { + + /// Creates a pipe task with read FD assigned to the task. + /// Returns a tuple with the task and the write FD for testing. + /// Caller is responsible for closing both FDs. + static func createPipeTask() -> (task: MockTaskNotifierTask, writeFd: Int32)? { + var writeFd: Int32 = 0 + guard let task = createPipeTask(withWriteFd: &writeFd) else { + return nil + } + return (task, writeFd) + } +} diff --git a/sources/MockTaskNotifierTask.h b/sources/MockTaskNotifierTask.h new file mode 100644 index 0000000000..4fcbaaf335 --- /dev/null +++ b/sources/MockTaskNotifierTask.h @@ -0,0 +1,88 @@ +// +// MockTaskNotifierTask.h +// iTerm2SharedARC +// +// Test-only mock implementation of iTermTask protocol for testing TaskNotifier behavior. +// Implementation is only compiled when ITERM_DEBUG is defined. +// + +#import +#import "TaskNotifier.h" + +NS_ASSUME_NONNULL_BEGIN + +/// Mock task that implements iTermTask for testing TaskNotifier behavior. +/// Configurable to test both dispatch source and legacy select() paths. +/// NOTE: Only available when ITERM_DEBUG is defined at compile time. +@interface MockTaskNotifierTask : NSObject + +// MARK: - iTermTask Required Properties + +@property (nonatomic) int fd; +@property (nonatomic) pid_t pid; +@property (nonatomic) pid_t pidToWaitOn; +@property (nonatomic) BOOL hasCoprocess; +@property (nonatomic, strong, nullable) Coprocess *coprocess; +@property (nonatomic) BOOL wantsRead; +@property (nonatomic) BOOL wantsWrite; +@property (nonatomic) BOOL writeBufferHasRoom; +@property (nonatomic) BOOL hasBrokenPipe; +@property (atomic) BOOL sshIntegrationActive; + +// MARK: - Configuration for Testing + +/// Set to true to make useDispatchSource return YES. +/// Default is NO (use select() path). +@property (nonatomic) BOOL dispatchSourceEnabled; + +/// If true, this mock does NOT respond to useDispatchSource selector, +/// simulating a legacy task that relies on select(). +@property (nonatomic) BOOL simulateLegacyTask; + +// MARK: - Call Tracking + +/// Number of times processRead was called +@property (nonatomic, readonly) NSInteger processReadCallCount; + +/// Number of times processWrite was called +@property (nonatomic, readonly) NSInteger processWriteCallCount; + +/// Number of times brokenPipe was called +@property (nonatomic, readonly) NSInteger brokenPipeCallCount; + +/// Number of times didRegister was called +@property (nonatomic, readonly) NSInteger didRegisterCallCount; + +/// Number of times writeTask:coprocess: was called with coprocess=YES +@property (nonatomic, readonly) NSInteger writeTaskCoprocessCallCount; + +/// Last data received via writeTask:coprocess: with coprocess=YES +@property (nonatomic, strong, readonly, nullable) NSData *lastCoprocessData; + +// MARK: - Test Helpers + +/// Reset all call counts and state for a fresh test +- (void)reset; + +/// Wait for processRead to be called at least `count` times, with timeout +/// Returns YES if reached, NO if timed out +- (BOOL)waitForProcessReadCalls:(NSInteger)count timeout:(NSTimeInterval)timeout; + +/// Wait for writeTask:coprocess: to be called at least `count` times, with timeout +/// Returns YES if reached, NO if timed out +- (BOOL)waitForCoprocessWriteCalls:(NSInteger)count timeout:(NSTimeInterval)timeout; + +/// Close the file descriptor if valid +- (void)closeFd; + +// MARK: - Factory Methods + +/// Create a pipe and set fd to the read end. +/// Returns the write fd via the out parameter. +/// Returns nil on failure. +/// Caller is responsible for closing both FDs. ++ (nullable MockTaskNotifierTask *)createPipeTaskWithWriteFd:(int *)writeFd; + +@end + +NS_ASSUME_NONNULL_END diff --git a/sources/MockTaskNotifierTask.m b/sources/MockTaskNotifierTask.m new file mode 100644 index 0000000000..44180b7819 --- /dev/null +++ b/sources/MockTaskNotifierTask.m @@ -0,0 +1,171 @@ +// +// MockTaskNotifierTask.m +// iTerm2SharedARC +// +// Test-only mock implementation of iTermTask protocol for testing TaskNotifier behavior. +// + +#import "MockTaskNotifierTask.h" +#import +#import + +@implementation MockTaskNotifierTask { + NSInteger _processReadCallCount; + NSInteger _processWriteCallCount; + NSInteger _brokenPipeCallCount; + NSInteger _didRegisterCallCount; + NSInteger _writeTaskCoprocessCallCount; + NSData *_lastCoprocessData; +} + +- (instancetype)init { + self = [super init]; + if (self) { + _fd = -1; + _pid = 0; + _pidToWaitOn = 0; + _hasCoprocess = NO; + _coprocess = nil; + _wantsRead = YES; + _wantsWrite = NO; + _writeBufferHasRoom = YES; + _hasBrokenPipe = NO; + _sshIntegrationActive = NO; + _dispatchSourceEnabled = NO; + _simulateLegacyTask = NO; + } + return self; +} + +#pragma mark - iTermTask Required Methods + +- (void)processRead { + _processReadCallCount++; +} + +- (void)processWrite { + _processWriteCallCount++; +} + +- (void)brokenPipe { + _brokenPipeCallCount++; +} + +- (void)writeTask:(NSData *)data coprocess:(BOOL)isCoprocess { + if (isCoprocess) { + _writeTaskCoprocessCallCount++; + _lastCoprocessData = [data copy]; + } +} + +- (void)didRegister { + _didRegisterCallCount++; +} + +#pragma mark - iTermTask Optional Methods + +- (BOOL)useDispatchSource { + return self.dispatchSourceEnabled; +} + +#pragma mark - Override respondsToSelector for Legacy Simulation + +- (BOOL)respondsToSelector:(SEL)aSelector { + // If simulating a legacy task, pretend we don't implement useDispatchSource + if (self.simulateLegacyTask && aSelector == @selector(useDispatchSource)) { + return NO; + } + return [super respondsToSelector:aSelector]; +} + +#pragma mark - Call Count Accessors + +- (NSInteger)processReadCallCount { + return _processReadCallCount; +} + +- (NSInteger)processWriteCallCount { + return _processWriteCallCount; +} + +- (NSInteger)brokenPipeCallCount { + return _brokenPipeCallCount; +} + +- (NSInteger)didRegisterCallCount { + return _didRegisterCallCount; +} + +- (NSInteger)writeTaskCoprocessCallCount { + return _writeTaskCoprocessCallCount; +} + +- (NSData *)lastCoprocessData { + return _lastCoprocessData; +} + +#pragma mark - Test Helpers + +- (void)reset { + _processReadCallCount = 0; + _processWriteCallCount = 0; + _brokenPipeCallCount = 0; + _didRegisterCallCount = 0; + _writeTaskCoprocessCallCount = 0; + _lastCoprocessData = nil; + _dispatchSourceEnabled = NO; + _simulateLegacyTask = NO; + _wantsRead = YES; + _wantsWrite = NO; + _hasCoprocess = NO; + _hasBrokenPipe = NO; +} + +- (BOOL)waitForProcessReadCalls:(NSInteger)count timeout:(NSTimeInterval)timeout { + NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:timeout]; + while (_processReadCallCount < count && [[NSDate date] compare:deadline] == NSOrderedAscending) { + [NSThread sleepForTimeInterval:0.01]; + } + return _processReadCallCount >= count; +} + +- (BOOL)waitForCoprocessWriteCalls:(NSInteger)count timeout:(NSTimeInterval)timeout { + NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:timeout]; + while (_writeTaskCoprocessCallCount < count && [[NSDate date] compare:deadline] == NSOrderedAscending) { + [NSThread sleepForTimeInterval:0.01]; + } + return _writeTaskCoprocessCallCount >= count; +} + +- (void)closeFd { + if (self.fd >= 0) { + close(self.fd); + self.fd = -1; + } +} + +#pragma mark - Factory Methods + ++ (MockTaskNotifierTask *)createPipeTaskWithWriteFd:(int *)writeFd { + int fds[2]; + if (pipe(fds) != 0) { + return nil; + } + + // Set non-blocking on read end + int flags = fcntl(fds[0], F_GETFL); + fcntl(fds[0], F_SETFL, flags | O_NONBLOCK); + + MockTaskNotifierTask *task = [[MockTaskNotifierTask alloc] init]; + task.fd = fds[0]; // Read end + + if (writeFd) { + *writeFd = fds[1]; // Write end + } else { + close(fds[1]); // Close write end if not needed + } + + return task; +} + +@end diff --git a/sources/PTYSession.m b/sources/PTYSession.m index b2b65306d9..c474dea6f2 100644 --- a/sources/PTYSession.m +++ b/sources/PTYSession.m @@ -3989,9 +3989,18 @@ - (void)taskDidReadFromCoprocessWhileSSHIntegrationInUse:(NSData *)data { [_conductor sendKeys:data]; } +// Use mutateAsynchronously for single-writer consistency on mutation queue. +// When unpausing, scheduleTokenExecution re-kicks the scheduler so queued tokens +// don't sit in purgatory until new data arrives. - (void)taskDidChangePaused:(PTYTask *)task paused:(BOOL)paused { - [_screen performBlockWithJoinedThreads:^(VT100Terminal *terminal, VT100ScreenMutableState *mutableState, id delegate) { + [_screen mutateAsynchronously:^(VT100Terminal *terminal, + VT100ScreenMutableState *mutableState, + id delegate) { mutableState.taskPaused = paused; + if (!paused) { + // Resume token execution when unpausing + [mutableState scheduleTokenExecution]; + } }]; } @@ -4030,6 +4039,16 @@ - (void)taskDidChangeTTY:(PTYTask *)task { // Main thread - (void)taskDidRegister:(PTYTask *)task { [self updateTTYSize]; + + // Wire up backpressure integration between TokenExecutor and PTYTask + // This enables the fairness scheduler to control read source state + iTermTokenExecutor *executor = _screen.mutableState.tokenExecutor; + task.tokenExecutor = executor; + + __weak PTYTask *weakTask = task; + executor.backpressureReleaseHandler = ^{ + [weakTask updateReadSourceState]; + }; } - (void)tmuxDidDisconnect { @@ -19738,10 +19757,16 @@ - (void)shortcutNavigationDidSetPrefix:(NSString *)prefix { }].action; } +// Use mutateAsynchronously for single-writer consistency on mutation queue. +// scheduleTokenExecution re-kicks the scheduler so queued tokens don't sit in purgatory. +// The textview operation runs on main thread first, then the mutation queue state change. - (void)shortcutNavigationDidComplete { [_textview removeContentNavigationShortcutsAndSearchResults:_modeHandler.clearSelectionsOnExit]; - [_screen performBlockWithJoinedThreads:^(VT100Terminal *terminal, VT100ScreenMutableState *mutableState, id delegate) { + [_screen mutateAsynchronously:^(VT100Terminal *terminal, + VT100ScreenMutableState *mutableState, + id delegate) { mutableState.shortcutNavigationMode = NO; + [mutableState scheduleTokenExecution]; }]; } diff --git a/sources/PTYTask.h b/sources/PTYTask.h index 1137f075a2..7553722f57 100644 --- a/sources/PTYTask.h +++ b/sources/PTYTask.h @@ -187,6 +187,17 @@ typedef NS_OPTIONS(NSUInteger, iTermJobManagerAttachResults) { // This is used by channels. It takes care of handling IO and this is the one strong reference to the ioBuffer. @property(nonatomic, strong) iTermIOBuffer *ioBuffer; +// TokenExecutor for backpressure monitoring. Set by PTYSession during setup. +// Used by dispatch sources to determine when to suspend/resume reading. +// Typed as `id` to avoid requiring Swift header import in this header. +// Implementation casts to TokenExecutor after importing Swift header. +@property(nonatomic, weak) id tokenExecutor; + +// Update read dispatch source state based on current conditions. +// Called when backpressure changes or other read-affecting state changes. +// Public to allow testing via SpyPTYTask. +- (void)updateReadSourceState; + + (NSMutableDictionary *)mutableEnvironmentDictionary; - (instancetype)init; @@ -251,3 +262,64 @@ typedef NS_OPTIONS(NSUInteger, iTermJobManagerAttachResults) { queue:(dispatch_queue_t)queue; @end + +#if ITERM_DEBUG +// Test-only interface for verifying dispatch source state in unit tests. +// These accessors provide visibility into private ivar state for testing +// dispatch source lifecycle, suspend/resume transitions, and teardown. +@interface PTYTask (Testing) + +/// Returns YES if a read dispatch source has been created. +@property(nonatomic, readonly) BOOL testHasReadSource; + +/// Returns YES if a write dispatch source has been created. +@property(nonatomic, readonly) BOOL testHasWriteSource; + +/// Returns YES if the read source is currently suspended. +/// Only meaningful if testHasReadSource is YES. +@property(nonatomic, readonly) BOOL testIsReadSourceSuspended; + +/// Returns YES if the write source is currently suspended. +/// Only meaningful if testHasWriteSource is YES. +@property(nonatomic, readonly) BOOL testIsWriteSourceSuspended; + +/// Returns YES if the write buffer has data waiting to be written. +@property(nonatomic, readonly) BOOL testWriteBufferHasData; + +/// Set the file descriptor for testing purposes. +/// This sets the fd via the jobManager. +- (void)testSetFd:(int)fd; + +/// Set up dispatch sources for testing with a pre-configured fd. +/// Requires fd >= 0 to be set before calling. +- (void)testSetupDispatchSourcesForTesting; + +/// Tear down dispatch sources for testing cleanup. +- (void)testTeardownDispatchSourcesForTesting; + +/// Add data to the write buffer for testing write source behavior. +- (void)testAppendDataToWriteBuffer:(NSData *)data; + +/// Override shouldWrite to return YES for testing write source resume. +/// When set to YES, shouldWrite will return YES regardless of jobManager state +/// (but still requires buffer to have data and not be paused). +/// Reset to NO to restore normal behavior. +@property(nonatomic, assign) BOOL testShouldWriteOverride; + +/// Override jobManager.ioAllowed for predicate testing. +/// nil = use real jobManager.ioAllowed value +/// @YES = force ioAllowed to return true +/// @NO = force ioAllowed to return false +/// This affects both shouldRead and shouldWrite predicates. +@property(nonatomic, strong, nullable) NSNumber *testIoAllowedOverride; + +/// Synchronously wait for the ioQueue to drain all pending work. +/// Use this instead of Thread.sleep to avoid flaky timing-dependent tests. +- (void)testWaitForIOQueue; + +/// Write data as if it came from a coprocess (for testing coprocess → PTY bridge). +/// This calls writeTask:coprocess:YES internally. +- (void)testWriteFromCoprocess:(NSData *)data; + +@end +#endif diff --git a/sources/PTYTask.m b/sources/PTYTask.m index 0a1719c39d..9a5bbe3a76 100644 --- a/sources/PTYTask.m +++ b/sources/PTYTask.m @@ -72,6 +72,20 @@ @implementation PTYTask { dispatch_queue_t _jobManagerQueue; BOOL _isTmuxTask; + + // Dispatch sources for per-PTY I/O (fairness scheduler integration) + dispatch_source_t _readSource; + dispatch_source_t _writeSource; + dispatch_queue_t _ioQueue; + BOOL _readSourceSuspended; + BOOL _writeSourceSuspended; + + // Test hook to override shouldWrite for testing write source resume + BOOL _testShouldWriteOverride; + + // Test hook to override jobManager.ioAllowed for predicate tests + // nil = use real value, @YES = force true, @NO = force false + NSNumber *_testIoAllowedOverride; } - (instancetype)init { @@ -99,6 +113,8 @@ - (instancetype)init { - (void)dealloc { DLog(@"Dealloc PTYTask %p", self); + // Tear down dispatch sources before releasing + [self teardownDispatchSources]; // TODO: The use of killpg seems pretty sketchy. It takes a pgid_t, not a // pid_t. Are they guaranteed to always be the same for process group // leaders? It is not clear from git history why killpg is used here and @@ -140,6 +156,9 @@ - (void)setPaused:(BOOL)paused { } // Start/stop selecting on our FD [[TaskNotifier sharedInstance] unblock]; + // Update dispatch sources based on new paused state + [self updateReadSourceState]; + [self updateWriteSourceState]; [_delegate taskDidChangePaused:self paused:paused]; } @@ -368,8 +387,12 @@ - (void)writeTask:(NSData *)data coprocess:(BOOL)fromCoprocessOutput { assert(!jobManager || !self.jobManager.isReadOnly); [writeLock lock]; [writeBuffer appendData:data]; - [[TaskNotifier sharedInstance] unblock]; [writeLock unlock]; + + // Trigger write dispatch_source (no-op if not set up) + // Must be outside writeLock because writeBufferDidChange -> shouldWrite takes writeLock + [self writeBufferDidChange]; + [[TaskNotifier sharedInstance] unblock]; // Still needed for coprocess FD wake-up } - (void)killWithMode:(iTermJobManagerKillingMode)mode { @@ -404,6 +427,9 @@ - (void)brokenPipe { // Main queue - (void)didRegister { DLog(@"didRegister %@", self); + // Activate dispatch sources for per-PTY I/O handling + // This is called for all paths: launch, attach, and restore + [self setupDispatchSources]; [self.delegate taskDidRegister:self]; } @@ -478,6 +504,222 @@ - (void)processWrite { [writeLock unlock]; } +#pragma mark - Dispatch Source Management (Fairness Scheduler) + +// LIFECYCLE: Only call after fd >= 0 (i.e., after process launch succeeds) +- (void)setupDispatchSources { + NSAssert(self.fd >= 0, @"setupDispatchSources called with invalid fd"); + + _ioQueue = dispatch_queue_create("com.iterm2.pty-io", DISPATCH_QUEUE_SERIAL); + + // Read source - starts SUSPENDED, will be resumed by updateReadSourceState if conditions allow + _readSource = dispatch_source_create(DISPATCH_SOURCE_TYPE_READ, + self.fd, 0, _ioQueue); + __weak typeof(self) weakSelf = self; + dispatch_source_set_event_handler(_readSource, ^{ + [weakSelf handleReadEvent]; + }); + dispatch_resume(_readSource); // Must resume before we can suspend + dispatch_suspend(_readSource); // Start suspended - updateReadSourceState will resume + _readSourceSuspended = YES; + + // Write source - starts SUSPENDED until writeBuffer has data + _writeSource = dispatch_source_create(DISPATCH_SOURCE_TYPE_WRITE, + self.fd, 0, _ioQueue); + dispatch_source_set_event_handler(_writeSource, ^{ + [weakSelf handleWriteEvent]; + }); + dispatch_resume(_writeSource); // Must resume before we can suspend + dispatch_suspend(_writeSource); // Start suspended - updateWriteSourceState will resume + _writeSourceSuspended = YES; + + // Initial state sync - resume sources if conditions allow + [self updateReadSourceState]; + [self updateWriteSourceState]; +} + +// TEARDOWN: Must resume suspended sources before canceling to avoid crash +- (void)teardownDispatchSources { + dispatch_queue_t ioQueue = _ioQueue; + dispatch_source_t readSource = _readSource; + dispatch_source_t writeSource = _writeSource; + + // Clear source ivars first - this prevents updateReadSourceState/updateWriteSourceState + // from dispatching any NEW blocks (they check _readSource/_writeSource != nil first). + _readSource = nil; + _writeSource = nil; + + if (!ioQueue) { + return; + } + + // Check if we're already on ioQueue to avoid deadlock from dispatch_sync. + const char *currentLabel = dispatch_queue_get_label(DISPATCH_CURRENT_QUEUE_LABEL); + const char *ioQueueLabel = dispatch_queue_get_label(ioQueue); + BOOL onIOQueue = (currentLabel != NULL && ioQueueLabel != NULL && + strcmp(currentLabel, ioQueueLabel) == 0); + + if (onIOQueue) { + // Already on ioQueue - do teardown inline with current state. + // No race here since we're already serialized on ioQueue. + if (readSource) { + if (_readSourceSuspended) { + dispatch_resume(readSource); + } + dispatch_source_cancel(readSource); + } + if (writeSource) { + if (_writeSourceSuspended) { + dispatch_resume(writeSource); + } + dispatch_source_cancel(writeSource); + } + } else { + // Not on ioQueue - use sync to capture state, then async to teardown. + // The sync ensures all prior updateRead/WriteSourceState blocks have completed, + // giving us the actual current state. + __block BOOL readSuspended = NO; + __block BOOL writeSuspended = NO; + dispatch_sync(ioQueue, ^{ + readSuspended = self->_readSourceSuspended; + writeSuspended = self->_writeSourceSuspended; + }); + + // Now teardown synchronously with the captured (consistent) state. + // Using dispatch_sync ensures all cleanup completes before we return, + // preventing races where the task is deallocated while blocks are pending. + // No self access here - just the captured sources and suspend flags. + dispatch_sync(ioQueue, ^{ + if (readSource) { + if (readSuspended) { + dispatch_resume(readSource); + } + dispatch_source_cancel(readSource); + } + if (writeSource) { + if (writeSuspended) { + dispatch_resume(writeSource); + } + dispatch_source_cancel(writeSource); + } + }); + } +} + +#pragma mark - Unified State Check + +// Helper to get effective ioAllowed, considering test override +- (BOOL)effectiveIoAllowed { +#if ITERM_DEBUG + if (_testIoAllowedOverride != nil) { + return _testIoAllowedOverride.boolValue; + } +#endif + return self.jobManager.ioAllowed; +} + +// All conditions that affect whether we should read +- (BOOL)shouldRead { + iTermTokenExecutor *executor = (iTermTokenExecutor *)self.tokenExecutor; + if (!executor) { + // No executor means no backpressure tracking - allow reads + return !self.paused && [self effectiveIoAllowed]; + } + return !self.paused && + [self effectiveIoAllowed] && + executor.backpressureLevel < BackpressureLevelHeavy; +} + +// All conditions that affect whether we should write +- (BOOL)shouldWrite { + if (self.paused) { + return NO; + } + // Test hook allows bypassing jobManager constraints for testing write source resume + if (!_testShouldWriteOverride && (self.jobManager.isReadOnly || ![self effectiveIoAllowed])) { + return NO; + } + [writeLock lock]; + BOOL hasData = [writeBuffer length] > 0; + [writeLock unlock]; + return hasData; +} + +// Called whenever ANY condition affecting read state changes +- (void)updateReadSourceState { + if (!_ioQueue || !_readSource) { + return; + } + BOOL shouldRead = [self shouldRead]; + dispatch_async(_ioQueue, ^{ + if (shouldRead && self->_readSourceSuspended && self->_readSource) { + dispatch_resume(self->_readSource); + self->_readSourceSuspended = NO; + } else if (!shouldRead && !self->_readSourceSuspended && self->_readSource) { + dispatch_suspend(self->_readSource); + self->_readSourceSuspended = YES; + } + }); +} + +// Called whenever ANY condition affecting write state changes +- (void)updateWriteSourceState { + if (!_ioQueue || !_writeSource) { + return; + } + BOOL shouldWrite = [self shouldWrite]; + dispatch_async(_ioQueue, ^{ + if (shouldWrite && self->_writeSourceSuspended && self->_writeSource) { + dispatch_resume(self->_writeSource); + self->_writeSourceSuspended = NO; + } else if (!shouldWrite && !self->_writeSourceSuspended && self->_writeSource) { + dispatch_suspend(self->_writeSource); + self->_writeSourceSuspended = YES; + } + }); +} + +#pragma mark - Dispatch Source Event Handlers + +- (void)handleReadEvent { + char buffer[MAXRW]; + ssize_t bytesRead = read(self.fd, buffer, MAXRW); + if (bytesRead <= 0) { + if (bytesRead < 0 && errno != EAGAIN) { + [self brokenPipe]; + } + return; + } + + hasOutput = YES; + + // Send data to delegate via non-blocking path + // (addTokens internally calls notifyScheduler which kicks FairnessScheduler) + [self.delegate threadedReadTask:buffer length:(int)bytesRead]; + + // Route PTY output to coprocess (same as readTask:length:) + @synchronized (self) { + if (coprocess_ && !self.sshIntegrationActive) { + [self writeToCoprocess:[NSData dataWithBytes:buffer length:bytesRead]]; + } + } + + // Re-check state after read (backpressure may have increased) + [self updateReadSourceState]; +} + +- (void)handleWriteEvent { + [self processWrite]; // Existing method - drains writeBuffer + + // Re-check state after write (buffer may now be empty) + [self updateWriteSourceState]; +} + +// Called when data is added to writeBuffer +- (void)writeBufferDidChange { + [self updateWriteSourceState]; +} + - (void)stopCoprocess { pid_t thePid = 0; @synchronized (self) { @@ -872,6 +1114,13 @@ - (BOOL)wantsWrite { return self.jobManager.ioAllowed; } +// iTermTask @optional protocol method +// Returns YES to indicate PTYTask uses dispatch_source for I/O instead of select(). +// TaskNotifier will skip adding this task's FD to select() fd_sets. +- (BOOL)useDispatchSource { + return YES; +} + - (BOOL)hasOutput { return hasOutput; } @@ -942,3 +1191,86 @@ - (void)winSizeControllerSetGridSize:(VT100GridSize)gridSize } @end + +#if ITERM_DEBUG +@implementation PTYTask (Testing) + +- (BOOL)testHasReadSource { + return _readSource != nil; +} + +- (BOOL)testHasWriteSource { + return _writeSource != nil; +} + +- (BOOL)testIsReadSourceSuspended { + return _readSourceSuspended; +} + +- (BOOL)testIsWriteSourceSuspended { + return _writeSourceSuspended; +} + +- (BOOL)testWriteBufferHasData { + [writeLock lock]; + BOOL hasData = [writeBuffer length] > 0; + [writeLock unlock]; + return hasData; +} + +- (void)testSetFd:(int)fd { + // MultiServerJobManager.setFd: asserts fd == -1 and does nothing. + // Replace with LegacyJobManager which has a simple fd property. + if (![self.jobManager isKindOfClass:[iTermLegacyJobManager class]]) { + self.jobManager = [[iTermLegacyJobManager alloc] initWithQueue:_jobManagerQueue]; + } + self.jobManager.fd = fd; +} + +- (void)testSetupDispatchSourcesForTesting { + [self setupDispatchSources]; +} + +- (void)testTeardownDispatchSourcesForTesting { + [self teardownDispatchSources]; +} + +- (void)testAppendDataToWriteBuffer:(NSData *)data { + [writeLock lock]; + [writeBuffer appendData:data]; + [writeLock unlock]; +} + +- (BOOL)testShouldWriteOverride { + return _testShouldWriteOverride; +} + +- (void)setTestShouldWriteOverride:(BOOL)testShouldWriteOverride { + _testShouldWriteOverride = testShouldWriteOverride; +} + +- (NSNumber *)testIoAllowedOverride { + return _testIoAllowedOverride; +} + +- (void)setTestIoAllowedOverride:(NSNumber *)testIoAllowedOverride { + _testIoAllowedOverride = testIoAllowedOverride; +} + +- (void)testWaitForIOQueue { + if (!_ioQueue) { + return; + } + // Dispatch a sync block to the ioQueue - this will wait until + // all previously queued async work has completed + dispatch_sync(_ioQueue, ^{ + // Empty block - just waiting for queue to drain + }); +} + +- (void)testWriteFromCoprocess:(NSData *)data { + [self writeTask:data coprocess:YES]; +} + +@end +#endif diff --git a/sources/PseudoTerminal.m b/sources/PseudoTerminal.m index 99e5a501c3..47b490c4e4 100644 --- a/sources/PseudoTerminal.m +++ b/sources/PseudoTerminal.m @@ -49,6 +49,7 @@ #import "iTermOrderEnforcer.h" #import "iTermPasswordManagerWindowController.h" #import "iTermPreferences.h" +#import "iTermProcessCache.h" #import "iTermProfilePreferences.h" #import "iTermProfilesWindowController.h" #import "iTermPromptOnCloseReason.h" @@ -6702,6 +6703,17 @@ - (void)tabView:(NSTabView *)tabView didSelectTabViewItem:(NSTabViewItem *)tabVi [_contentView setCurrentSessionAlpha:self.currentSession.textview.transparencyAlpha]; [tab didSelectTab]; [[NSNotificationCenter defaultCenter] postNotificationName:iTermSelectedTabDidChange object:tab]; + + // Update process cache with foreground root PIDs for this window + NSMutableSet *foregroundPIDs = [NSMutableSet set]; + for (PTYSession *session in [tab sessions]) { + pid_t pid = session.shell.pid; + if (pid > 0) { + [foregroundPIDs addObject:@(pid)]; + } + } + [[iTermProcessCache sharedInstance] setForegroundRootPIDs:foregroundPIDs]; + DLog(@"Finished"); } diff --git a/sources/TaskNotifier.h b/sources/TaskNotifier.h index 5ff9f1e6b2..94bcfa1a9f 100644 --- a/sources/TaskNotifier.h +++ b/sources/TaskNotifier.h @@ -30,6 +30,14 @@ extern NSString *const kCoprocessStatusChangeNotification; - (void)writeTask:(NSData *)data coprocess:(BOOL)coprocess; - (void)didRegister; +@optional + +// Returns YES if this task uses dispatch_source for I/O instead of select(). +// Tasks implementing this and returning YES will have their FD skipped in +// TaskNotifier's select() loop. Coprocess FDs are still handled via select(). +// Default (not implemented): NO - use select() for backward compatibility. +- (BOOL)useDispatchSource; + @end @interface TaskNotifier : NSObject diff --git a/sources/TaskNotifier.m b/sources/TaskNotifier.m index 11bc3765b1..2a300a847c 100644 --- a/sources/TaskNotifier.m +++ b/sources/TaskNotifier.m @@ -291,19 +291,30 @@ - (void)run { if (fd < 0) { PtyTaskDebugLog(@"Task has fd of %d\n", fd); } else { - // PtyTaskDebugLog(@"Select on fd %d\n", fd); - if (fd > highfd) { - highfd = fd; + // Check if task uses dispatch_source for I/O (optional protocol method) + BOOL usesDispatchSource = NO; + if ([task respondsToSelector:@selector(useDispatchSource)]) { + usesDispatchSource = [task useDispatchSource]; } - if ([task wantsRead]) { - FD_SET(fd, &rfds); - } - if ([task wantsWrite]) { - FD_SET(fd, &wfds); + + if (!usesDispatchSource) { + // Legacy path - add task's FD to select() sets + // PtyTaskDebugLog(@"Select on fd %d\n", fd); + if (fd > highfd) { + highfd = fd; + } + if ([task wantsRead]) { + FD_SET(fd, &rfds); + } + if ([task wantsWrite]) { + FD_SET(fd, &wfds); + } + FD_SET(fd, &efds); } - FD_SET(fd, &efds); + // else: Task uses dispatch_source - skip FD_SET for task's main FD } + // Coprocess handling continues for ALL tasks (including dispatch_source tasks) @synchronized (task) { Coprocess *coprocess = [task coprocess]; if (coprocess) { @@ -382,16 +393,25 @@ - (void)run { [[task retain] autorelease]; [handledFds addObject:@(fd)]; - if ([self handleReadOnFileDescriptor:fd task:task fdSet:&rfds]) { - iter = [_tasks objectEnumerator]; + // Check if task uses dispatch_source for I/O + BOOL usesDispatchSource = NO; + if ([task respondsToSelector:@selector(useDispatchSource)]) { + usesDispatchSource = [task useDispatchSource]; } - if ([self handleWriteOnFileDescriptor:fd task:task fdSet:&wfds]) { - iter = [_tasks objectEnumerator]; - } - if ([self handleErrorOnFileDescriptor:fd task:task fdSet:&efds]) { - iter = [_tasks objectEnumerator]; + + // Only handle task's main FD via select() if not using dispatch_source + if (!usesDispatchSource) { + if ([self handleReadOnFileDescriptor:fd task:task fdSet:&rfds]) { + iter = [_tasks objectEnumerator]; + } + if ([self handleWriteOnFileDescriptor:fd task:task fdSet:&wfds]) { + iter = [_tasks objectEnumerator]; + } + if ([self handleErrorOnFileDescriptor:fd task:task fdSet:&efds]) { + iter = [_tasks objectEnumerator]; + } } - // Move input around between coprocess and main process. + // Coprocess handling continues for ALL tasks (below) if ([task fd] >= 0 && ![task hasBrokenPipe]) { // Make sure the pipe wasn't just broken. @synchronized (task) { Coprocess *coprocess = [task coprocess]; diff --git a/sources/TokenArray.swift b/sources/TokenArray.swift index 75f5154819..e9be03e417 100644 --- a/sources/TokenArray.swift +++ b/sources/TokenArray.swift @@ -26,6 +26,8 @@ class TokenArray: IteratorProtocol, CustomDebugStringConvertible { return DispatchQueue(label: "com.iterm2.token-destroyer") }() private var semaphore: DispatchSemaphore? + // Called when the semaphore is signaled (slot released back to backpressure pool). + private var onSemaphoreSignaled: (() -> Void)? var hasNext: Bool { return nextIndex < count @@ -53,16 +55,18 @@ class TokenArray: IteratorProtocol, CustomDebugStringConvertible { nextToken?.asciiData.pointee.buffer[0] == 13) } - // length is byte length ofinputs + // length is byte length of inputs init(_ cvector: CVector, lengthTotal: Int, lengthExcludingInBandSignaling: Int, - semaphore: DispatchSemaphore?) { + semaphore: DispatchSemaphore?, + onSemaphoreSignaled: (() -> Void)? = nil) { precondition(lengthTotal > 0 && lengthExcludingInBandSignaling >= 0) self.cvector = cvector self.lengthTotal = lengthTotal self.lengthExcludingInBandSignaling = lengthExcludingInBandSignaling self.semaphore = semaphore + self.onSemaphoreSignaled = onSemaphoreSignaled count = CVectorCount(&self.cvector) } @@ -72,9 +76,11 @@ class TokenArray: IteratorProtocol, CustomDebugStringConvertible { } defer { nextIndex += 1 - if nextIndex == count, let semaphore = semaphore { - semaphore.signal() + if nextIndex == count { + semaphore?.signal() + onSemaphoreSignaled?() self.semaphore = nil + self.onSemaphoreSignaled = nil } } return (CVectorGetObject(&cvector, nextIndex) as! VT100Token) @@ -97,9 +103,11 @@ class TokenArray: IteratorProtocol, CustomDebugStringConvertible { // Returns whether there is another token. func consume() -> Bool { nextIndex += 1 - if nextIndex == count, let semaphore = semaphore { - semaphore.signal() + if nextIndex == count { + semaphore?.signal() + onSemaphoreSignaled?() self.semaphore = nil + self.onSemaphoreSignaled = nil } return hasNext } @@ -110,17 +118,19 @@ class TokenArray: IteratorProtocol, CustomDebugStringConvertible { return } nextIndex = count - if let semaphore = semaphore { - semaphore.signal() - self.semaphore = nil - } + semaphore?.signal() + onSemaphoreSignaled?() + self.semaphore = nil + self.onSemaphoreSignaled = nil } private var dirty = true func didFinish() { semaphore?.signal() + onSemaphoreSignaled?() semaphore = nil + onSemaphoreSignaled = nil } func cleanup(asyncFree: Bool) { @@ -129,6 +139,9 @@ class TokenArray: IteratorProtocol, CustomDebugStringConvertible { } dirty = false semaphore?.signal() + onSemaphoreSignaled?() + semaphore = nil + onSemaphoreSignaled = nil if asyncFree { TokenArray.destroyQueue.async { [cvector] in CVectorReleaseObjectsAndDestroy(cvector) diff --git a/sources/TokenExecutor.swift b/sources/TokenExecutor.swift index 40a1fd5f4a..7a6616773d 100644 --- a/sources/TokenExecutor.swift +++ b/sources/TokenExecutor.swift @@ -94,13 +94,36 @@ func CVectorReleaseObjectsAndDestroy(_ vector: CVector) { CVectorDestroy(&temp) } +// Indicates the current level of backpressure on token processing. +// Higher levels mean the mutation queue is falling behind. +// Conforms to Comparable for natural ordering comparisons. +@objc enum BackpressureLevel: Int, Comparable { + case none = 0 // > 75% slots available + case light = 1 // 50-75% available + case moderate = 2 // 25-50% available + case heavy = 3 // < 25% available + case blocked = 4 // 0 available, PTY read is blocked + + static func < (lhs: BackpressureLevel, rhs: BackpressureLevel) -> Bool { + return lhs.rawValue < rhs.rawValue + } +} + @objc(iTermTokenExecutor) -class TokenExecutor: NSObject { +class TokenExecutor: NSObject, FairnessSchedulerExecutor { @objc weak var delegate: TokenExecutorDelegate? { didSet { impl.delegate = delegate } } + private let totalSlots = Int(iTermAdvancedSettingsModel.bufferDepth()) + // Initialize counter to totalSlots immediately (not in init) to avoid a window + // where backpressureLevel could return .blocked before init completes. + private var availableSlots = { + let counter = iTermAtomicInt64Create() + iTermAtomicInt64Add(counter, Int64(iTermAdvancedSettingsModel.bufferDepth())) + return counter + }() private let semaphore = DispatchSemaphore(value: Int(iTermAdvancedSettingsModel.bufferDepth())) private let impl: TokenExecutorImpl private let queue: DispatchQueue @@ -108,6 +131,22 @@ class TokenExecutor: NSObject { private var onExecutorQueue: Bool { return DispatchQueue.getSpecific(key: Self.isTokenExecutorSpecificKey) == true } + + /// Closure called when backpressure transitions from heavy to lighter. + /// Used by PTYTask to re-evaluate read source state. + @objc var backpressureReleaseHandler: (() -> Void)? { + didSet { + impl.backpressureReleaseHandler = backpressureReleaseHandler + } + } + + /// Session ID assigned by FairnessScheduler during registration. + @objc var fairnessSessionId: UInt64 = 0 { + didSet { + impl.fairnessSessionId = fairnessSessionId + } + } + @objc var isBackgroundSession = false { didSet { #if DEBUG @@ -138,6 +177,32 @@ class TokenExecutor: NSObject { queue: queue) } + // Returns the current backpressure level based on available slots. + // This can be called from any queue. + // + // NOTE: High-priority tokens bypass backpressure (no semaphore wait), so this + // metric only reflects load from normal PTY token processing. This is intentional: + // high-priority tokens are meant to bypass flow control. + @objc var backpressureLevel: BackpressureLevel { + let available = Int(iTermAtomicInt64Get(availableSlots)) + // Use <= 0 since availableSlots can go negative when more tokens are added + // than total capacity (the counter isn't clamped at 0) + if available <= 0 { + return .blocked + } + let ratio = Double(available) / Double(totalSlots) + switch ratio { + case ..<0.25: + return .heavy + case ..<0.50: + return .moderate + case ..<0.75: + return .light + default: + return .none + } + } + // This takes ownership of vector. // You can call this on any queue. @objc @@ -160,7 +225,7 @@ class TokenExecutor: NSObject { // This takes ownership of vector. // You can call this on any queue when not high priority. // If high priority, then you must be on the main queue or have joined the main & mutation queue. - // This blocks when the queue of tokens gets too large. + // NON-BLOCKING: PTY read sources should never block. @objc func addTokens(_ vector: CVector, lengthTotal: Int, @@ -170,6 +235,10 @@ class TokenExecutor: NSObject { if lengthTotal == 0 { return } + + // Always decrement availableSlots for backpressure tracking (both normal and high-priority) + iTermAtomicInt64Add(availableSlots, -1) + if highPriority { #if DEBUG iTermGCD.assertMutationQueueSafe() @@ -179,26 +248,26 @@ class TokenExecutor: NSObject { reallyAddTokens(vector, lengthTotal: lengthTotal, lengthExcludingInBandSignaling: lengthExcludingInBandSignaling, - highPriority: highPriority, - semaphore: nil) + highPriority: highPriority) + // High-priority: already on mutation queue, notify scheduler synchronously + impl.notifyScheduler() return } - // Normal code path for tokens from PTY. Use the semaphore to give backpressure to reading. - let semaphore = self.semaphore + + // Normal code path for tokens from PTY. Non-blocking with backpressure via suspend/resume. if enableTimingStats { TokenExecutor.addTokensTimingStats.recordEnd() } - _ = semaphore.wait(timeout: .distantFuture) if enableTimingStats { TokenExecutor.addTokensTimingStats.recordStart() } reallyAddTokens(vector, lengthTotal: lengthTotal, lengthExcludingInBandSignaling: lengthExcludingInBandSignaling, - highPriority: highPriority, - semaphore: semaphore) + highPriority: highPriority) + // Normal: dispatch to mutation queue to notify scheduler queue.async { [weak self] in - self?.impl.didAddTokens() + self?.impl.notifyScheduler() } } @@ -247,12 +316,21 @@ class TokenExecutor: NSObject { private func reallyAddTokens(_ vector: CVector, lengthTotal: Int, lengthExcludingInBandSignaling: Int, - highPriority: Bool, - semaphore: DispatchSemaphore?) { + highPriority: Bool) { + // Always set consumption callback to increment availableSlots and trigger backpressure release + let onConsumed: () -> Void = { [weak self, availableSlots] in + guard let self = self else { return } + let newValue = iTermAtomicInt64Add(availableSlots, 1) + // Notify PTYTask to re-evaluate read state when crossing out of heavy backpressure + if newValue > 0 && self.backpressureLevel < .heavy { + self.impl.backpressureReleaseHandler?() + } + } let tokenArray = TokenArray(vector, lengthTotal: lengthTotal, lengthExcludingInBandSignaling: lengthExcludingInBandSignaling, - semaphore: semaphore) + semaphore: nil, // No semaphore - non-blocking model + onSemaphoreSignaled: onConsumed) self.impl.addTokens(tokenArray, highPriority: highPriority) } @@ -287,6 +365,22 @@ class TokenExecutor: NSObject { impl.schedule() } + // MARK: - FairnessSchedulerExecutor + + /// Execute tokens up to the given budget. Calls completion with result. + @objc + func executeTurn(tokenBudget: Int, completion: @escaping (TurnResult) -> Void) { + if gDebugLogging.boolValue { DLog("executeTurn(tokenBudget: \(tokenBudget))") } + impl.executeTurn(tokenBudget: tokenBudget, completion: completion) + } + + /// Called when session is unregistered to clean up pending tokens. + @objc + func cleanupForUnregistration() { + if gDebugLogging.boolValue { DLog("cleanupForUnregistration") } + impl.cleanupForUnregistration() + } + @objc func assertSynchronousSideEffectsAreSafe() { impl.assertSynchronousSideEffectsAreSafe() } @@ -303,9 +397,12 @@ class TokenExecutor: NSObject { } // Main queue only + // Returns true if the block was executed. + // Crashes (it_fatalError) on timeout since skipping the joined block would leave state inconsistent. @objc - func whilePaused(_ block: () -> ()) { - self.impl.whilePaused(block, onExecutorQueue: onExecutorQueue) + @discardableResult + func whilePaused(_ block: () -> ()) -> Bool { + return self.impl.whilePaused(block, onExecutorQueue: onExecutorQueue) } } @@ -329,9 +426,32 @@ private class TokenExecutorImpl { private(set) var isExecutingToken = false weak var delegate: TokenExecutorDelegate? - // This is used to give visible sessions priority for token processing over those that cannot - // be seen. This prevents a very busy non-selected tab from starving a visible one. - private static var activeSessionsWithTokens = MutableAtomicObject>(Set()) + // MARK: - Test Tracking (ITERM_DEBUG only) + #if ITERM_DEBUG + /// Atomic counter: total number of times executeTurn was called + private let _testExecuteTurnCallCount = iTermAtomicInt64Create() + /// Atomic counter: number of times executeTurn completed without being blocked + private let _testExecuteTurnCompletedCount = iTermAtomicInt64Create() + /// Atomic counter: total number of token arrays consumed across all executions + private let _testTokenArraysConsumedCount = iTermAtomicInt64Create() + + var testExecuteTurnCallCount: Int64 { iTermAtomicInt64Get(_testExecuteTurnCallCount) } + var testExecuteTurnCompletedCount: Int64 { iTermAtomicInt64Get(_testExecuteTurnCompletedCount) } + var testTokenArraysConsumedCount: Int64 { iTermAtomicInt64Get(_testTokenArraysConsumedCount) } + + func testResetCounters() { + _ = iTermAtomicInt64GetAndReset(_testExecuteTurnCallCount) + _ = iTermAtomicInt64GetAndReset(_testExecuteTurnCompletedCount) + _ = iTermAtomicInt64GetAndReset(_testTokenArraysConsumedCount) + } + #endif + + /// Closure called when backpressure transitions from heavy to lighter. + var backpressureReleaseHandler: (() -> Void)? + + /// Session ID assigned by FairnessScheduler during registration. + var fairnessSessionId: UInt64 = 0 + @objc var isBackgroundSession = false { didSet { #if DEBUG @@ -339,11 +459,6 @@ private class TokenExecutorImpl { #endif if isBackgroundSession != oldValue { sideEffectScheduler.period = isBackgroundSession ? 1.0 : 1.0 / 30.0 - if isBackgroundSession { - Self.activeSessionsWithTokens.mutableAccess { set in - set.remove(ObjectIdentifier(self)) - } - } } } } @@ -381,9 +496,7 @@ private class TokenExecutorImpl { } deinit { - Self.activeSessionsWithTokens.mutableAccess { set in - set.remove(ObjectIdentifier(self)) - } + // No cleanup needed - FairnessScheduler uses weak references } func pause() -> Unpauser { @@ -415,21 +528,126 @@ private class TokenExecutorImpl { func addTokens(_ tokenArray: TokenArray, highPriority: Bool) { throughputEstimator.addByteCount(tokenArray.lengthTotal) tokenQueue.addTokens(tokenArray, highPriority: highPriority) - if !isBackgroundSession { - Self.activeSessionsWithTokens.mutableAccess { set in - set.insert(ObjectIdentifier(self)) + } + + func didAddTokens() { + notifyScheduler() + } + + // MARK: - FairnessSchedulerExecutor Support + + /// Execute tokens up to the given budget. Calls completion with result. + func executeTurn(tokenBudget: Int, completion: @escaping (TurnResult) -> Void) { + DLog("executeTurn(tokenBudget: \(tokenBudget))") +#if DEBUG + assertQueue() +#endif + + #if ITERM_DEBUG + iTermAtomicInt64Add(_testExecuteTurnCallCount, 1) + #endif + + // Check if we're blocked (paused, copy mode, etc.) + if let delegate = delegate, delegate.tokenExecutorShouldQueueTokens() { + completion(.blocked) + return + } + + executingCount += 1 + defer { + executingCount -= 1 + executeHighPriorityTasks() + } + + executeHighPriorityTasks() + + guard let delegate = delegate else { + tokenQueue.removeAll() + #if ITERM_DEBUG + iTermAtomicInt64Add(_testExecuteTurnCompletedCount, 1) + #endif + completion(.completed) + return + } + + var tokensConsumed = 0 + var groupsExecuted = 0 + var accumulatedLength = ByteExecutionStats() + + tokenQueue.enumerateTokenArrayGroups { [weak self] (group, priority) in + guard let self = self else { return false } + + let groupTokenCount = group.arrays.reduce(0) { $0 + Int($1.count) } + + // Budget check BETWEEN groups, not within + // At least one group always executes (progress guarantee) + if tokensConsumed + groupTokenCount > tokenBudget && groupsExecuted > 0 { + return false // budget would be exceeded, yield to next session } + + // Execute the entire group atomically + if groupsExecuted == 0 { + delegate.tokenExecutorWillExecuteTokens() + } + + let shouldContinue = self.executeTokenGroups(group, + priority: priority, + accumulatedLength: &accumulatedLength, + delegate: delegate) + + tokensConsumed += groupTokenCount + groupsExecuted += 1 + + #if ITERM_DEBUG + // Track token arrays consumed (one per group iteration) + iTermAtomicInt64Add(self._testTokenArraysConsumedCount, Int64(group.arrays.count)) + #endif + + return shouldContinue && !self.isPaused + } + + if accumulatedLength.total > 0 || groupsExecuted > 0 { + delegate.tokenExecutorDidExecute(lengthTotal: accumulatedLength.total, + lengthExcludingInBandSignaling: accumulatedLength.excludingInBandSignaling, + throughput: throughputEstimator.estimatedThroughput) } + + #if ITERM_DEBUG + iTermAtomicInt64Add(_testExecuteTurnCompletedCount, 1) + #endif + + // Report back to scheduler + let hasMoreWork = !tokenQueue.isEmpty || taskQueue.count > 0 + completion(hasMoreWork ? .yielded : .completed) } - func didAddTokens() { - execute() + /// Called when session is unregistered to clean up pending tokens. + func cleanupForUnregistration() { + DLog("cleanupForUnregistration") + // Discard all remaining tokens and trigger their consumption callbacks + // This ensures availableSlots is correctly incremented for unconsumed tokens + let unconsumedCount = tokenQueue.discardAllAndReturnCount() + if unconsumedCount > 0 { + DLog("Cleaned up \(unconsumedCount) unconsumed token arrays") + } + } + + // MARK: - Scheduler Notification + + /// Notify the FairnessScheduler that this session has work. + /// Must be called on mutation queue. + func notifyScheduler() { + DLog("notifyScheduler(sessionId: \(fairnessSessionId))") +#if DEBUG + assertQueue() +#endif + FairnessScheduler.shared.sessionDidEnqueueWork(fairnessSessionId) } // You can call this on any queue. func schedule() { queue.async { [weak self] in - self?.execute() + self?.notifyScheduler() } } @@ -441,41 +659,87 @@ private class TokenExecutorImpl { assertQueue() #endif if executingCount == 0 { - execute() + notifyScheduler() return } + // Already executing - task will be picked up in current turn + return } schedule() } // Main queue // Runs block synchronously while token executor is stopped. - func whilePaused(_ block: () -> (), onExecutorQueue: Bool) { + // Returns true if the block was executed. + // Crashes (it_fatalError) on timeout since skipping the joined block would leave state inconsistent. + @discardableResult + func whilePaused(_ block: () -> (), onExecutorQueue: Bool) -> Bool { dispatchPrecondition(condition: .onQueue(.main)) + +#if DEBUG + // Detect reentrant joins + let wasInProgress = Self.joinInProgress.getAndSet(true) + it_assert(!wasInProgress, "Reentrant join detected - check call stack") + defer { Self.joinInProgress.set(false) } +#endif + if gDebugLogging.boolValue { DLog("Incr pending pauses if \(iTermPreferences.maximizeThroughput())") } var unpauser = iTermPreferences.maximizeThroughput() ? nil : TokenExecutor.globalPause() let sema = DispatchSemaphore(value: 0) let sema2 = DispatchSemaphore(value: 0) + // Cancellation flag - checked by async block before setting joined = true. + // This prevents the async block from setting joined state if main times out. + // NOTE: There's a small race window where the async block may have passed the + // cancel check but not yet signaled sema2. Under it_fatalError this is acceptable; + // if soft-failure is ever needed, a stronger handshake (e.g., DispatchWorkItem.cancel) + // would be required. + let cancelled = MutableAtomicObject(false) + queue.async { + // If main timed out and cancelled, don't set joined state + if cancelled.value { + DLog("Join was cancelled due to timeout - not setting joined state") + return + } sema2.signal() iTermGCD.joined = true sema.wait() iTermGCD.joined = false DLog("Mutation queue unpaused") } - if unpauser != nil { - sema2.wait() - } else { - // When maximize throughput is on, we want to avoid pausing token execution but we can't - // let it go on indefinitely. After a timeout, pause it and block for what should be a short - // amount of time (until the current batch of tokens is done being executed). - let timeout = 0.03 - if sema2.wait(timeout: .now() + timeout) == .timedOut { + + // Timeouts prevent deadlock if mutation queue is stuck. + // DEBUG: Short timeouts catch issues during development. + // RELEASE: Longer timeouts avoid false positives from legitimately busy queues, + // but still catch truly stuck queues. +#if DEBUG + let initialTimeout: TimeInterval = unpauser != nil ? 1.0 : 0.03 + let retryTimeout: TimeInterval = 1.0 +#else + let initialTimeout: TimeInterval = unpauser != nil ? 5.0 : 0.03 + let retryTimeout: TimeInterval = 10.0 +#endif + + if sema2.wait(timeout: .now() + initialTimeout) == .timedOut { + if unpauser == nil { + // When maximize throughput is on, we want to avoid pausing token execution but we can't + // let it go on indefinitely. After a timeout, pause it and block for what should be a short + // amount of time (until the current batch of tokens is done being executed). unpauser = TokenExecutor.globalPause() - sema2.wait() + } + // Try once more with pause in effect, but still use a timeout + if sema2.wait(timeout: .now() + retryTimeout) == .timedOut { + DLog("FATAL: whilePaused timed out waiting for mutation queue") + // Cancel the async block before crashing so it doesn't set joined state + cancelled.set(true) + // Signal sema in case the async block is waiting + sema.signal() + unpauser?.unpause() + it_fatalError("whilePaused timed out waiting for mutation queue - join required for state coherence") } } + DLog("Mutation queue paused") block() if unpauser != nil { @@ -483,8 +747,13 @@ private class TokenExecutorImpl { } unpauser?.unpause() sema.signal() + return true } +#if DEBUG + private static let joinInProgress = MutableAtomicObject(false) +#endif + // Any queue func addSideEffect(_ task: @escaping TokenExecutorTask) { sideEffects.append(task) @@ -601,12 +870,6 @@ private class TokenExecutorImpl { accumulatedLength: &accumulatedLength, delegate: delegate) } - if !isBackgroundSession && tokenQueue.isEmpty { - DLog("Active session completely drained") - Self.activeSessionsWithTokens.mutableAccess { set in - set.remove(ObjectIdentifier(self)) - } - } if gDebugLogging.boolValue { DLog("Finished enumerating token arrays. \(tokenQueue.isEmpty ? "There are no more tokens in the queue" : "The queue is not empty")") } } } @@ -659,12 +922,8 @@ private class TokenExecutorImpl { DLog("commit=\(commit) consume=\(consume) remaining=\(group.arrays.map(\.numberRemaining))") } } - if isBackgroundSession && !Self.activeSessionsWithTokens.value.isEmpty { - // Avoid blocking the active session. If there were multiple mutation threads this - // would be unnecessary. - DLog("Stop processing early because active session has tokens") - return false - } + // FairnessScheduler now provides equal round-robin for all sessions, + // so background sessions don't need to yield to foreground sessions here. } if quitVectorEarly { if gDebugLogging.boolValue { DLog("quitVectorEarly") } @@ -761,6 +1020,44 @@ extension TokenExecutor: IdempotentOperationScheduler { } } +// MARK: - Testing Hooks + +#if ITERM_DEBUG +extension TokenExecutor { + /// Test-only: Returns the current value of availableSlots for verifying accounting invariants. + /// This should equal totalSlots when no tokens are pending. + @objc var testAvailableSlots: Int { + return Int(iTermAtomicInt64Get(availableSlots)) + } + + /// Test-only: Returns the total slots capacity for computing expected values. + @objc var testTotalSlots: Int { + return totalSlots + } + + /// Test-only: Total number of times executeTurn was called (includes blocked calls). + @objc var testExecuteTurnCallCount: Int64 { + return impl.testExecuteTurnCallCount + } + + /// Test-only: Number of times executeTurn completed without being blocked. + /// This increments only when actual token execution happens. + @objc var testExecuteTurnCompletedCount: Int64 { + return impl.testExecuteTurnCompletedCount + } + + /// Test-only: Total number of token arrays consumed across all executions. + @objc var testTokenArraysConsumedCount: Int64 { + return impl.testTokenArraysConsumedCount + } + + /// Test-only: Reset all test counters to zero. + @objc func testResetCounters() { + impl.testResetCounters() + } +} +#endif + // Run a closure but not too often. @objc(iTermPeriodicScheduler) class PeriodicScheduler: NSObject { diff --git a/sources/TwoTierTokenQueue.swift b/sources/TwoTierTokenQueue.swift index 228e500bce..a1091063a7 100644 --- a/sources/TwoTierTokenQueue.swift +++ b/sources/TwoTierTokenQueue.swift @@ -132,6 +132,17 @@ class TwoTierTokenQueue { } } + /// Discard all token arrays and return the count for accounting cleanup. + /// Calls didFinish() on each array to trigger consumption callbacks. + func discardAllAndReturnCount() -> Int { + DLog("discard all and return count") + var count = 0 + for queue in queues { + count += queue.discardAllAndReturnCount() + } + return count + } + func addTokens(_ tokenArray: TokenArray, highPriority: Bool) { if gDebugLogging.boolValue { DLog("add \(tokenArray.count) tokens, highpri=\(highPriority)") @@ -209,6 +220,31 @@ fileprivate class Queue: CustomDebugStringConvertible { } } + /// Discard all arrays and return count. Calls didFinish() on each. + /// + /// This is O(N) because we call didFinish() on each array to trigger its + /// onSemaphoreSignaled callback (which increments availableSlots for backpressure). + /// We must call didFinish() to null out the callback; otherwise deinit would + /// fire it again causing double-increment. + /// + /// Potential optimization: Could be reduced to O(N) cheap pointer nulling + O(1) + /// batch increment by: (1) nulling onSemaphoreSignaled on each array without + /// invoking it, (2) batch-incrementing availableSlots by count, (3) calling + /// backpressureReleaseHandler once. This avoids N atomic operations. + /// + /// Not implemented because this is only called during session shutdown (not a + /// hot path) and typically has few pending tokens. Revisit if usage patterns change. + func discardAllAndReturnCount() -> Int { + mutex.sync { + let count = arrays.count + for array in arrays { + array.didFinish() + } + arrays.removeAll() + return count + } + } + func append(_ tokenArray: TokenArray) { mutex.sync { arrays.append(tokenArray) diff --git a/sources/VT100ScreenMutableState.m b/sources/VT100ScreenMutableState.m index 1e85eba7ea..0d32d14967 100644 --- a/sources/VT100ScreenMutableState.m +++ b/sources/VT100ScreenMutableState.m @@ -70,6 +70,7 @@ @implementation VT100ScreenMutableState { BOOL _runSideEffectAfterTopJoinFinishes; NSMutableArray *_postTriggerActions; void (^_nextPromptBlock)(void); + uint64_t _fairnessSessionId; } // performingJoinedBlock is now centralized in iTermGCD. @@ -123,6 +124,10 @@ - (instancetype)initWithSideEffectPerformer:(id slownessDetector:_triggerEvaluator.triggersSlownessDetector queue:_queue]; _tokenExecutor.delegate = self; + + // Register with FairnessScheduler for round-robin token execution + _fairnessSessionId = [iTermFairnessScheduler.shared register:_tokenExecutor]; + _tokenExecutor.fairnessSessionId = _fairnessSessionId; _echoProbe = [[iTermEchoProbe alloc] initWithQueue:_queue]; _echoProbe.delegate = self; self.unconditionalTemporaryDoubleBuffer.delegate = self; @@ -208,7 +213,13 @@ - (void)setTerminalEnabled:(BOOL)enabled { _commandRangeChangeJoiner = [iTermIdempotentOperationJoiner joinerWithScheduler:_tokenExecutor]; _terminal.delegate = self; _tokenExecutor.delegate = self; + // Re-kick token execution when terminal becomes enabled + [self scheduleTokenExecution]; } else { + // Unregister from FairnessScheduler BEFORE clearing delegate + // This also calls cleanupForUnregistration() to restore availableSlots + [iTermFairnessScheduler.shared unregisterWithSessionId:_fairnessSessionId]; + [_commandRangeChangeJoiner invalidate]; _commandRangeChangeJoiner = nil; _tokenExecutor.delegate = nil; @@ -4903,12 +4914,17 @@ - (void)performBlockWithJoinedThreads:(void (^ NS_NOESCAPE)(VT100Terminal *termi } } -- (void)performSynchroDanceWithBlock:(void (^)(void))block { +// Performs a synchronous join with the mutation queue. +// whilePaused: will crash (it_fatalError) if the join times out, so this method +// always returns YES if it returns at all. The BOOL return is kept for API clarity. +- (BOOL)performSynchroDanceWithBlock:(void (^)(void))block { assert(dispatch_queue_get_label(DISPATCH_CURRENT_QUEUE_LABEL) == dispatch_queue_get_label(dispatch_get_main_queue())); DLog(@"begin"); [iTermGCD setMainQueueSafe:YES]; // Stop the token executor while we run `block`. + // Note: whilePaused: crashes on timeout since skipping the joined block would + // leave mutable state inconsistent. If this returns, the join succeeded. DLog(@"Calling whilePaused:"); [_tokenExecutor whilePaused:^{ // The token executor is now stopped. This runs on the main thread. @@ -4919,6 +4935,7 @@ - (void)performSynchroDanceWithBlock:(void (^)(void))block { DLog(@"unblock executor"); }]; DLog(@"returning"); + return YES; } // This runs on the main queue while the mutation queue waits on `group`. @@ -6475,13 +6492,9 @@ - (NSString *)tokenExecutorCursorCoordString { // Main queue or mutation queue while joined. - (void)tokenExecutorSync { - DLog(@"[side effects] begin"); - [self performLightweightBlockWithJoinedThreads:^(VT100ScreenMutableState * _Nonnull mutableState) { - [self performSideEffect:^(id delegate) { - [delegate screenSync:mutableState]; - } name:@"tokenExecutorSync calling screenSync"]; - }]; - DLog(@"[side effects] end"); + // No-op: Syncing is handled by the cadence-driven refresh path. + // Visible tabs sync at ~60Hz, background tabs at 1Hz. + // Doing an additional sync here is redundant and expensive. } // Runs on mutation queue diff --git a/sources/iTerm2SharedARC-Bridging-Header.h b/sources/iTerm2SharedARC-Bridging-Header.h index aa2c597d07..e9d8875bf4 100644 --- a/sources/iTerm2SharedARC-Bridging-Header.h +++ b/sources/iTerm2SharedARC-Bridging-Header.h @@ -211,3 +211,5 @@ #import "charmaps.h" #import "SSKeychain.h" #import "librailroad_dsl/include/railroad_dsl.h" +#import "MockTaskNotifierTask.h" +#import "MockCoprocess.h" diff --git a/sources/iTermLSOF.h b/sources/iTermLSOF.h index 3a21049a19..7fdb5cfaf6 100644 --- a/sources/iTermLSOF.h +++ b/sources/iTermLSOF.h @@ -19,6 +19,7 @@ int iTermProcPidInfoWrapper(int pid, int flavor, uint64_t arg, void *buffer, in + (NSString *)commandForProcess:(pid_t)pid execName:(NSString **)execName; + (NSArray *)allPids; + (pid_t)ppidForPid:(pid_t)childPid; ++ (NSArray *)childPidsForPid:(pid_t)parentPid; + (NSString *)nameOfProcessWithPid:(pid_t)thePid isForeground:(BOOL *)isForeground; + (NSString *)workingDirectoryOfProcess:(pid_t)pid; + (void)asyncWorkingDirectoryOfProcess:(pid_t)pid diff --git a/sources/iTermLSOF.m b/sources/iTermLSOF.m index c38884cbf3..16803c84c6 100644 --- a/sources/iTermLSOF.m +++ b/sources/iTermLSOF.m @@ -246,6 +246,50 @@ + (pid_t)ppidForPid:(pid_t)childPid { } } +// Returns direct child PIDs for a given parent PID. +// Note: proc_listchildpids returns PID count (not bytes like proc_listpids). ++ (NSArray *)childPidsForPid:(pid_t)parentPid { + int count = proc_listchildpids(parentPid, NULL, 0); + if (count <= 0) { + return @[]; + } + + pid_t *pids = NULL; + int returnedCount = 0; + + // Retry loop: child list can change between size query and fetch + for (int attempt = 0; attempt < 3; attempt++) { + int capacity = count + 16; // Headroom for new children + size_t bufferSize = capacity * sizeof(pid_t); + pids = (pid_t *)iTermMalloc(bufferSize); + + returnedCount = proc_listchildpids(parentPid, pids, (int)bufferSize); + if (returnedCount < 0) { + free(pids); + return @[]; + } + if (returnedCount <= capacity) { + break; + } + // Buffer too small, retry with larger capacity + free(pids); + pids = NULL; + count = returnedCount; + } + + // If all retries exhausted (child count kept growing faster than we could allocate), bail out + if (pids == NULL) { + return @[]; + } + + NSMutableArray *result = [NSMutableArray arrayWithCapacity:returnedCount]; + for (int i = 0; i < returnedCount; i++) { + [result addObject:@(pids[i])]; + } + free(pids); + return result; +} + // Use sysctl magic to get the name of a process and whether it is controlling // the tty. This code was adapted from ps, here: // http://opensource.apple.com/source/adv_cmds/adv_cmds-138.1/ps/ diff --git a/sources/iTermProcessCache.h b/sources/iTermProcessCache.h index 750c74f0ef..6805678b31 100644 --- a/sources/iTermProcessCache.h +++ b/sources/iTermProcessCache.h @@ -19,6 +19,11 @@ NS_ASSUME_NONNULL_BEGIN + (instancetype)sharedInstance; + (iTermProcessCollection *)newProcessCollection; +// Update which root PIDs are foreground (high priority). +// Foreground roots get fast incremental updates via process monitors. +// Background roots have their monitors suspended and rely on the 0.5s cadence. +- (void)setForegroundRootPIDs:(NSSet *)foregroundPIDs; + @end NS_ASSUME_NONNULL_END diff --git a/sources/iTermProcessCache.m b/sources/iTermProcessCache.m index e4d2017fd3..40b3f34a23 100644 --- a/sources/iTermProcessCache.m +++ b/sources/iTermProcessCache.m @@ -16,6 +16,32 @@ #import "NSArray+iTerm.h" #import +// Event bits for coalescer +typedef NS_OPTIONS(unsigned long, iTermProcessCacheCoalescerEvent) { + iTermProcessCacheCoalescerEventHighPriority = 1 << 0, + iTermProcessCacheCoalescerEventLowPriority = 1 << 1, +}; + +// Per-root tracking with cached subtree and epoch +@interface iTermTrackedRootInfo : NSObject +@property (nonatomic) BOOL isHighPriority; +@property (nonatomic, strong, nullable) iTermProcessMonitor *monitor; // nil if suspended (background) +@property (nonatomic, strong) NSMutableIndexSet *cachedDescendants; // PIDs in last snapshot +@property (nonatomic) NSUInteger lastRefreshEpoch; +@property (nonatomic) BOOL isDirty; +@end + +@implementation iTermTrackedRootInfo +- (instancetype)init { + self = [super init]; + if (self) { + _cachedDescendants = [NSMutableIndexSet indexSet]; + _isHighPriority = YES; // Default to high priority (foreground) + } + return self; +} +@end + @interface iTermProcessCache() // Maps process id to deepest foreground job. _lockQueue @@ -27,11 +53,23 @@ @implementation iTermProcessCache { dispatch_queue_t _lockQueue; dispatch_queue_t _workQueue; iTermProcessCollection *_collectionLQ; // _lockQueue - NSMutableDictionary *_trackedPidsLQ; // _lockQueue + NSMutableDictionary *_trackedPidsLQ; // _lockQueue (legacy, being replaced by _rootsLQ) NSMutableArray *_blocksLQ; // _lockQueue BOOL _needsUpdateFlagLQ; // _lockQueue iTermRateLimitedUpdate *_rateLimit; // Main queue. keeps updateIfNeeded from eating all the CPU NSMutableIndexSet *_dirtyPIDsLQ; // _lockQueue + + // Per-root tracking (new coalescing system) + NSMutableDictionary *_rootsLQ; // _lockQueue + + // Global coalescer (dispatch_source DATA_OR) + dispatch_source_t _coalescer; // Merges all monitor events (_workQueue) + NSMutableIndexSet *_dirtyHighRootsLQ; // High-priority roots needing refresh (_lockQueue) + NSMutableIndexSet *_dirtyLowRootsLQ; // Low-priority (background) roots (_lockQueue) + NSUInteger _currentEpoch; // Incremented on each refresh cycle (_workQueue) + + // Throttle for background refresh (ensures 0.5s minimum between background refreshes) + NSTimeInterval _lastBackgroundRefreshTime; } + (instancetype)sharedInstance { @@ -52,6 +90,15 @@ - (instancetype)init { _dirtyPIDsLQ = [NSMutableIndexSet indexSet]; _blocksLQ = [NSMutableArray array]; + // Initialize new coalescing system + _rootsLQ = [NSMutableDictionary dictionary]; + _dirtyHighRootsLQ = [NSMutableIndexSet indexSet]; + _dirtyLowRootsLQ = [NSMutableIndexSet indexSet]; + _currentEpoch = 0; + + // Set up global coalescer (DATA_OR merges concurrent events) + [self setupCoalescer]; + // I'm not fond of this pattern (code that sometimes is synchronous and sometimes not) but // I don't want to break -setNeedsUpdate when called on the main queue and that requires // synchronous initialization. Job managers use the process cache on their own queues and @@ -203,11 +250,14 @@ - (iTermProcessInfo *)deepestForegroundJobForPid:(pid_t)pid { - (void)registerTrackedPID:(pid_t)pid { dispatch_async(_lockQueue, ^{ __weak __typeof(self) weakSelf = self; + + // Create monitor with trackedRootPID for new coalescing system iTermProcessMonitor *monitor = [[iTermProcessMonitor alloc] initWithQueue:self->_lockQueue - callback: - ^(iTermProcessMonitor * monitor, dispatch_source_proc_flags_t flags) { - [weakSelf processMonitor:monitor didChangeFlags:flags]; - }]; + callback:^(iTermProcessMonitor *mon, dispatch_source_proc_flags_t flags) { + [weakSelf processMonitor:mon didChangeFlags:flags]; + } + trackedRootPID:pid]; + iTermProcessInfo *info = [self->_collectionLQ infoForProcessID:pid]; if (!info) { DLog(@"Request update for %@", @(pid)); @@ -216,9 +266,22 @@ - (void)registerTrackedPID:(pid_t)pid { [weakSelf didUpdateForPid:pid]; }]; } else { - monitor.processInfo = info; + [monitor setProcessInfo:info]; } + + // Legacy tracking (for backward compatibility) self->_trackedPidsLQ[@(pid)] = monitor; + + // New coalescing system - create iTermTrackedRootInfo + iTermTrackedRootInfo *rootInfo = [[iTermTrackedRootInfo alloc] init]; + rootInfo.monitor = monitor; + rootInfo.isHighPriority = YES; // New registrations are foreground by default + rootInfo.isDirty = YES; + self->_rootsLQ[@(pid)] = rootInfo; + + // Trigger initial refresh for this root + [self->_dirtyHighRootsLQ addIndex:pid]; + dispatch_source_merge_data(self->_coalescer, iTermProcessCacheCoalescerEventHighPriority); }); } @@ -241,12 +304,36 @@ - (void)didUpdateForPid:(pid_t)pid { // lockQueue - (void)processMonitor:(iTermProcessMonitor *)monitor didChangeFlags:(dispatch_source_proc_flags_t)flags { DLog(@"Flags changed for %@.", @(monitor.processInfo.processID)); + + // New coalescing system: use trackedRootPID to find the root and signal coalescer + pid_t rootPID = monitor.trackedRootPID; + if (rootPID > 0) { + iTermTrackedRootInfo *rootInfo = _rootsLQ[@(rootPID)]; + if (rootInfo) { + rootInfo.isDirty = YES; + + // Preserve dirty PID signaling for processIsDirty: callers (e.g., session title updates) + [_dirtyPIDsLQ addIndex:rootPID]; + + if (rootInfo.isHighPriority) { + [_dirtyHighRootsLQ addIndex:rootPID]; + // Signal coalescer (merges with other concurrent events) + dispatch_source_merge_data(_coalescer, iTermProcessCacheCoalescerEventHighPriority); + } else { + // Background root - just mark dirty, will be handled by cadence timer + [_dirtyLowRootsLQ addIndex:rootPID]; + } + return; // New system handled it + } + } + + // Legacy path: fall back to old behavior for monitors not in the new system _needsUpdateFlagLQ = YES; const BOOL wasForced = self.forcingLQ; self.forcingLQ = YES; if (!wasForced) { dispatch_async(dispatch_get_main_queue(), ^{ - DLog(@"Forcing update"); + DLog(@"Forcing update (legacy path)"); [self->_rateLimit performRateLimitedSelector:@selector(updateIfNeeded) onTarget:self withObject:nil]; [self->_rateLimit performWithinDuration:0.0167]; self.forcingLQ = NO; @@ -270,7 +357,20 @@ - (BOOL)processIsDirty:(pid_t)pid { // Any queue - (void)unregisterTrackedPID:(pid_t)pid { dispatch_async(_lockQueue, ^{ + // Legacy cleanup [self->_trackedPidsLQ removeObjectForKey:@(pid)]; + + // New coalescing system cleanup + iTermTrackedRootInfo *rootInfo = self->_rootsLQ[@(pid)]; + if (rootInfo) { + // Invalidate the monitor (stops the dispatch source) + [rootInfo.monitor invalidate]; + [self->_rootsLQ removeObjectForKey:@(pid)]; + } + + // Remove from dirty sets + [self->_dirtyHighRootsLQ removeIndex:pid]; + [self->_dirtyLowRootsLQ removeIndex:pid]; }); } @@ -283,6 +383,14 @@ - (void)sendSignal:(int32_t)signal toPID:(int32_t)pid { // Any queue - (void)updateIfNeeded { DLog(@"updateIfNeeded"); + + // Process background roots only on ~0.5s cadence (not on every call) + NSTimeInterval now = [NSDate timeIntervalSinceReferenceDate]; + if (now - _lastBackgroundRefreshTime >= 0.5) { + _lastBackgroundRefreshTime = now; + [self backgroundRefreshTick]; + } + __block BOOL needsUpdate; dispatch_sync(_lockQueue, ^{ needsUpdate = self->_needsUpdateFlagLQ; @@ -360,6 +468,360 @@ - (void)reallyUpdate { } } +#pragma mark - Coalescer + +- (void)setupCoalescer { + _coalescer = dispatch_source_create(DISPATCH_SOURCE_TYPE_DATA_OR, 0, 0, _workQueue); + + __weak __typeof(self) weakSelf = self; + dispatch_source_set_event_handler(_coalescer, ^{ + [weakSelf handleCoalescedEvents]; + }); + dispatch_resume(_coalescer); +} + +// _workQueue +- (void)handleCoalescedEvents { + unsigned long data = dispatch_source_get_data(_coalescer); + BOOL hasHighPriority = (data & iTermProcessCacheCoalescerEventHighPriority) != 0; + + if (hasHighPriority) { + // Immediate incremental refresh for dirty high-priority roots + [self refreshDirtyHighPriorityRoots]; + } + // Low-priority handled by 0.5s cadence timer (backgroundRefreshTick), not here +} + +// _workQueue +- (void)refreshDirtyHighPriorityRoots { + _currentEpoch++; + + __block NSMutableArray *dirtyRoots = [NSMutableArray array]; + __block iTermProcessCollection *collection; + + dispatch_sync(_lockQueue, ^{ + [self->_dirtyHighRootsLQ enumerateIndexesUsingBlock:^(NSUInteger pid, BOOL *stop) { + [dirtyRoots addObject:@(pid)]; + }]; + [self->_dirtyHighRootsLQ removeAllIndexes]; + collection = self->_collectionLQ; // Capture reference under lock + }); + + if (!collection || dirtyRoots.count == 0) { + return; + } + + NSMutableDictionary *newCache = [NSMutableDictionary dictionary]; + NSMutableArray *confirmedDeadRoots = [NSMutableArray array]; + + for (NSNumber *rootPidNum in dirtyRoots) { + pid_t rootPid = rootPidNum.intValue; + iTermProcessInfo *result = [self refreshRootCollectionFirst:rootPid + collection:collection + epoch:_currentEpoch]; + if (result) { + newCache[rootPidNum] = result; + } else { + // Refresh returned nil - check if process is actually dead before removing cache entry. + // This avoids dropping cached data due to collection staleness. + if (![self processIsAlive:rootPid]) { + [confirmedDeadRoots addObject:rootPidNum]; + } + // If process is still alive, preserve existing cache entry (don't add to newCache or deadRoots) + } + } + + dispatch_sync(_lockQueue, ^{ + // Merge new cache entries into existing cache + NSMutableDictionary *mutableCache = [self->_cachedDeepestForegroundJobLQ mutableCopy] ?: [NSMutableDictionary dictionary]; + [mutableCache addEntriesFromDictionary:newCache]; + + // Only remove entries for roots confirmed dead (not just fallback/stale) + for (NSNumber *deadPid in confirmedDeadRoots) { + [mutableCache removeObjectForKey:deadPid]; + } + + self->_cachedDeepestForegroundJobLQ = [mutableCache copy]; + + // Evict unseen nodes from per-root caches + for (NSNumber *rootPidNum in dirtyRoots) { + [self evictUnseenNodesForRoot:rootPidNum.intValue epoch:self->_currentEpoch]; + } + }); +} + +// _workQueue - Fast path: walk existing collection (no PID enumeration syscalls) +- (iTermProcessInfo *)refreshRootCollectionFirst:(pid_t)rootPid + collection:(iTermProcessCollection *)collection + epoch:(NSUInteger)epoch { + __block iTermTrackedRootInfo *rootInfo; + dispatch_sync(_lockQueue, ^{ + rootInfo = self->_rootsLQ[@(rootPid)]; + }); + + if (!rootInfo) { + return nil; + } + + iTermProcessInfo *root = [collection infoForProcessID:rootPid]; + if (!root) { + // Root disappeared - fall back to kernel query + return [self refreshRootWithKernelFallback:rootPid epoch:epoch]; + } + + // Walk foreground chain preferentially (root → fg child → fg grandchild) + // Collect PIDs to mark as seen (batched to avoid per-PID dispatch_sync) + NSMutableIndexSet *seenPIDs = [NSMutableIndexSet indexSet]; + iTermProcessInfo *candidate = root; + int depth = 0; + while (depth < 50) { + [seenPIDs addIndex:candidate.processID]; + + iTermProcessInfo *fgChild = [self findForegroundChildOf:candidate inCollection:collection]; + if (!fgChild) { + break; + } + + // Verify child still exists in collection + if (![collection infoForProcessID:fgChild.processID]) { + // Inconsistency detected - bounded kernel fallback + return [self refreshRootWithKernelFallback:rootPid epoch:epoch]; + } + + candidate = fgChild; + depth++; + } + + // Single dispatch_sync to mark all seen PIDs and clear dirty flag + dispatch_sync(_lockQueue, ^{ + [rootInfo.cachedDescendants addIndexes:seenPIDs]; + rootInfo.lastRefreshEpoch = epoch; + rootInfo.isDirty = NO; + }); + + return candidate; // Deepest foreground job +} + +// _workQueue - Find foreground child using collection's cached foreground state +- (iTermProcessInfo *)findForegroundChildOf:(iTermProcessInfo *)parent + inCollection:(iTermProcessCollection *)collection { + // iTermProcessInfo has children and isForegroundJob properties + for (iTermProcessInfo *child in parent.children) { + if (child.isForegroundJob) { + return child; + } + } + return nil; +} + +// _workQueue - Fallback: bounded kernel queries for this root only +- (iTermProcessInfo *)refreshRootWithKernelFallback:(pid_t)rootPid epoch:(NSUInteger)epoch { + // Query kernel for root's immediate children only (bounded) + NSArray *children = [iTermLSOF childPidsForPid:rootPid]; + + __block iTermTrackedRootInfo *rootInfo; + dispatch_sync(_lockQueue, ^{ + rootInfo = self->_rootsLQ[@(rootPid)]; + }); + + if (!rootInfo) { + return nil; + } + + // Collect all PIDs to mark as seen (batched to avoid per-PID dispatch_sync) + NSMutableIndexSet *seenPIDs = [NSMutableIndexSet indexSet]; + [seenPIDs addIndex:rootPid]; + for (NSNumber *childPid in children) { + [seenPIDs addIndex:childPid.unsignedIntegerValue]; + } + + // Single dispatch_sync to mark all seen PIDs and clear dirty flag + dispatch_sync(_lockQueue, ^{ + [rootInfo.cachedDescendants addIndexes:seenPIDs]; + rootInfo.lastRefreshEpoch = epoch; + rootInfo.isDirty = NO; + }); + + return nil; // Let the regular cadence update provide the full answer +} + +// _lockQueue - Remove PIDs that weren't seen in the current epoch +- (void)evictUnseenNodesForRoot:(pid_t)rootPid epoch:(NSUInteger)epoch { + iTermTrackedRootInfo *info = _rootsLQ[@(rootPid)]; + if (!info) { + return; + } + + // If this root's last refresh epoch is older than current, it wasn't refreshed + // In that case, don't evict (it might be a background root) + if (info.lastRefreshEpoch < epoch) { + return; + } + + // For now, we don't track per-PID epochs, so we can't selectively evict + // The epoch system mainly prevents drift when roots become stale + // A more sophisticated implementation could track (pid -> lastSeenEpoch) in cachedDescendants +} + +// Check if a process is still alive using kill(pid, 0) +// Returns YES if process exists, NO if confirmed dead +- (BOOL)processIsAlive:(pid_t)pid { + if (pid <= 0) { + return NO; + } + int result = kill(pid, 0); + if (result == 0) { + return YES; // Process exists and we have permission to signal it + } + // Check errno: ESRCH means no such process, EPERM means exists but no permission + return (errno == EPERM); +} + +#pragma mark - Background Monitor Suspension + +// _lockQueue - Called when a tab loses foreground +- (void)suspendMonitorForRootLQ:(pid_t)rootPid { + iTermTrackedRootInfo *info = _rootsLQ[@(rootPid)]; + if (!info) { + return; + } + + DLog(@"Suspending monitor for root %@", @(rootPid)); + if (info.monitor) { + [info.monitor pauseMonitoring]; + } + info.isHighPriority = NO; + [_dirtyLowRootsLQ addIndex:rootPid]; +} + +// _lockQueue - Called when a tab becomes foreground +- (void)resumeMonitorForRootLQ:(pid_t)rootPid { + iTermTrackedRootInfo *info = _rootsLQ[@(rootPid)]; + if (!info) { + return; + } + + DLog(@"Resuming monitor for root %@", @(rootPid)); + info.isHighPriority = YES; + + if (info.monitor) { + [info.monitor resumeMonitoring]; + } else { + [self createMonitorForRootLQ:rootPid info:info]; + } + + // Immediate refresh for newly-foregrounded root + info.isDirty = YES; + [_dirtyHighRootsLQ addIndex:rootPid]; + dispatch_source_merge_data(_coalescer, iTermProcessCacheCoalescerEventHighPriority); +} + +// _lockQueue - Create a new monitor for a root PID +- (void)createMonitorForRootLQ:(pid_t)rootPid info:(iTermTrackedRootInfo *)info { + __weak __typeof(self) weakSelf = self; + iTermProcessMonitor *monitor = [[iTermProcessMonitor alloc] initWithQueue:_lockQueue + callback:^(iTermProcessMonitor *mon, dispatch_source_proc_flags_t flags) { + [weakSelf processMonitor:mon didChangeFlags:flags]; + } + trackedRootPID:rootPid]; + + iTermProcessInfo *processInfo = [_collectionLQ infoForProcessID:rootPid]; + if (processInfo) { + [monitor setProcessInfo:processInfo]; + } + + info.monitor = monitor; +} + +// Any queue - Public API for tab selection +- (void)setForegroundRootPIDs:(NSSet *)foregroundPIDs { + dispatch_async(_lockQueue, ^{ + [self setForegroundRootPIDsLQ:foregroundPIDs]; + }); +} + +// _lockQueue +- (void)setForegroundRootPIDsLQ:(NSSet *)foregroundPIDs { + for (NSNumber *pidNum in _rootsLQ) { + iTermTrackedRootInfo *info = _rootsLQ[pidNum]; + BOOL shouldBeForeground = [foregroundPIDs containsObject:pidNum]; + + if (shouldBeForeground && !info.isHighPriority) { + [self resumeMonitorForRootLQ:pidNum.intValue]; + } else if (!shouldBeForeground && info.isHighPriority) { + [self suspendMonitorForRootLQ:pidNum.intValue]; + } + } +} + +#pragma mark - Amortized Background Refresh + +// Called by 0.5s cadence timer - refresh 1-2 background roots per tick +- (void)backgroundRefreshTick { + __block NSArray *toRefresh = nil; + __block iTermProcessCollection *collection = nil; + __block NSUInteger epoch = 0; + + dispatch_sync(_lockQueue, ^{ + if (self->_dirtyLowRootsLQ.count == 0) { + return; + } + + NSMutableArray *picked = [NSMutableArray array]; + __block NSUInteger remaining = 2; // Process at most 2 background roots per tick + [self->_dirtyLowRootsLQ enumerateIndexesUsingBlock:^(NSUInteger pid, BOOL *stop) { + [picked addObject:@(pid)]; + if (--remaining == 0) { + *stop = YES; + } + }]; + + for (NSNumber *pidNum in picked) { + [self->_dirtyLowRootsLQ removeIndex:pidNum.unsignedIntegerValue]; + } + + toRefresh = [picked copy]; + collection = self->_collectionLQ; // Capture under lock + // Reuse current epoch (background refreshes don't advance epoch) + epoch = self->_currentEpoch; + }); + + if (toRefresh.count == 0) { + return; + } + + dispatch_async(_workQueue, ^{ + NSMutableArray *confirmedDeadRoots = [NSMutableArray array]; + NSMutableDictionary *newCache = [NSMutableDictionary dictionary]; + + for (NSNumber *pidNum in toRefresh) { + iTermProcessInfo *result = [self refreshRootCollectionFirst:pidNum.intValue + collection:collection + epoch:epoch]; + if (result) { + newCache[pidNum] = result; + } else { + // Only remove cache entry if process is confirmed dead + if (![self processIsAlive:pidNum.intValue]) { + [confirmedDeadRoots addObject:pidNum]; + } + } + } + + // Update cache with results and remove confirmed dead entries + if (newCache.count > 0 || confirmedDeadRoots.count > 0) { + dispatch_sync(self->_lockQueue, ^{ + NSMutableDictionary *mutableCache = [self->_cachedDeepestForegroundJobLQ mutableCopy] ?: [NSMutableDictionary dictionary]; + [mutableCache addEntriesFromDictionary:newCache]; + for (NSNumber *deadPid in confirmedDeadRoots) { + [mutableCache removeObjectForKey:deadPid]; + } + self->_cachedDeepestForegroundJobLQ = [mutableCache copy]; + }); + } + }); +} + #pragma mark - Notifications // Main queue diff --git a/sources/iTermProcessMonitor.h b/sources/iTermProcessMonitor.h index 9752b9a6b0..96132004f3 100644 --- a/sources/iTermProcessMonitor.h +++ b/sources/iTermProcessMonitor.h @@ -20,14 +20,29 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, readonly) dispatch_queue_t queue; @property (nullable, nonatomic, weak, readonly) iTermProcessMonitor *parent; +// The root PID that this monitor (or its ancestor) was created to track. +// Set at creation time and propagated to children for O(1) lookup in callbacks. +@property (nonatomic, readonly) pid_t trackedRootPID; + +- (instancetype)initWithQueue:(dispatch_queue_t)queue + callback:(void (^)(iTermProcessMonitor *, dispatch_source_proc_flags_t))callback + trackedRootPID:(pid_t)trackedRootPID NS_DESIGNATED_INITIALIZER; + +// Legacy initializer - creates a monitor without trackedRootPID (defaults to 0) - (instancetype)initWithQueue:(dispatch_queue_t)queue - callback:(void (^)(iTermProcessMonitor *, dispatch_source_proc_flags_t))callback NS_DESIGNATED_INITIALIZER; + callback:(void (^)(iTermProcessMonitor *, dispatch_source_proc_flags_t))callback; - (instancetype)init NS_UNAVAILABLE; -// Stops monitoring. +// Stops monitoring permanently. - (void)invalidate; +// Temporarily pauses monitoring (dispatch source suspended). Call resumeMonitoring to resume. +- (void)pauseMonitoring; + +// Resumes monitoring after pauseMonitoring was called. +- (void)resumeMonitoring; + - (void)addChild:(iTermProcessMonitor *)child; // Returns whether this or any child changed. Begins monitoring if nonnil. diff --git a/sources/iTermProcessMonitor.m b/sources/iTermProcessMonitor.m index 5374e2626e..86d70f4156 100644 --- a/sources/iTermProcessMonitor.m +++ b/sources/iTermProcessMonitor.m @@ -19,19 +19,27 @@ @interface iTermProcessMonitor() @implementation iTermProcessMonitor { dispatch_source_t _source; NSMutableArray *_children; + BOOL _isPaused; } - (instancetype)initWithQueue:(dispatch_queue_t)queue - callback:(void (^)(iTermProcessMonitor *, dispatch_source_proc_flags_t))callback { + callback:(void (^)(iTermProcessMonitor *, dispatch_source_proc_flags_t))callback + trackedRootPID:(pid_t)trackedRootPID { self = [super init]; if (self) { _callback = [callback copy]; _queue = queue; _children = [NSMutableArray array]; + _trackedRootPID = trackedRootPID; } return self; } +- (instancetype)initWithQueue:(dispatch_queue_t)queue + callback:(void (^)(iTermProcessMonitor *, dispatch_source_proc_flags_t))callback { + return [self initWithQueue:queue callback:callback trackedRootPID:0]; +} + - (BOOL)setProcessInfo:(iTermProcessInfo *)processInfo { return [self setProcessInfo:processInfo depth:0]; } @@ -92,8 +100,10 @@ - (BOOL)setProcessInfo:(iTermProcessInfo *)processInfo depth:(NSInteger)depth { return; } - // Create a new one. - child = [[iTermProcessMonitor alloc] initWithQueue:_queue callback:_callback]; + // Create a new one. Propagate trackedRootPID from parent. + child = [[iTermProcessMonitor alloc] initWithQueue:_queue + callback:_callback + trackedRootPID:_trackedRootPID]; [child setProcessInfo:childInfo depth:depth + 1]; [childrenToAdd addObject:child]; }]; @@ -132,6 +142,11 @@ - (void)invalidate { return; } DLog(@"Stop monitoring process %@", _processInfo); + // If paused, need to resume before canceling (dispatch sources must be resumed before cancel) + if (_isPaused) { + dispatch_resume(_source); + _isPaused = NO; + } dispatch_source_cancel(_source); _source = nil; [_parent removeChild:self]; @@ -143,6 +158,36 @@ - (void)invalidate { _processInfo = nil; } +// Called on _queue +- (void)pauseMonitoring { + if (_source == nil || _isPaused) { + return; + } + DLog(@"Pause monitoring process %@", _processInfo); + dispatch_suspend(_source); + _isPaused = YES; + + // Recursively pause children + for (iTermProcessMonitor *child in _children) { + [child pauseMonitoring]; + } +} + +// Called on _queue +- (void)resumeMonitoring { + if (_source == nil || !_isPaused) { + return; + } + DLog(@"Resume monitoring process %@", _processInfo); + dispatch_resume(_source); + _isPaused = NO; + + // Recursively resume children + for (iTermProcessMonitor *child in _children) { + [child resumeMonitoring]; + } +} + // Called on _queue - (void)addChild:(iTermProcessMonitor *)child { [_children addObject:child]; diff --git a/sources/iTermUpdateCadenceController.m b/sources/iTermUpdateCadenceController.m index 4237251e43..daa3968b1e 100644 --- a/sources/iTermUpdateCadenceController.m +++ b/sources/iTermUpdateCadenceController.m @@ -286,22 +286,26 @@ - (void)setGCDUpdateCadence:(NSTimeInterval)cadence liveResizing:(BOOL)liveResiz _cadence = period; - if (_gcdUpdateTimer != nil) { - dispatch_source_cancel(_gcdUpdateTimer); - _gcdUpdateTimer = nil; + if (_gcdUpdateTimer == nil) { + // First-time creation: set up the dispatch source and event handler + _gcdUpdateTimer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_main_queue()); + __weak __typeof(self) weakSelf = self; + dispatch_source_set_event_handler(_gcdUpdateTimer, ^{ + DLog(@"GCD cadence timer fired for %@", weakSelf); + [weakSelf maybeUpdateDisplay]; + }); + dispatch_source_set_timer(_gcdUpdateTimer, + dispatch_time(DISPATCH_TIME_NOW, period * NSEC_PER_SEC), + period * NSEC_PER_SEC, + 0.0005 * NSEC_PER_SEC); + dispatch_resume(_gcdUpdateTimer); + } else { + // Reuse existing timer: just update the timing parameters + dispatch_source_set_timer(_gcdUpdateTimer, + dispatch_time(DISPATCH_TIME_NOW, period * NSEC_PER_SEC), + period * NSEC_PER_SEC, + 0.0005 * NSEC_PER_SEC); } - - _gcdUpdateTimer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_main_queue()); - dispatch_source_set_timer(_gcdUpdateTimer, - dispatch_time(DISPATCH_TIME_NOW, period * NSEC_PER_SEC), - period * NSEC_PER_SEC, - 0.0005 * NSEC_PER_SEC); - __weak __typeof(self) weakSelf = self; - dispatch_source_set_event_handler(_gcdUpdateTimer, ^{ - DLog(@"GCD cadence timer fired for %@", weakSelf); - [weakSelf maybeUpdateDisplay]; - }); - dispatch_resume(_gcdUpdateTimer); } - (BOOL)updateTimerIsValid { diff --git a/tools/add_to_xcode_project.py b/tools/add_to_xcode_project.py new file mode 100755 index 0000000000..38c1dcaddc --- /dev/null +++ b/tools/add_to_xcode_project.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +""" +Add Swift files to the iTerm2 Xcode project. + +This script adds files to the iTerm2SharedARC target. ModernTests uses +PBXFileSystemSynchronizedRootGroup which auto-syncs with the filesystem, +so test files don't need to be added manually. + +Usage: + python3 tools/add_to_xcode_project.py sources/FairnessScheduler.swift +""" + +import sys +import os +import random +import re + +def generate_uuid(): + """Generate a 24-character hex UUID like Xcode uses.""" + return ''.join(random.choices('0123456789ABCDEF', k=24)) + +def add_swift_file_to_project(filepath, project_path): + """Add a Swift file to the iTerm2SharedARC target.""" + + filename = os.path.basename(filepath) + + # Generate UUIDs + file_ref_uuid = generate_uuid() + build_file_uuid = generate_uuid() + + print(f"Adding {filename} to project...") + print(f" File Reference UUID: {file_ref_uuid}") + print(f" Build File UUID: {build_file_uuid}") + + # Read the project file + with open(project_path, 'r') as f: + content = f.read() + + # Check if file is already in project + if filename in content: + print(f" WARNING: {filename} appears to already be in the project!") + return False + + # 1. Add PBXFileReference entry + # Find a good insertion point (after TokenArray.swift reference) + file_ref_entry = f'\t\t{file_ref_uuid} /* {filename} */ = {{isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = {filename}; sourceTree = ""; }};\n' + + # Find TokenArray.swift file reference and insert after it + token_array_pattern = r'(\t\tA69553882DD1AA7B002E694D /\* TokenArray\.swift \*/ = \{[^}]+\};\n)' + match = re.search(token_array_pattern, content) + if match: + insert_pos = match.end() + content = content[:insert_pos] + file_ref_entry + content[insert_pos:] + print(f" Added PBXFileReference entry") + else: + print(" ERROR: Could not find TokenArray.swift file reference") + return False + + # 2. Add PBXBuildFile entry + build_file_entry = f'\t\t{build_file_uuid} /* {filename} in Sources */ = {{isa = PBXBuildFile; fileRef = {file_ref_uuid} /* {filename} */; }};\n' + + # Find TokenArray.swift build file and insert after it + token_array_build_pattern = r'(\t\tA69553892DD1AA7F002E694D /\* TokenArray\.swift in Sources \*/ = \{[^}]+\};\n)' + match = re.search(token_array_build_pattern, content) + if match: + insert_pos = match.end() + content = content[:insert_pos] + build_file_entry + content[insert_pos:] + print(f" Added PBXBuildFile entry") + else: + print(" ERROR: Could not find TokenArray.swift build file entry") + return False + + # 3. Add to sources group (near TokenArray.swift) + group_entry = f'\t\t\t\t{file_ref_uuid} /* {filename} */,\n' + + # Find TokenArray.swift in the group and insert after it + group_pattern = r'(\t\t\t\tA69553882DD1AA7B002E694D /\* TokenArray\.swift \*/,\n)' + match = re.search(group_pattern, content) + if match: + insert_pos = match.end() + content = content[:insert_pos] + group_entry + content[insert_pos:] + print(f" Added to sources group") + else: + print(" ERROR: Could not find TokenArray.swift in sources group") + return False + + # 4. Add to iTerm2SharedARC Sources build phase + build_phase_entry = f'\t\t\t\t{build_file_uuid} /* {filename} in Sources */,\n' + + # Find TokenArray.swift in build phase and insert after it + build_phase_pattern = r'(\t\t\t\tA69553892DD1AA7F002E694D /\* TokenArray\.swift in Sources \*/,\n)' + match = re.search(build_phase_pattern, content) + if match: + insert_pos = match.end() + content = content[:insert_pos] + build_phase_entry + content[insert_pos:] + print(f" Added to iTerm2SharedARC Sources build phase") + else: + print(" ERROR: Could not find TokenArray.swift in build phase") + return False + + # Write the updated project file + with open(project_path, 'w') as f: + f.write(content) + + print(f" Successfully added {filename} to project!") + return True + +def main(): + if len(sys.argv) < 2: + print("Usage: python3 tools/add_to_xcode_project.py ") + print("Example: python3 tools/add_to_xcode_project.py sources/FairnessScheduler.swift") + sys.exit(1) + + filepath = sys.argv[1] + project_path = "iTerm2.xcodeproj/project.pbxproj" + + if not os.path.exists(filepath): + print(f"Error: File not found: {filepath}") + sys.exit(1) + + if not os.path.exists(project_path): + print(f"Error: Project file not found: {project_path}") + sys.exit(1) + + success = add_swift_file_to_project(filepath, project_path) + sys.exit(0 if success else 1) + +if __name__ == "__main__": + main() diff --git a/tools/run_fairness_tests.sh b/tools/run_fairness_tests.sh new file mode 100755 index 0000000000..050fa2bfd5 --- /dev/null +++ b/tools/run_fairness_tests.sh @@ -0,0 +1,295 @@ +#!/bin/bash +# +# Run fairness scheduler tests in isolation from legacy tests. +# Usage: +# ./tools/run_fairness_tests.sh # Run all fairness tests +# ./tools/run_fairness_tests.sh SessionTests # Run specific test class +# + +# Don't use set -e so we can capture exit codes and check for crashes +PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$PROJECT_DIR" + +# Crash detection: Check for existing crash reports before tests run +CRASH_DIR="$HOME/Library/Logs/DiagnosticReports" + +# Check for pre-existing crash reports +EXISTING_CRASHES=$(ls -1 "$CRASH_DIR"/*iTerm*.ips 2>/dev/null) +if [[ -n "$EXISTING_CRASHES" ]]; then + echo "==========================================" + echo "ERROR: Pre-existing iTerm2 crash reports found" + echo "==========================================" + echo "$EXISTING_CRASHES" + echo "" + echo "Review and/or delete before running tests:" + echo " - To view: head -100 | grep -A5 'exception\\|termination'" + echo " - To delete: rm $CRASH_DIR/*iTerm*.ips" + echo "==========================================" + exit 1 +fi + +CRASH_REPORTS_BEFORE=0 + +# Function to check for new crash reports +check_for_crashes() { + local crash_reports_after=$(ls -1 "$CRASH_DIR"/*iTerm*.ips 2>/dev/null | wc -l | tr -d ' ') + if [[ "$crash_reports_after" -gt "$CRASH_REPORTS_BEFORE" ]]; then + echo "" + echo "==========================================" + echo "WARNING: NEW CRASH REPORT(S) DETECTED!" + echo "==========================================" + echo "New crash reports found in $CRASH_DIR:" + # Show new crash reports (those newer than when we started) + ls -lt "$CRASH_DIR"/*iTerm*.ips 2>/dev/null | head -$((crash_reports_after - CRASH_REPORTS_BEFORE)) + echo "" + echo "To view crash details:" + echo " head -100 $CRASH_DIR/iTerm2-*.ips | grep -A5 'exception\|termination'" + echo "==========================================" + return 1 + fi + return 0 +} + +# Test classes that are part of the fairness scheduler test suite +# Milestone 1: FairnessScheduler (Checkpoint 1) +FAIRNESS_TEST_CLASSES=( + "FairnessSchedulerSessionTests" + "FairnessSchedulerBusyListTests" + "FairnessSchedulerTurnExecutionTests" + "FairnessSchedulerRoundRobinTests" + "FairnessSchedulerThreadSafetyTests" + "FairnessSchedulerLifecycleEdgeCaseTests" + "FairnessSchedulerSustainedLoadTests" +) + +# Milestone 2: TokenExecutor Fairness (Checkpoint 2) +TOKENEXECUTOR_TEST_CLASSES=( + "TokenExecutorNonBlockingTests" + "TokenExecutorAccountingTests" + "TokenExecutorExecuteTurnTests" + "TokenExecutorBudgetEdgeCaseTests" + "TokenExecutorSchedulerEntryPointTests" + "TokenExecutorLegacyRemovalTests" + "TokenExecutorCleanupTests" + "TokenExecutorAccountingInvariantTests" + "TokenExecutorCompletionCallbackTests" + "TokenExecutorBudgetEnforcementDetailedTests" + "TokenExecutorSameQueueGroupBoundaryTests" + "TokenExecutorAvailableSlotsBoundaryTests" + "TokenExecutorHighPriorityOrderingTests" + "TwoTierTokenQueueTests" + "TwoTierTokenQueueGroupingTests" +) + +# Milestone 3: PTYTask Dispatch Sources (Checkpoint 3) +PTYTASK_TEST_CLASSES=( + "PTYTaskDispatchSourceLifecycleTests" + "PTYTaskReadStateTests" + "PTYTaskWriteStateTests" + "PTYTaskEventHandlerTests" + "PTYTaskPauseStateTests" + "PTYTaskIoAllowedPredicateTests" + "PTYTaskBackpressureIntegrationTests" + "PTYTaskUseDispatchSourceTests" + "PTYTaskStateTransitionTests" + "PTYTaskEdgeCaseTests" + "PTYTaskReadHandlerPipelineTests" + "PTYTaskWritePathRoundTripTests" +) + +# Milestone 4: TaskNotifier Changes (Checkpoint 4) +TASKNOTIFIER_TEST_CLASSES=( + "TaskNotifierDispatchSourceProtocolTests" + "TaskNotifierSelectLoopTests" + "TaskNotifierMixedModeTests" +) + +# Milestone 4b: Coprocess Bridge Tests (separate due to hang investigation) +COPROCESS_TEST_CLASSES=( + "CoprocessDataFlowBridgeTests" +) + +# Milestone 5: Integration (Checkpoint 5) +INTEGRATION_TEST_CLASSES=( + "IntegrationRegistrationTests" + "IntegrationUnregistrationTests" + "IntegrationAutomaticSchedulingTests" + "IntegrationRekickTests" + "IntegrationBackgroundForegroundFairnessTests" + "IntegrationMutationQueueTests" + "IntegrationDispatchSourceActivationTests" + "IntegrationPTYSessionWiringTests" + "PTYSessionWiringTests" + "PTYSessionBackpressureWiringTests" + "DispatchSourceLifecycleIntegrationTests" + "BackpressureIntegrationTests" + "SessionLifecycleIntegrationTests" +) + +# All test classes +ALL_TEST_CLASSES=("${FAIRNESS_TEST_CLASSES[@]}" "${TOKENEXECUTOR_TEST_CLASSES[@]}" "${PTYTASK_TEST_CLASSES[@]}" "${TASKNOTIFIER_TEST_CLASSES[@]}" "${COPROCESS_TEST_CLASSES[@]}" "${INTEGRATION_TEST_CLASSES[@]}") + +# Build the -only-testing arguments +build_only_testing_args() { + local filter="$1" + local args="" + local classes_to_check=() + + # Determine which classes to check based on filter + case "$filter" in + milestone1|phase1|checkpoint1) + classes_to_check=("${FAIRNESS_TEST_CLASSES[@]}") + ;; + milestone2|phase2|checkpoint2) + classes_to_check=("${TOKENEXECUTOR_TEST_CLASSES[@]}") + ;; + milestone3|phase3|checkpoint3) + classes_to_check=("${PTYTASK_TEST_CLASSES[@]}") + ;; + milestone4|phase4|checkpoint4) + classes_to_check=("${TASKNOTIFIER_TEST_CLASSES[@]}") + ;; + coprocess) + classes_to_check=("${COPROCESS_TEST_CLASSES[@]}") + ;; + milestone5|phase5|checkpoint5) + classes_to_check=("${INTEGRATION_TEST_CLASSES[@]}") + ;; + *) + classes_to_check=("${ALL_TEST_CLASSES[@]}") + ;; + esac + + for class in "${classes_to_check[@]}"; do + if [[ -z "$filter" ]] || [[ "$filter" == "milestone1" ]] || [[ "$filter" == "milestone2" ]] || [[ "$filter" == "milestone3" ]] || [[ "$filter" == "milestone4" ]] || [[ "$filter" == "milestone5" ]] || \ + [[ "$filter" == "phase1" ]] || [[ "$filter" == "phase2" ]] || [[ "$filter" == "phase3" ]] || [[ "$filter" == "phase4" ]] || [[ "$filter" == "phase5" ]] || \ + [[ "$filter" == "checkpoint1" ]] || [[ "$filter" == "checkpoint2" ]] || [[ "$filter" == "checkpoint3" ]] || [[ "$filter" == "checkpoint4" ]] || [[ "$filter" == "checkpoint5" ]] || \ + [[ "$filter" == "coprocess" ]] || [[ "$class" == *"$filter"* ]]; then + args="$args -only-testing:ModernTests/$class" + fi + done + + echo "$args" +} + +# Parse arguments +FILTER="" +VERBOSE=0 + +while [[ $# -gt 0 ]]; do + case $1 in + -v|--verbose) + VERBOSE=1 + shift + ;; + *) + FILTER="$1" + shift + ;; + esac +done + +ONLY_TESTING_ARGS=$(build_only_testing_args "$FILTER") + +if [[ -z "$ONLY_TESTING_ARGS" ]]; then + echo "Error: No matching test classes found for filter: $FILTER" + echo "" + echo "Usage: $0 [filter]" + echo "" + echo "Filters:" + echo " milestone1 - Run FairnessScheduler tests only (Checkpoint 1)" + echo " milestone2 - Run TokenExecutor fairness tests only (Checkpoint 2)" + echo " milestone3 - Run PTYTask dispatch source tests only (Checkpoint 3)" + echo " milestone4 - Run TaskNotifier dispatch source tests only (Checkpoint 4)" + echo " milestone5 - Run Integration tests only (Checkpoint 5)" + echo " - Run tests matching class name" + echo " (no filter) - Run all fairness tests" + echo "" + echo "Milestone 1 test classes (FairnessScheduler):" + for class in "${FAIRNESS_TEST_CLASSES[@]}"; do + echo " - $class" + done + echo "" + echo "Milestone 2 test classes (TokenExecutor):" + for class in "${TOKENEXECUTOR_TEST_CLASSES[@]}"; do + echo " - $class" + done + echo "" + echo "Milestone 3 test classes (PTYTask):" + for class in "${PTYTASK_TEST_CLASSES[@]}"; do + echo " - $class" + done + echo "" + echo "Milestone 4 test classes (TaskNotifier):" + for class in "${TASKNOTIFIER_TEST_CLASSES[@]}"; do + echo " - $class" + done + echo "" + echo "Milestone 5 test classes (Integration):" + for class in "${INTEGRATION_TEST_CLASSES[@]}"; do + echo " - $class" + done + exit 1 +fi + +echo "Running fairness scheduler tests..." +if [[ -n "$FILTER" ]]; then + echo "Filter: $FILTER" +fi +echo "" + +# Clean up previous test results +rm -rf "TestResults/FairnessSchedulerTests.xcresult" + +# Run tests (with code signing disabled for command-line builds) +SIGNING_FLAGS="CODE_SIGN_IDENTITY=- CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO" + +# Use a temp file to capture output so we can both display and analyze it +TEST_OUTPUT=$(mktemp) +trap "rm -f $TEST_OUTPUT" EXIT + +if [[ $VERBOSE -eq 1 ]]; then + xcodebuild test \ + -project iTerm2.xcodeproj \ + -scheme ModernTests \ + $ONLY_TESTING_ARGS \ + -parallel-testing-enabled NO \ + -resultBundlePath "TestResults/FairnessSchedulerTests.xcresult" \ + $SIGNING_FLAGS \ + 2>&1 | tee "$TEST_OUTPUT" | tee test_output.log + XCODE_EXIT=${PIPESTATUS[0]} +else + xcodebuild test \ + -project iTerm2.xcodeproj \ + -scheme ModernTests \ + $ONLY_TESTING_ARGS \ + -parallel-testing-enabled NO \ + -resultBundlePath "TestResults/FairnessSchedulerTests.xcresult" \ + $SIGNING_FLAGS \ + 2>&1 | tee "$TEST_OUTPUT" | grep -E "(Test Case|passed|failed|error:|\*\*)" + XCODE_EXIT=${PIPESTATUS[0]} +fi + +echo "" + +# Check for crash indicators in test output +if grep -q "Program crashed" "$TEST_OUTPUT" 2>/dev/null; then + echo "==========================================" + echo "WARNING: TEST CRASHED! (detected 'Program crashed' in output)" + echo "==========================================" + XCODE_EXIT=1 +fi + +# Check for new crash reports +if ! check_for_crashes; then + XCODE_EXIT=1 +fi + +# Final status +if [[ $XCODE_EXIT -eq 0 ]]; then + echo "Done. All tests passed." +else + echo "Done. Tests FAILED or CRASHED (exit code: $XCODE_EXIT)" +fi + +exit $XCODE_EXIT