diff --git a/ModernTests/FairnessScheduler/FairnessSchedulerTests.swift b/ModernTests/FairnessScheduler/FairnessSchedulerTests.swift new file mode 100644 index 0000000000..68d15fde85 --- /dev/null +++ b/ModernTests/FairnessScheduler/FairnessSchedulerTests.swift @@ -0,0 +1,1581 @@ +// +// FairnessSchedulerTests.swift +// ModernTests +// +// Unit tests for FairnessScheduler - the round-robin fair scheduling coordinator. +// See testing.md Phase 1 for test specifications. +// +// Session restoration tests (revive/undo termination) are implemented in +// FairnessSchedulerSessionRestorationTests at the end of this file. +// + +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) + + // Sync to mutation queue to ensure any (incorrect) execution would have completed + for _ in 0..<3 { + iTermGCD.mutationQueue().sync {} + } + + XCTAssertEqual(mockExecutorA.executeTurnCallCount, 0, + "Unregistered session should not execute") + } + + 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 firstTurn = XCTestExpectation(description: "First turn executed") + + mockExecutorA.executeTurnHandler = { budget, completion in + turnCount += 1 + if turnCount == 1 { + firstTurn.fulfill() + } + completion(.completed) + } + + // Enqueue work multiple times before execution + scheduler.sessionDidEnqueueWork(idA) + scheduler.sessionDidEnqueueWork(idA) + scheduler.sessionDidEnqueueWork(idA) + + // Wait for first turn + wait(for: [firstTurn], timeout: 1.0) + + // Sync to mutation queue to ensure any duplicate execution would have completed + for _ in 0..<3 { + iTermGCD.mutationQueue().sync {} + } + + // 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 _ = scheduler.register(mockExecutorB) + + // Don't call sessionDidEnqueueWork for B + // Sync to mutation queue to ensure any (incorrect) execution would have completed + for _ in 0..<3 { + iTermGCD.mutationQueue().sync {} + } + + 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, FairnessScheduler.defaultTokenBudget, + "Token budget should match FairnessScheduler.defaultTokenBudget") + 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) + // Must call completion on mutation queue per protocol contract + iTermGCD.mutationQueue().async { + 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 + // Must call completion on mutation queue per protocol contract + iTermGCD.mutationQueue().async { + 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() + + // Call completion on mutation queue (required by threading contract) + // Should be safe even though session was unregistered + iTermGCD.mutationQueue().async { + completion(.yielded) + } + } + } + + scheduler.sessionDidEnqueueWork(sessionId) + + wait(for: [executionStarted, unregisterDone], timeout: 2.0) + + // Flush mutation queue to ensure no crash from late completion + waitForMutationQueue() + + // Verify session was unregistered + XCTAssertFalse(scheduler.testIsSessionRegistered(sessionId), + "Session should be unregistered") + } + + func testUnregisterDuringExecuteTurnDoesNotStallOtherSessions() { + // REGRESSION: If session A is unregistered while its executeTurn is running, + // sessionFinishedTurn must still call ensureExecutionScheduled() so that + // session B (waiting in busyQueue) gets its turn. Without this, session B + // stalls indefinitely until some external event triggers scheduling. + + let scheduler = self.scheduler! + + let executorA = MockFairnessSchedulerExecutor() + let executorB = MockFairnessSchedulerExecutor() + let sessionA = scheduler.register(executorA) + let sessionB = scheduler.register(executorB) + + var completionA: ((TurnResult) -> Void)? + let executionAStarted = XCTestExpectation(description: "A started") + let executionBStarted = XCTestExpectation(description: "B executed") + + executorA.executeTurnHandler = { _, completion in + // Hold A's completion so we can unregister before calling it + completionA = completion + executionAStarted.fulfill() + } + + executorB.executeTurnHandler = { _, completion in + executionBStarted.fulfill() + completion(.completed) + } + + // Enqueue work for both sessions + scheduler.sessionDidEnqueueWork(sessionA) + scheduler.sessionDidEnqueueWork(sessionB) + + // Wait for A to start executing + wait(for: [executionAStarted], timeout: 2.0) + + // Unregister A while its turn is in progress, then complete the turn + iTermGCD.mutationQueue().async { + scheduler.unregister(sessionId: sessionA) + // Complete A's turn after unregister — sessionFinishedTurn must still + // pump the scheduler so B gets scheduled + completionA?(.yielded) + } + + // B must get its turn despite A's mid-flight unregister + wait(for: [executionBStarted], timeout: 2.0) + + // Cleanup + scheduler.unregister(sessionId: sessionB) + 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 session was unregistered + XCTAssertFalse(scheduler.testIsSessionRegistered(sessionId), + "Session should be unregistered") + + // 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 {} + + XCTAssertFalse(scheduler.testIsSessionRegistered(sessionId), + "Session should be unregistered") + + // Second unregister of original session - should be no-op (no crash) + scheduler.unregister(sessionId: sessionId) + + // Wait for second unregister to complete + iTermGCD.mutationQueue().sync {} + + // Session should still be unregistered + XCTAssertFalse(scheduler.testIsSessionRegistered(sessionId), + "Session should remain unregistered") + } + + 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 + // 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)") + } + + // 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 + // 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]) + + // 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") + + // Call setupDispatchSources (simulating what didRegister does) + task.testSetupDispatchSourcesForTesting() + + // 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") + + // 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 + + 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 + executor.isRegistered = true + defer { + FairnessScheduler.shared.unregister(sessionId: sessionId) + executor.isRegistered = false + 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") + } + + 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 { + + override func setUp() { + super.setUp() + iTermAdvancedSettingsModel.setUseFairnessSchedulerForTesting(true) + } + + override func tearDown() { + iTermAdvancedSettingsModel.setUseFairnessSchedulerForTesting(false) + super.tearDown() + } + + 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") + + XCTAssertTrue(FairnessScheduler.shared.testIsSessionRegistered(sessionId), + "Session should be registered") + + // 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) + + let registeredBefore = FairnessScheduler.shared.testRegisteredSessionCount + + // Simulate session close - unregister should cleanup + FairnessScheduler.shared.unregister(sessionId: sessionId) + waitForMutationQueue() + + XCTAssertEqual(FairnessScheduler.shared.testRegisteredSessionCount, registeredBefore - 1, + "Registered count should decrease after cleanup") + XCTAssertFalse(FairnessScheduler.shared.testIsSessionRegistered(sessionId), + "Session should not be registered after cleanup") + + // 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 { + + override func setUp() { + super.setUp() + iTermAdvancedSettingsModel.setUseFairnessSchedulerForTesting(true) + } + + override func tearDown() { + iTermAdvancedSettingsModel.setUseFairnessSchedulerForTesting(false) + super.tearDown() + } + + 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]) + + // 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") + + // 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) + + // 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") + } + + 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 + let hadSourceBefore = task.testHasReadSource + XCTAssertFalse(hadSourceBefore, "Should start without sources") + + // Use the production TaskNotifier registration path + TaskNotifier.sharedInstance().register(iTermTaskConformingTask) + + // The didRegister call is dispatched to main queue, wait for it to complete + waitForMainQueue() + + // 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") + } + + 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") + + // 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") + } + + 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() + + // All sessions should be cleaned up + XCTAssertEqual(FairnessScheduler.shared.testBusySessionCount, 0, + "No busy sessions should remain after rapid restart test") + } +} + +// MARK: - Backpressure Integration Tests + +/// Tests for backpressure system integration +final class BackpressureIntegrationTests: XCTestCase { + + override func setUp() { + super.setUp() + iTermAdvancedSettingsModel.setUseFairnessSchedulerForTesting(true) + } + + override func tearDown() { + iTermAdvancedSettingsModel.setUseFairnessSchedulerForTesting(false) + super.tearDown() + } + + 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 (must run on mutation queue) + let drainExpectation = XCTestExpectation(description: "Drain tokens") + drainExpectation.expectedFulfillmentCount = 5 + iTermGCD.mutationQueue().async { + 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) + + // 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) + executorA.testSkipNotifyScheduler = true + executorB.testSkipNotifyScheduler = true + 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. + } +} + +// MARK: - Session Lifecycle Integration Tests + +/// Tests for session lifecycle edge cases +final class SessionLifecycleIntegrationTests: XCTestCase { + + override func setUp() { + super.setUp() + iTermAdvancedSettingsModel.setUseFairnessSchedulerForTesting(true) + } + + override func tearDown() { + iTermAdvancedSettingsModel.setUseFairnessSchedulerForTesting(false) + super.tearDown() + } + + 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 (must run on mutation queue) + let executionComplete = XCTestExpectation(description: "Execution complete") + + iTermGCD.mutationQueue().async { + executor.executeTurn(tokenBudget: 500) { result in + executionComplete.fulfill() + } + // Immediately unregister (mid-turn) + FairnessScheduler.shared.unregister(sessionId: sessionId) + } + + // Wait for completion without crash + wait(for: [executionComplete], timeout: 2.0) + + // After everything settles, verify cleanup + waitForMutationQueue() + + XCTAssertFalse(FairnessScheduler.shared.testIsSessionRegistered(sessionId), + "Session should be unregistered after mid-turn close") + } +} 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/MockCoprocess.swift b/ModernTests/FairnessScheduler/Mocks/MockCoprocess.swift new file mode 100644 index 0000000000..a4a33a7e3c --- /dev/null +++ b/ModernTests/FairnessScheduler/Mocks/MockCoprocess.swift @@ -0,0 +1,82 @@ +// +// MockCoprocess.swift +// ModernTests +// +// Mock implementation of Coprocess for testing TaskNotifier coprocess handling. +// Subclass of Coprocess that uses pipes instead of spawning a subprocess. +// + +import Foundation +@testable import iTerm2SharedARC + +class 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). + private(set) var testWriteFd: Int32 = -1 + + /// 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). + private(set) var testReadFd: Int32 = -1 + + /// Create a MockCoprocess with pipe FDs. + /// Returns nil on failure (pipe creation failed). + @objc static func createPipe() -> MockCoprocess? { + var readPipe: [Int32] = [0, 0] + var writePipe: [Int32] = [0, 0] + + guard pipe(&readPipe) == 0 else { return nil } + guard pipe(&writePipe) == 0 else { + close(readPipe[0]) + close(readPipe[1]) + return nil + } + + // Set non-blocking on inputFd + var 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) + + let coprocess = MockCoprocess() + coprocess.inputFd = readPipe[0] + coprocess.outputFd = writePipe[1] + coprocess.pid = getpid() + coprocess.testWriteFd = readPipe[1] + coprocess.testReadFd = writePipe[0] + + return coprocess + } + + deinit { + closeTestFds() + } + + // Override to NOT send kill signal - MockCoprocess uses getpid() as pid + // and we don't want to kill the test process! + override func terminate() { + if outputFd >= 0 { + close(outputFd) + outputFd = -1 + } + if inputFd >= 0 { + close(inputFd) + inputFd = -1 + } + pid = -1 + } + + /// Close the test FDs (call in addition to terminate to clean up test resources). + @objc func closeTestFds() { + if testWriteFd >= 0 { + close(testWriteFd) + testWriteFd = -1 + } + if testReadFd >= 0 { + close(testReadFd) + testReadFd = -1 + } + } +} diff --git a/ModernTests/FairnessScheduler/Mocks/MockFairnessSchedulerExecutor.swift b/ModernTests/FairnessScheduler/Mocks/MockFairnessSchedulerExecutor.swift new file mode 100644 index 0000000000..95e938800e --- /dev/null +++ b/ModernTests/FairnessScheduler/Mocks/MockFairnessSchedulerExecutor.swift @@ -0,0 +1,77 @@ +// +// 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 + + // 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) + } + } + + // MARK: - Test Helpers + + func reset() { + turnResult = .completed + executeTurnHandler = nil + executionDelay = 0 + 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..537073e55e --- /dev/null +++ b/ModernTests/FairnessScheduler/Mocks/MockPTYTaskDelegate.swift @@ -0,0 +1,158 @@ +// +// 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)? + + /// Callback invoked when taskDidRegister is called. + /// Use this to wire up tokenExecutor or other dependencies during registration. + var onTaskDidRegister: ((PTYTask) -> Void)? + + // MARK: - Call Tracking + + private let lock = NSLock() + private var _readCallCount = 0 + private var _lastReadData: Data? + private var _readDataChunks: [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 + } + + /// All data chunks received via threadedReadTask, in order. + var readDataChunks: [Data] { + lock.lock() + defer { lock.unlock() } + return _readDataChunks + } + + var brokenPipeCount: Int { + lock.lock() + defer { lock.unlock() } + return _brokenPipeCount + } + + var threadedBrokenPipeCount: Int { + lock.lock() + defer { lock.unlock() } + return _threadedBrokenPipeCount + } + + // MARK: - Convenience (backward compat with regression tests) + + var threadedBrokenPipeCalled: Bool { + return threadedBrokenPipeCount > 0 + } + + var readData: [Data] { + return readDataChunks + } + + // MARK: - PTYTaskDelegate + + func threadedReadTask(_ buffer: UnsafeMutablePointer, length: Int32) { + lock.lock() + _readCallCount += 1 + let data = Data(bytes: buffer, count: Int(length)) + _lastReadData = data + _readDataChunks.append(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) { + onTaskDidRegister?(task) + } + + 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 + _readDataChunks = [] + _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/MockTaskNotifierTask.swift b/ModernTests/FairnessScheduler/Mocks/MockTaskNotifierTask.swift new file mode 100644 index 0000000000..da0d0ed51d --- /dev/null +++ b/ModernTests/FairnessScheduler/Mocks/MockTaskNotifierTask.swift @@ -0,0 +1,144 @@ +// +// MockTaskNotifierTask.swift +// ModernTests +// +// Test-only mock implementation of iTermTask protocol for testing TaskNotifier behavior. +// Configurable to test both dispatch source and legacy select() paths. +// + +import Foundation +@testable import iTerm2SharedARC + +@objc class MockTaskNotifierTask: NSObject, iTermTask { + + // MARK: - iTermTask Required Properties + + @objc var fd: Int32 = -1 + @objc var pid: pid_t = 0 + @objc var pidToWaitOn: pid_t = 0 + @objc var hasCoprocess: Bool = false + @objc var coprocess: Coprocess? + @objc var wantsRead: Bool = true + @objc var wantsWrite: Bool = false + @objc var writeBufferHasRoom: Bool = true + @objc var hasBrokenPipe: Bool = false + @objc var sshIntegrationActive: Bool = false + + // MARK: - Configuration for Testing + + /// Set to true to make useDispatchSource return YES. + /// Default is NO (use select() path). + var dispatchSourceEnabled: Bool = false + + /// If true, this mock does NOT respond to useDispatchSource selector, + /// simulating a legacy task that relies on select(). + var simulateLegacyTask: Bool = false + + // MARK: - Call Tracking + + private(set) var processReadCallCount: Int = 0 + private(set) var processWriteCallCount: Int = 0 + private(set) var brokenPipeCallCount: Int = 0 + private(set) var didRegisterCallCount: Int = 0 + private(set) var writeTaskCoprocessCallCount: Int = 0 + private(set) var lastCoprocessData: Data? + + // MARK: - iTermTask Required Methods + + @objc func processRead() { + processReadCallCount += 1 + } + + @objc func processWrite() { + processWriteCallCount += 1 + } + + @objc func brokenPipe() { + brokenPipeCallCount += 1 + } + + @objc func write(_ data: Data!, coprocess isCoprocess: Bool) { + if isCoprocess { + writeTaskCoprocessCallCount += 1 + lastCoprocessData = data + } + } + + @objc func didRegister() { + didRegisterCallCount += 1 + } + + // MARK: - iTermTask Optional Methods + + @objc func useDispatchSource() -> Bool { + return dispatchSourceEnabled + } + + // MARK: - Override respondsToSelector for Legacy Simulation + + override func responds(to aSelector: Selector!) -> Bool { + if simulateLegacyTask && aSelector == #selector(useDispatchSource) { + return false + } + return super.responds(to: aSelector) + } + + // MARK: - Test Helpers + + func reset() { + processReadCallCount = 0 + processWriteCallCount = 0 + brokenPipeCallCount = 0 + didRegisterCallCount = 0 + writeTaskCoprocessCallCount = 0 + lastCoprocessData = nil + dispatchSourceEnabled = false + simulateLegacyTask = false + wantsRead = true + wantsWrite = false + hasCoprocess = false + hasBrokenPipe = false + } + + func wait(forProcessReadCalls count: Int, timeout: TimeInterval) -> Bool { + let deadline = Date(timeIntervalSinceNow: timeout) + while processReadCallCount < count && Date() < deadline { + Thread.sleep(forTimeInterval: 0.01) + } + return processReadCallCount >= count + } + + func wait(forCoprocessWriteCalls count: Int, timeout: TimeInterval) -> Bool { + let deadline = Date(timeIntervalSinceNow: timeout) + while writeTaskCoprocessCallCount < count && Date() < deadline { + Thread.sleep(forTimeInterval: 0.01) + } + return writeTaskCoprocessCallCount >= count + } + + func closeFd() { + if fd >= 0 { + close(fd) + fd = -1 + } + } + + // MARK: - Factory Methods + + /// 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 fds: [Int32] = [0, 0] + guard pipe(&fds) == 0 else { return nil } + + // Set non-blocking on read end + let flags = fcntl(fds[0], F_GETFL) + fcntl(fds[0], F_SETFL, flags | O_NONBLOCK) + + let task = MockTaskNotifierTask() + task.fd = fds[0] // Read end + + return (task, fds[1]) // Write end + } +} 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..29fc94a7c3 --- /dev/null +++ b/ModernTests/FairnessScheduler/Mocks/SpyVT100Screen.swift @@ -0,0 +1,42 @@ +// +// SpyVT100Screen.swift +// ModernTests +// +// Spy VT100Screen that intercepts performBlock(joinedThreads:) calls for testing PTYSession wiring. +// Used to verify that PTYSession methods like taskDidChangePaused and shortcutNavigationDidComplete +// correctly dispatch through performBlock(joinedThreads:). +// + +import Foundation +@testable import iTerm2SharedARC + +/// Type alias for the joined-threads block signature +typealias JoinedThreadsBlock = (VT100Terminal?, VT100ScreenMutableState, (any VT100ScreenDelegate)?) -> Void + +/// Spy VT100Screen that intercepts performBlock(joinedThreads:) calls. +/// Since performBlock(joinedThreads:) uses NS_NOESCAPE, the block executes synchronously. +/// The spy executes the block immediately with an injected spy mutableState, allowing +/// tests to verify state changes after the call returns. +@objc final class SpyVT100Screen: VT100Screen { + + /// The spy mutable state to pass to intercepted blocks. + /// Set this before calling the method under test. + @objc var spyMutableState: VT100ScreenMutableState? + + /// Number of times performBlock(joinedThreads:) was called + private(set) var performBlockWithJoinedThreadsCallCount = 0 + + /// Override performBlock(joinedThreads:) to execute the block with the spy mutableState + /// instead of going through the real mutation queue machinery. + @objc override func performBlock(joinedThreads block: (VT100Terminal?, VT100ScreenMutableState, (any VT100ScreenDelegate)?) -> Void) { + performBlockWithJoinedThreadsCallCount += 1 + if let state = spyMutableState { + block(nil, state, nil) + } + } + + /// Reset the spy state + func reset() { + performBlockWithJoinedThreadsCallCount = 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..20840c65eb --- /dev/null +++ b/ModernTests/FairnessScheduler/PTYSessionWiringTests.swift @@ -0,0 +1,276 @@ +// +// PTYSessionWiringTests.swift +// ModernTests +// +// Tests that verify PTYSession correctly wires taskDidChangePaused and +// shortcutNavigationDidComplete to call performBlock(joinedThreads:) and set +// the appropriate state on VT100ScreenMutableState. +// +// In v3, these methods use performBlock(joinedThreads:) (synchronous, NS_NOESCAPE) +// rather than mutateAsynchronously. The block sets the relevant property on +// mutableState but does not call scheduleTokenExecution. +// + +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() + iTermAdvancedSettingsModel.setUseFairnessSchedulerForTesting(true) + performer = MockSideEffectPerformer() + spyScreen = SpyVT100Screen() + spyMutableState = SpyVT100ScreenMutableState(sideEffectPerformer: performer) + + // Inject the spy mutableState into the spy screen so performBlock(joinedThreads:) + // executes blocks against it. + spyScreen.spyMutableState = spyMutableState + + // 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 + iTermAdvancedSettingsModel.setUseFairnessSchedulerForTesting(false) + super.tearDown() + } + + // MARK: - taskDidChangePaused Tests + + func testTaskDidChangePausedCallsPerformBlockWithJoinedThreads() { + // REQUIREMENT: taskDidChangePaused should call performBlock(joinedThreads:) on screen + spyScreen.reset() + + // Call the method under test + session.taskDidChangePaused(PTYTask(), paused: false) + + // Verify performBlockWithJoinedThreads was called + XCTAssertEqual(spyScreen.performBlockWithJoinedThreadsCallCount, 1, + "taskDidChangePaused should call performBlock(joinedThreads:) exactly once") + } + + func testTaskDidChangePausedWithPausedTrueSetsTaskPaused() { + // REQUIREMENT: When paused=true, block sets mutableState.taskPaused = true + spyScreen.reset() + spyMutableState.taskPaused = false + + // Call the method under test -- block executes synchronously + session.taskDidChangePaused(PTYTask(), paused: true) + + // Verify taskPaused was set to true + XCTAssertTrue(spyMutableState.taskPaused, + "Block should set taskPaused = true when paused parameter is true") + } + + func testTaskDidChangePausedWithPausedFalseSetsTaskPaused() { + // REQUIREMENT: When paused=false, block sets mutableState.taskPaused = false + // NOTE: In v3, scheduleTokenExecution is NOT called by taskDidChangePaused. + spyScreen.reset() + spyMutableState.taskPaused = true // Start in paused state + + // Call the method under test -- block executes synchronously + session.taskDidChangePaused(PTYTask(), paused: false) + + // Verify taskPaused was set to false + XCTAssertFalse(spyMutableState.taskPaused, + "Block should set taskPaused = false when paused parameter is false") + } + + // MARK: - shortcutNavigationDidComplete Tests + + func testShortcutNavigationDidCompleteCallsPerformBlockWithJoinedThreads() { + // REQUIREMENT: shortcutNavigationDidComplete should call performBlock(joinedThreads:) 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 performBlockWithJoinedThreads was called + XCTAssertEqual(spyScreen.performBlockWithJoinedThreadsCallCount, 1, + "shortcutNavigationDidComplete should call performBlock(joinedThreads:) exactly once") + } + + func testShortcutNavigationDidCompleteSetsShortcutNavigationMode() { + // REQUIREMENT: shortcutNavigationDidComplete block sets shortcutNavigationMode = false + // NOTE: In v3, scheduleTokenExecution is NOT called by shortcutNavigationDidComplete. + spyScreen.reset() + spyMutableState.shortcutNavigationMode = true // Start in shortcut nav mode + + // Call the method under test using performSelector + session.perform(NSSelectorFromString("shortcutNavigationDidComplete")) + + // Verify shortcutNavigationMode was set to false + XCTAssertFalse(spyMutableState.shortcutNavigationMode, + "Block should set shortcutNavigationMode = false") + } + + // 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() + // Enable fairness scheduler flag BEFORE creating components + // (taskDidRegister is gated by useFairnessScheduler) + iTermAdvancedSettingsModel.setUseFairnessSchedulerForTesting(true) + + // 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 + iTermAdvancedSettingsModel.setUseFairnessSchedulerForTesting(false) + 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..929fc541ba --- /dev/null +++ b/ModernTests/FairnessScheduler/PTYTaskDispatchSourceBasicTests.swift @@ -0,0 +1,405 @@ +// +// 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 + 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) + + // 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() + } + + func testTeardownCleansUpSources() throws { + // REQUIREMENT: teardownDispatchSources removes sources and cleans up state + 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) + + // 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") + } + + 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 + } + + // 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") + + // This should not crash - sources were never created + let selector = NSSelectorFromString("teardownDispatchSources") + if task.responds(to: selector) { + task.perform(selector) + } + + // 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") + + 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) + + // 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)") + } + + 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. + + 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.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") + } + + 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. + + 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.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") + } + + 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. + + 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.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") + } +} + +final class PTYTaskUseDispatchSourceTests: XCTestCase { + + override func setUp() { + super.setUp() + iTermAdvancedSettingsModel.setUseFairnessSchedulerForTesting(true) + } + + override func tearDown() { + iTermAdvancedSettingsModel.setUseFairnessSchedulerForTesting(false) + super.tearDown() + } + + 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 + } + + // Record initial state + let initialHasReadSource = task.testHasReadSource + let initialHasWriteSource = task.testHasWriteSource + + // Call update methods many times - should be idempotent + for _ in 0..<20 { + task.perform(readSelector) + task.perform(writeSelector) + } + + // 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") + + 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..b107e6b20f --- /dev/null +++ b/ModernTests/FairnessScheduler/PTYTaskDispatchSourceHandlerTests.swift @@ -0,0 +1,782 @@ +// +// 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 + + // 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() + } + + 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 + + // 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() + } + + 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) + + // 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() + } + + 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 { + + override func setUp() { + super.setUp() + iTermAdvancedSettingsModel.setUseFairnessSchedulerForTesting(true) + } + + override func tearDown() { + iTermAdvancedSettingsModel.setUseFairnessSchedulerForTesting(false) + super.tearDown() + } + + 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 + + // 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() + } + + 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 + + // 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() + } + + 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 + + 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() + } + + 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 + + // 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() + } +} + +// 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). + + 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 + + // 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:") + } + } + + func testWriteTaskWithoutIoAllowedDoesNotWrite() throws { + // Verify that shouldWrite returns false when ioAllowed is false, + // and data stays in buffer (not written to fd) + + 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 + + // 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") + } + + func testMultipleWriteTaskCallsAccumulate() throws { + // Verify multiple writeTask: calls accumulate in buffer and all get written + + 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 + + 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. + + 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 + + // NO OVERRIDES - use real job manager behavior + // Verify preconditions + let jobManager = task.value(forKey: "jobManager") + XCTAssertNotNil(jobManager, "Job manager should exist after testSetFd") + + // Check shouldWrite transitions WITHOUT dispatch sources (avoids race with write source draining buffer) + if let shouldWriteBefore = task.value(forKey: "shouldWrite") as? Bool { + XCTAssertFalse(shouldWriteBefore, "shouldWrite should be false with empty buffer") + } + + // Add data and verify shouldWrite becomes true (no dispatch sources yet, so buffer won't drain) + let testMessage = "Real job manager test" + task.write(testMessage.data(using: .utf8)!) + + if let shouldWriteAfter = task.value(forKey: "shouldWrite") as? Bool { + XCTAssertTrue(shouldWriteAfter, + "shouldWrite should be true after adding data (ioAllowed=\(task.value(forKey: "effectiveIoAllowed") ?? "nil"))") + } + + // Now set up dispatch sources — the write source will drain the buffer + task.testSetupDispatchSourcesForTesting() + defer { task.testTeardownDispatchSourcesForTesting() } + + // 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") + } + } +} diff --git a/ModernTests/FairnessScheduler/PTYTaskDispatchSourceIntegrationTests.swift b/ModernTests/FairnessScheduler/PTYTaskDispatchSourceIntegrationTests.swift new file mode 100644 index 0000000000..3ee9deb3d4 --- /dev/null +++ b/ModernTests/FairnessScheduler/PTYTaskDispatchSourceIntegrationTests.swift @@ -0,0 +1,585 @@ +// +// PTYTaskDispatchSourceIntegrationTests.swift +// ModernTests +// +// Integration and edge case tests for PTYTask dispatch sources. +// + +import XCTest +@testable import iTerm2SharedARC + +final class PTYTaskBackpressureIntegrationTests: XCTestCase { + + override func setUp() { + super.setUp() + // Enable fairness scheduler so addTokens uses non-blocking path + // (legacy path blocks on semaphore, which deadlocks tests that add >40 tokens) + iTermAdvancedSettingsModel.setUseFairnessSchedulerForTesting(true) + } + + override func tearDown() { + iTermAdvancedSettingsModel.setUseFairnessSchedulerForTesting(false) + super.tearDown() + } + + 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) + executor.testSkipNotifyScheduler = true + task.tokenExecutor = executor + + XCTAssertEqual(executor.backpressureLevel, .none, + "Fresh executor should have no backpressure") + + // 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() + } + + 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) + executor.testSkipNotifyScheduler = true + task.tokenExecutor = executor + + XCTAssertEqual(executor.backpressureLevel, .none, + "Fresh executor should have no backpressure") + + // 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() + } + + 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 mockDelegate = MockTokenExecutorDelegate() + let executor = TokenExecutor(terminal, slownessDetector: SlownessDetector(), queue: iTermGCD.mutationQueue()) + executor.delegate = mockDelegate + executor.testSkipNotifyScheduler = true + task.tokenExecutor = executor + + // 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 (must run on mutation queue) + let drainExpectation = XCTestExpectation(description: "Tokens drained") + iTermGCD.mutationQueue().async { + 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() + } + + func testDidRegisterWiresBackpressureBeforeStartingSources() { + // REQUIREMENT: didRegister must call taskDidRegister: (which wires tokenExecutor) + // BEFORE setupDispatchSources. If sources start first, shouldRead sees executor==nil + // and skips backpressure checks, allowing unconditional reads. + // + // This test wires heavy backpressure in the taskDidRegister: callback. + // If ordering is correct: sources see the executor with heavy backpressure → suspended. + // If ordering is wrong: sources see executor==nil → no backpressure → resumed. + + 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 + + let mockDelegate = MockPTYTaskDelegate() + + // In taskDidRegister:, wire up a tokenExecutor with heavy backpressure. + // This simulates what PTYSession.taskDidRegister: does in production. + let terminal = VT100Terminal() + let executor = TokenExecutor(terminal, slownessDetector: SlownessDetector(), queue: DispatchQueue.main) + executor.testSkipNotifyScheduler = true + + mockDelegate.onTaskDidRegister = { registeredTask in + registeredTask.tokenExecutor = executor + // Create .heavy backpressure (35 of 40 slots consumed → ratio 0.125 → .heavy) + executor.addMultipleTokenArrays(count: 35, tokensPerArray: 5) + } + + task.delegate = mockDelegate + + // Call didRegister — this triggers taskDidRegister: then setupDispatchSources + task.perform(NSSelectorFromString("didRegister")) + task.testWaitForIOQueue() + + // Verify executor was wired + XCTAssertNotNil(task.tokenExecutor, "tokenExecutor should be wired by taskDidRegister:") + XCTAssertEqual(executor.backpressureLevel, .heavy, + "Backpressure should be heavy after registration callback") + + // The critical assertion: read source should be SUSPENDED because backpressure + // was already heavy when setupDispatchSources evaluated shouldRead. + // If didRegister had the wrong order (sources before wiring), the read source + // would be RESUMED because shouldRead with nil executor skips backpressure. + XCTAssertTrue(task.testIsReadSourceSuspended, + "Read source should start suspended when backpressure is heavy at registration time") + + task.testTeardownDispatchSourcesForTesting() + } + + func testDidRegisterWithPreloadedDataDoesNotLeakReads() { + // REQUIREMENT: When data is already readable on the fd at registration time + // and backpressure is heavy, no threadedReadTask callback should fire. + // + // setupDispatchSources does dispatch_resume then dispatch_suspend on the read + // source (required to transition from "created" to "suspended" state in GCD). + // If a read event slips through that window, it would bypass backpressure. + // This test preloads data on the pipe before calling didRegister, then verifies + // zero delegate callbacks under heavy backpressure. + + 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 + + let mockDelegate = MockPTYTaskDelegate() + + // Wire heavy backpressure in the taskDidRegister callback (same as production) + let terminal = VT100Terminal() + let executor = TokenExecutor(terminal, slownessDetector: SlownessDetector(), queue: DispatchQueue.main) + executor.testSkipNotifyScheduler = true + + mockDelegate.onTaskDidRegister = { registeredTask in + registeredTask.tokenExecutor = executor + executor.addMultipleTokenArrays(count: 35, tokensPerArray: 5) + } + + task.delegate = mockDelegate + + // Preload data on the pipe BEFORE registration so the fd is already readable + let preloadData = "preloaded data that should not leak through".data(using: .utf8)! + preloadData.withUnsafeBytes { bufferPointer in + _ = Darwin.write(pipe.writeFd, bufferPointer.baseAddress!, preloadData.count) + } + + // Call didRegister — wires backpressure, then sets up dispatch sources + task.perform(NSSelectorFromString("didRegister")) + + // Wait for IO queue to drain any pending events from the resume/suspend window + task.testWaitForIOQueue() + + // Wait again — if a read event was queued during the brief resume, it would + // have dispatched back to IO queue by now + task.testWaitForIOQueue() + + // The critical assertion: no data should have been delivered to the delegate. + // The read source should be suspended due to heavy backpressure, and the brief + // resume/suspend window in setupDispatchSources must not leak events. + XCTAssertEqual(mockDelegate.readCallCount, 0, + "No threadedReadTask should fire when data is preloaded but backpressure is heavy at registration") + + XCTAssertTrue(task.testIsReadSourceSuspended, + "Read source should remain suspended under heavy backpressure") + + task.testTeardownDispatchSourcesForTesting() + } + + 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) + executor.testSkipNotifyScheduler = true + task.tokenExecutor = executor + + // 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() + } +} + +// 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) + } + + // 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") + + 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..2d82629811 --- /dev/null +++ b/ModernTests/FairnessScheduler/PTYTaskDispatchSourcePredicateTests.swift @@ -0,0 +1,863 @@ +// +// 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 + } + + // Before update, no sources exist + XCTAssertFalse(task.testHasReadSource, "No read source before update") + + // 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) + } + + // State should remain unchanged - no source created + XCTAssertFalse(task.testHasReadSource, + "updateReadSourceState should not create source") + + 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 + } + + // 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 + } + + 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 + } + + // Before update, no sources exist + XCTAssertFalse(task.testHasWriteSource, "No write source before update") + + 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) + } + + // State should remain unchanged - no source created + XCTAssertFalse(task.testHasWriteSource, + "updateWriteSourceState should not create source") + + 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) + + // 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() + } + + 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) + + // 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() + } +} + +// 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 + } + + // 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) + } + + 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 + } + + // 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") + } + } + + func testShouldReadTrueWhenIoAllowedTrueAndOtherConditionsMet() { + // REQUIREMENT: shouldRead returns true when ioAllowed=true, paused=false, backpressure (task: MockTaskNotifierTask, writeFd: Int32)? { + return MockTaskNotifierTask.createPipeTask() +} + +// MARK: - 4.1 Dispatch Source Protocol Tests + +/// Tests for the useDispatchSource optional protocol method (4.1) +final class TaskNotifierDispatchSourceProtocolTests: XCTestCase { + + override func setUp() { + super.setUp() + iTermAdvancedSettingsModel.setUseFairnessSchedulerForTesting(true) + } + + override func tearDown() { + iTermAdvancedSettingsModel.setUseFairnessSchedulerForTesting(false) + super.tearDown() + } + + 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 + // 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") + } + + func testDefaultBehaviorIsSelectLoop() throws { + // REQUIREMENT: Tasks not implementing useDispatchSource use select() path + // This is the default/legacy behavior for backward compatibility + + // 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") + } +} + +// 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() + + // 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") + } + + func testLegacyTaskProcessReadCalledBySelect() throws { + // REQUIREMENT: Tasks NOT using dispatch source SHOULD have processRead called + + // 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") + } + + // 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. + + 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") + } + + func testLegacyTaskProcessWriteCalledBySelect() throws { + // GAP 1 (inverse): Verify legacy tasks DO have processWrite called. + + 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()") + } + + 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) + + 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") + } + + func testUnblockPipeStillInSelect() throws { + // REQUIREMENT: Unblock pipe remains in select() set + // The unblock pipe is used to wake select() on registration changes + + // 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) + } + + 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 + + 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") + } + + func testDeadpoolHandlingUnchanged() throws { + // REQUIREMENT: Deadpool/waitpid handling continues working + // Process reaping is independent of I/O mechanism (uses WNOHANG) + + 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") + } +} + +// 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 + + // 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()") + } + + 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 + + // 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) + } + + func testLegacyTasksUnaffected() throws { + // REQUIREMENT: Tasks not implementing useDispatchSource work unchanged + // Backward compatibility - existing conformers need no changes + + // 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()") + } + + 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 + + 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") + } +} + +// 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 { + + override func setUp() { + super.setUp() + iTermAdvancedSettingsModel.setUseFairnessSchedulerForTesting(true) + } + + override func tearDown() { + iTermAdvancedSettingsModel.setUseFairnessSchedulerForTesting(false) + super.tearDown() + } + + func testHandleReadEventRoutesToCoprocess() throws { + // REQUIREMENT: handleReadEvent should call writeToCoprocess when coprocess is attached + // This tests that PTY output flows to the coprocess via the bridge. + // + // Full data flow: + // 1. Data written to ptyPipe.writeFd + // 2. Read dispatch source fires on ptyPipe.readFd → handleReadEvent + // 3. handleReadEvent calls writeToCoprocess: → appends to coprocess.outputBuffer + // 4. Coprocess write dispatch source drains outputBuffer → coprocess.outputFd + // 5. Data appears on coprocess.testReadFd (other end of the pipe) + + 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) + + // Set up dispatch sources FIRST — this creates _ioQueue, which is required + // before attaching a coprocess (setCoprocess: calls setupCoprocessDispatchSources: + // which asserts _ioQueue != nil). + task.testSetupDispatchSourcesForTesting() + defer { task.testTeardownDispatchSourcesForTesting() } + + // Attach coprocess to task (must be after dispatch source setup) + task.coprocess = coprocess + XCTAssertTrue(task.hasCoprocess, "Task should have coprocess attached") + + // 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) + } + + // Read from coprocess.testReadFd to verify data flowed through the bridge. + // We cannot check coprocess.outputBuffer because the coprocess write dispatch + // source drains it asynchronously (race condition). Instead, read from the + // pipe end where drained data arrives. + var receivedData = Data() + var buffer = [UInt8](repeating: 0, count: 1024) + + let flags = fcntl(coprocess.testReadFd, F_GETFL) + fcntl(coprocess.testReadFd, F_SETFL, flags | O_NONBLOCK) + + for _ in 0..<50 { + task.testWaitForIOQueue() + 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) + } + + // Verify data was routed through the bridge to the coprocess + XCTAssertEqual(receivedData.count, testData.count, + "handleReadEvent should route PTY data through writeToCoprocess bridge to coprocess fd") + + if let receivedString = String(data: receivedData, encoding: .utf8) { + XCTAssertEqual(receivedString, testMessage, + "Coprocess should receive the PTY data") + } + } + + func testWriteTaskTriggersWriteSource() throws { + // REQUIREMENT: writeTask should call writeBufferDidChange + // This tests that the write dispatch_source is triggered when data is added + + 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") + } + } + + 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. + + 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") + } + } + + // 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. + // + // NOTE: This test uses a legacy (non-dispatch-source) task because only legacy + // tasks are added to TaskNotifier's _tasks array and iterated in the select loop. + // Dispatch-source tasks handle their own coprocess I/O via PTYTask's coprocess + // dispatch sources (setupCoprocessDispatchSources:). + + // 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 legacy task so TaskNotifier adds it to _tasks and iterates + // it in the select loop (dispatch-source tasks are NOT added to _tasks). + mockTask.simulateLegacyTask = 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") + } + } +} diff --git a/ModernTests/FairnessScheduler/TestUtilities.swift b/ModernTests/FairnessScheduler/TestUtilities.swift new file mode 100644 index 0000000000..10bd9fa663 --- /dev/null +++ b/ModernTests/FairnessScheduler/TestUtilities.swift @@ -0,0 +1,209 @@ +// +// 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 {} + } +} + +/// 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) +} + +// 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 + +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 + } +} + +// 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.. Void) { + iTermGCD.mutationQueue().async { + self.executeTurn(tokenBudget: tokenBudget, completion: completion) + } + } +} + +// MARK: - 2.1 Non-Blocking Token Addition Tests + +/// Tests for non-blocking token addition behavior (2.1) +/// These tests verify the REMOVAL of semaphore blocking from addTokens(). +/// With the fairness scheduler, PTYTask uses dispatch source suspend/resume +/// for backpressure instead of blocking on the semaphore. +final class TokenExecutorNonBlockingTests: XCTestCase { + + var mockDelegate: MockTokenExecutorDelegate! + var mockTerminal: VT100Terminal! + var executor: TokenExecutor! + + override func setUp() { + super.setUp() + // Enable fairness scheduler BEFORE creating executor (flag is cached at init) + iTermAdvancedSettingsModel.setUseFairnessSchedulerForTesting(true) + + mockDelegate = MockTokenExecutorDelegate() + mockTerminal = VT100Terminal() + executor = TokenExecutor( + mockTerminal, + slownessDetector: SlownessDetector(), + queue: iTermGCD.mutationQueue() + ) + executor.delegate = mockDelegate + executor.testSkipNotifyScheduler = true + } + + override func tearDown() { + executor = nil + mockTerminal = nil + mockDelegate = nil + iTermAdvancedSettingsModel.setUseFairnessSchedulerForTesting(false) + super.tearDown() + } + + func testAddTokensDoesNotBlock() throws { + // REQUIREMENT: addTokens() must return immediately without blocking. + // This enables the dispatch_source model where PTY read handlers never block. + // + // Critical: We must BLOCK consumption to prove non-blocking behavior. + // Without this, tokens could drain fast enough that a semaphore-based + // implementation would never actually block (always having permits). + + // Block token consumption so tokens accumulate + mockDelegate.shouldQueueTokens = true + + // Verify adding tokens beyond buffer capacity doesn't block + // and returns immediately with backpressure reflected in backpressureLevel + let expectation = XCTestExpectation(description: "addTokens returns immediately") + + // Capture executor locally to prevent race with tearDown deallocation + let executor = self.executor! + + DispatchQueue.global().async { + // Add many token arrays rapidly (100 > 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 testHighPriorityTokensBypassBackpressure() throws { + // High-priority tokens (API injection, Inject trigger) deliberately bypass + // backpressure tracking. They don't decrement availableSlots because they + // need to execute synchronously on the mutation queue without flow control. + + 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 from mutation queue (required for high-priority path) + let exp = XCTestExpectation(description: "add high-priority tokens") + iTermGCD.mutationQueue().async { [executor] in + for _ in 0..<15 { + let vector = createTestTokenVector(count: 1) + executor!.addTokens(vector, lengthTotal: 10, lengthExcludingInBandSignaling: 10, highPriority: true) + } + exp.fulfill() + } + wait(for: [exp], timeout: 1.0) + + // High-priority tokens don't affect backpressure level + let afterLevel = executor.backpressureLevel + XCTAssertEqual(afterLevel, .none, + "High-priority tokens should not affect backpressure (they bypass flow control)") + } + + // NEGATIVE TEST: Verify addTokens never blocks even under extreme concurrent load + func testSemaphoreNotCreated() throws { + // With the fairness scheduler, the semaphore still exists but is never waited on. + // Backpressure is handled by dispatch source suspend/resume in PTYTask. + // + // Critical: We must BLOCK consumption to prove addTokens doesn't block. + // Without this, tokens could drain fast enough that blocking behavior + // would never be observed. + + // 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() + // Enable fairness scheduler BEFORE creating executor (flag is cached at init) + iTermAdvancedSettingsModel.setUseFairnessSchedulerForTesting(true) + + mockDelegate = MockTokenExecutorDelegate() + mockTerminal = VT100Terminal() + executor = TokenExecutor( + mockTerminal, + slownessDetector: SlownessDetector(), + queue: iTermGCD.mutationQueue() + ) + executor.delegate = mockDelegate + // Skip notifyScheduler so we can test in isolation without registering + executor.testSkipNotifyScheduler = true + } + + override func tearDown() { + executor = nil + mockTerminal = nil + mockDelegate = nil + iTermAdvancedSettingsModel.setUseFairnessSchedulerForTesting(false) + 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 + let totalSlots = executor.testTotalSlots + let targetTokenArrays = Int(Double(totalSlots) * 0.80) // 80% to ensure heavy + + for _ in 0..(0) + executor.backpressureReleaseHandler = { + _ = handlerCallCount.mutate { $0 + 1 } + } + + // Register with scheduler so executeTurn works properly + let sessionId = FairnessScheduler.shared.register(executor) + executor.fairnessSessionId = sessionId + executor.isRegistered = true + defer { + FairnessScheduler.shared.unregister(sessionId: sessionId) + waitForMutationQueue() + } + + // Add 50 tokens (> 40 buffer depth) to reach .blocked backpressure. + // With non-blocking addTokens, this doesn't block. + for _ in 0..<50 { + let vector = createTestTokenVector(count: 1) + executor.addTokens(vector, lengthTotal: 10, lengthExcludingInBandSignaling: 10) + } + + waitForMutationQueue() + + XCTAssertGreaterThanOrEqual(executor.backpressureLevel, .heavy, + "Should be at heavy+ backpressure after adding 50 tokens") + + // Reset counter + _ = handlerCallCount.mutate { _ in 0 } + + // Process just ONE turn — should consume a few tokens but remain at heavy + let exp = XCTestExpectation(description: "turn") + executor.executeTurnOnMutationQueue(tokenBudget: 1) { _ in + exp.fulfill() + } + wait(for: [exp], timeout: 1.0) + + // Should still be at heavy or blocked (consumed very few of 50) + if executor.backpressureLevel >= .heavy { + XCTAssertEqual(handlerCallCount.value, 0, + "Handler should not fire while still at heavy backpressure") + } + // If somehow we dropped below heavy with budget=1, that's also fine — just + // means the handler correctly fired. The test validates the negative case. + } +} + +// MARK: - 2.3 ExecuteTurn Implementation Tests + +/// Tests for executeTurn method behavior (2.3) +/// This is the core fairness method that limits token processing per turn. +final class TokenExecutorExecuteTurnTests: XCTestCase { + + var mockDelegate: MockTokenExecutorDelegate! + var mockTerminal: VT100Terminal! + var executor: TokenExecutor! + + override func setUp() { + super.setUp() + // Enable fairness scheduler BEFORE creating executor (flag is cached at init) + iTermAdvancedSettingsModel.setUseFairnessSchedulerForTesting(true) + + mockDelegate = MockTokenExecutorDelegate() + mockTerminal = VT100Terminal() + executor = TokenExecutor( + mockTerminal, + slownessDetector: SlownessDetector(), + queue: iTermGCD.mutationQueue() + ) + executor.delegate = mockDelegate + // Skip notifyScheduler so we can test in isolation without registering + executor.testSkipNotifyScheduler = true + } + + override func tearDown() { + executor = nil + mockTerminal = nil + mockDelegate = nil + iTermAdvancedSettingsModel.setUseFairnessSchedulerForTesting(false) + super.tearDown() + } + + func testExecuteTurnMethodExists() throws { + // REQUIREMENT: TokenExecutor must conform to FairnessSchedulerExecutor protocol. + + XCTAssertTrue(executor is FairnessSchedulerExecutor, "TokenExecutor should conform to FairnessSchedulerExecutor") + } + + func testExecuteTurnReturnsBlockedWhenPaused() throws { + // REQUIREMENT: When tokenExecutorShouldQueueTokens() returns true, + // executeTurn must return .blocked immediately without processing. + + mockDelegate.shouldQueueTokens = true + + // Add some tokens + let vector = createTestTokenVector(count: 5) + executor.addTokens(vector, lengthTotal: 50, lengthExcludingInBandSignaling: 50) + + let expectation = XCTestExpectation(description: "ExecuteTurn completed") + executor.executeTurnOnMutationQueue(tokenBudget: 500) { result in + XCTAssertEqual(result, .blocked, "Should return blocked when delegate says to queue tokens") + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + } + + // NEGATIVE TEST: When blocked, NO tokens should be processed + func testBlockedDoesNotProcessTokens() throws { + // REQUIREMENT: .blocked must mean zero token execution, not partial. + + mockDelegate.shouldQueueTokens = true + + // Add tokens + let vector = createTestTokenVector(count: 5) + executor.addTokens(vector, lengthTotal: 50, lengthExcludingInBandSignaling: 50) + + let initialExecuteCount = mockDelegate.willExecuteCount + + let expectation = XCTestExpectation(description: "ExecuteTurn completed") + executor.executeTurnOnMutationQueue(tokenBudget: 500) { result in + XCTAssertEqual(result, .blocked) + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + + XCTAssertEqual(mockDelegate.willExecuteCount, initialExecuteCount, + "No tokens should be executed when blocked") + } + + func testExecuteTurnReturnsYieldedWhenMoreWork() throws { + // REQUIREMENT: When budget is exhausted but queue has more work, return .yielded. + + // Add many tokens to exceed budget + for _ in 0..<20 { + let vector = createTestTokenVector(count: 100) + executor.addTokens(vector, lengthTotal: 1000, lengthExcludingInBandSignaling: 1000) + } + + let expectation = XCTestExpectation(description: "ExecuteTurn completed") + executor.executeTurnOnMutationQueue(tokenBudget: 10) { result in + // With a tiny budget and lots of work, should yield + XCTAssertEqual(result, .yielded, "Should yield when more work remains after budget exhausted") + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + } + + func testExecuteTurnReturnsCompletedWhenEmpty() throws { + // REQUIREMENT: When queue is fully drained, return .completed. + + // Don't add any tokens - queue is empty + let expectation = XCTestExpectation(description: "ExecuteTurn completed") + executor.executeTurnOnMutationQueue(tokenBudget: 500) { result in + XCTAssertEqual(result, .completed, "Should return completed when queue is empty") + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + } + + // NEGATIVE TEST: .completed should ONLY be returned when truly empty + func testCompletedNotReturnedWithPendingWork() throws { + // REQUIREMENT: Must never return .completed if taskQueue or tokenQueue has work. + + // This test verifies the semantic: if there's work, don't return completed. + // The implementation may process all tokens in one turn if they fit the budget, + // so we verify the behavior with blocked state instead. + + mockDelegate.shouldQueueTokens = true // Force blocked state + + // Add tokens + let vector = createTestTokenVector(count: 10) + executor.addTokens(vector, lengthTotal: 100, lengthExcludingInBandSignaling: 100) + + let expectation = XCTestExpectation(description: "ExecuteTurn completed") + executor.executeTurnOnMutationQueue(tokenBudget: 500) { result in + // When blocked with pending work, should return blocked not completed + XCTAssertEqual(result, .blocked, "Should return blocked when shouldQueueTokens is true") + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + } + + func testExecuteTurnDrainsTaskQueue() throws { + // REQUIREMENT: High-priority tasks in taskQueue must run during executeTurn. + + var taskExecuted = false + executor.scheduleHighPriorityTask { + taskExecuted = true + } + + let expectation = XCTestExpectation(description: "ExecuteTurn completed") + executor.executeTurnOnMutationQueue(tokenBudget: 500) { _ in + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + + XCTAssertTrue(taskExecuted, "High-priority task should be executed during executeTurn") + } +} + +// MARK: - 2.4 Budget Enforcement Edge Cases + +/// Tests for budget enforcement edge cases (2.4) +/// These tests verify the "stop between groups, overshoot once" semantics. +/// +/// Key semantics being tested: +/// 1. Budget is checked BETWEEN groups, not within a group +/// 2. First group always executes (progress guarantee), even if it exceeds budget +/// 3. Second group does NOT execute if budget was exceeded by first group +/// 4. Groups are atomic - never split mid-execution +final class TokenExecutorBudgetEdgeCaseTests: XCTestCase { + + var mockDelegate: MockTokenExecutorDelegate! + var mockTerminal: VT100Terminal! + var executor: TokenExecutor! + + override func setUp() { + super.setUp() + // Enable fairness scheduler BEFORE creating executor (flag is cached at init) + iTermAdvancedSettingsModel.setUseFairnessSchedulerForTesting(true) + + mockDelegate = MockTokenExecutorDelegate() + mockTerminal = VT100Terminal() + executor = TokenExecutor( + mockTerminal, + slownessDetector: SlownessDetector(), + queue: iTermGCD.mutationQueue() + ) + executor.delegate = mockDelegate + // Skip notifyScheduler so we can test in isolation without registering + executor.testSkipNotifyScheduler = true + } + + override func tearDown() { + executor = nil + mockTerminal = nil + mockDelegate = nil + iTermAdvancedSettingsModel.setUseFairnessSchedulerForTesting(false) + super.tearDown() + } + + func testFirstGroupExceedingBudgetExecutes() throws { + // REQUIREMENT: Progress guarantee - at least one group must execute per turn, + // even if that group exceeds the budget. + + // Add a large token group (100 tokens, budget will be 1) + let vector = createTestTokenVector(count: 100) + executor.addTokens(vector, lengthTotal: 1000, lengthExcludingInBandSignaling: 1000) + + let initialExecuteCount = mockDelegate.willExecuteCount + + let expectation = XCTestExpectation(description: "ExecuteTurn completed") + var receivedResult: TurnResult? + executor.executeTurnOnMutationQueue(tokenBudget: 1) { result in + receivedResult = result + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + + // Even with budget of 1, at least one group should execute for progress + XCTAssertGreaterThan(mockDelegate.willExecuteCount, initialExecuteCount, + "At least one group should execute even if it exceeds budget") + // With only one group, it completes after processing + XCTAssertEqual(receivedResult, .completed, + "Single group should complete even if it exceeds budget") + } + + // NEGATIVE TEST: Budget should NOT be checked mid-group + func testBudgetNotCheckedWithinGroup() throws { + // REQUIREMENT: Groups are atomic. Never split a group mid-execution. + // This test verifies that a group with many tokens executes completely + // even with a tiny budget. + + // Add a token group with 50 tokens + let tokenCount = 50 + let vector = createTestTokenVector(count: tokenCount) + executor.addTokens(vector, lengthTotal: 500, lengthExcludingInBandSignaling: 500) + + let initialExecuteCount = mockDelegate.willExecuteCount + + let expectation = XCTestExpectation(description: "ExecuteTurn completed") + executor.executeTurnOnMutationQueue(tokenBudget: 1) { result in + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + + // The entire group should have executed atomically (progress guarantee) + XCTAssertGreaterThan(mockDelegate.willExecuteCount, initialExecuteCount, + "Group should execute atomically regardless of budget") + } + + func testBudgetCheckBetweenGroups() throws { + // REQUIREMENT: Budget is checked BETWEEN groups, allowing bounded overshoot. + // Uses high-priority vs normal-priority to ensure separate groups. + + // 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) + + let expectation = XCTestExpectation(description: "ExecuteTurn completed") + var receivedResult: TurnResult? + executor.executeTurnOnMutationQueue(tokenBudget: 10) { result in + receivedResult = result + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + + // Budget exceeded after first group (100 > 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.executeTurnOnMutationQueue(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.executeTurnOnMutationQueue(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() + // Enable fairness scheduler BEFORE creating executor (flag is cached at init) + iTermAdvancedSettingsModel.setUseFairnessSchedulerForTesting(true) + + 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 + iTermAdvancedSettingsModel.setUseFairnessSchedulerForTesting(false) + 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 + executor.isRegistered = true + 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 + executor.isRegistered = true + 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 + executor.isRegistered = true + 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") + } + + // Test that rapid addTokens calls all get processed correctly + func testRapidAddTokensAllProcessed() throws { + // REQUIREMENT: Multiple rapid addTokens calls should all be processed. + // The "no duplicate busy list entries" invariant is tested in FairnessScheduler tests + // (testEnqueueWorkNoDuplicates). + + // Register executor with scheduler + let sessionId = FairnessScheduler.shared.register(executor) + executor.fairnessSessionId = sessionId + executor.isRegistered = true + defer { FairnessScheduler.shared.unregister(sessionId: sessionId) } + + mockDelegate.shouldQueueTokens = false + + // Add tokens multiple times rapidly - each triggers notifyScheduler + let addCount = 5 + var totalLength = 0 + for _ in 0.. 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 + executor.isRegistered = true + 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: - Feature Flag Gating Tests + +/// Tests verifying that the useFairnessScheduler feature flag gates code paths correctly. +/// This is critical to ensure flag OFF uses legacy execute() and flag ON uses the scheduler. +/// +/// The conditional in notifyScheduler() is: +/// if useFairnessScheduler() && fairnessSessionId != 0 { scheduler path } +/// else { legacy path } +/// +/// We test all combinations: +/// - sessionId == 0, flag OFF → legacy (test 1) +/// - sessionId == 0, flag ON → legacy (test 1, flag doesn't matter) +/// - sessionId != 0, flag OFF → legacy (test 2) +/// - sessionId != 0, flag ON → scheduler (test 3) +final class TokenExecutorFeatureFlagGatingTests: XCTestCase { + + var mockDelegate: MockTokenExecutorDelegate! + var mockTerminal: VT100Terminal! + var executor: TokenExecutor! + var savedFlagValue: Bool = false + + override func setUp() { + super.setUp() + // Save original flag value + savedFlagValue = iTermAdvancedSettingsModel.useFairnessScheduler() + + mockDelegate = MockTokenExecutorDelegate() + mockTerminal = VT100Terminal() + // Clear any prior execution history + FairnessScheduler.shared.testClearExecutionHistory() + } + + /// Create executor after setting the feature flag, since the flag is cached at init time. + func createExecutor() { + executor = TokenExecutor( + mockTerminal, + slownessDetector: SlownessDetector(), + queue: iTermGCD.mutationQueue() + ) + executor.delegate = mockDelegate + } + + override func tearDown() { + // Restore original flag value + iTermAdvancedSettingsModel.setUseFairnessSchedulerForTesting(savedFlagValue) + + // Unregister if registered + if let executor, executor.fairnessSessionId != 0 { + FairnessScheduler.shared.unregister(sessionId: executor.fairnessSessionId) + } + executor = nil + mockTerminal = nil + mockDelegate = nil + super.tearDown() + } + + func testSchedulerPathWhenSessionIdIsNonZero() throws { + // REQUIREMENT: When fairnessSessionId != 0 AND useFairnessScheduler() is true, + // notifyScheduler() must route through FairnessScheduler. + // + // The scheduler calls executeTurn() which records to execution history. + // A non-empty execution history proves the scheduler path was taken. + + // Enable the feature flag + iTermAdvancedSettingsModel.setUseFairnessSchedulerForTesting(true) + createExecutor() + + // Register executor with scheduler - this gives us a non-zero sessionId + let sessionId = FairnessScheduler.shared.register(executor) + executor.fairnessSessionId = sessionId + executor.isRegistered = true + XCTAssertNotEqual(sessionId, 0, "Registration should return non-zero sessionId") + + mockDelegate.shouldQueueTokens = false + + // Clear history before our test + FairnessScheduler.shared.testClearExecutionHistory() + + // Add tokens - this will call notifyScheduler() internally + let vector = createTestTokenVector(count: 3) + executor.addTokens(vector, lengthTotal: 30, lengthExcludingInBandSignaling: 30) + + // Drain mutation queue to allow scheduler to process + for _ in 0..<10 { + waitForMutationQueue() + } + + // Tokens should have been executed + XCTAssertGreaterThan(mockDelegate.executedLengths.count, 0, + "Tokens should execute via scheduler path") + + // Execution history should have our sessionId - proving scheduler path was taken + let history = FairnessScheduler.shared.testGetAndClearExecutionHistory() + XCTAssertFalse(history.isEmpty, + "Execution history should NOT be empty when using scheduler path") + XCTAssertTrue(history.contains(sessionId), + "Execution history should contain our sessionId (\(sessionId)), got: \(history)") + } + + func testLegacyPathWhenFlagIsOffDespiteNonZeroSessionId() throws { + // CRITICAL TEST: This proves the feature flag actually gates the behavior. + // When flag is OFF, even a registered executor (non-zero sessionId) should use legacy path. + + // Explicitly disable the feature flag + iTermAdvancedSettingsModel.setUseFairnessSchedulerForTesting(false) + createExecutor() + + // Register executor with scheduler - this gives us a non-zero sessionId + let sessionId = FairnessScheduler.shared.register(executor) + executor.fairnessSessionId = sessionId + XCTAssertNotEqual(sessionId, 0, "Registration should return non-zero sessionId") + + mockDelegate.shouldQueueTokens = false + + // Clear history before our test + FairnessScheduler.shared.testClearExecutionHistory() + + // Add tokens - this will call notifyScheduler() internally + let vector = createTestTokenVector(count: 3) + executor.addTokens(vector, lengthTotal: 30, lengthExcludingInBandSignaling: 30) + + // Drain mutation queue + for _ in 0..<10 { + waitForMutationQueue() + } + + // Tokens should have been executed via legacy path + XCTAssertGreaterThan(mockDelegate.executedLengths.count, 0, + "Tokens should execute via legacy path when flag is OFF") + + // Execution history should be EMPTY because flag is OFF - proving flag gates the path + let history = FairnessScheduler.shared.testGetAndClearExecutionHistory() + XCTAssertTrue(history.isEmpty, + "Execution history should be empty when flag is OFF (even with non-zero sessionId)") + } +} + +// 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() + iTermAdvancedSettingsModel.setUseFairnessSchedulerForTesting(true) + mockDelegate = MockTokenExecutorDelegate() + mockTerminal = VT100Terminal() + } + + override func tearDown() { + mockTerminal = nil + mockDelegate = nil + iTermAdvancedSettingsModel.setUseFairnessSchedulerForTesting(false) + 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 + bgExecutor.isRegistered = true + fgExecutor.fairnessSessionId = fgId + fgExecutor.isRegistered = true + + 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 testBackgroundSessionCanProcessTokens() { + // Test that background sessions can 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 + executor.isRegistered = true + + // 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") + + // 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 + executor.isRegistered = true + + 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)") + } + } +} + +// MARK: - Accounting Invariant Tests + +/// Critical tests for availableSlots accounting invariants. +/// These are the most important tests - accounting drift causes stalls or overflow. +final class TokenExecutorAccountingInvariantTests: XCTestCase { + + var mockDelegate: MockTokenExecutorDelegate! + var mockTerminal: VT100Terminal! + + override func setUp() { + super.setUp() + // Explicitly disable fairness scheduler since these tests don't need it + iTermAdvancedSettingsModel.setUseFairnessSchedulerForTesting(false) + mockDelegate = MockTokenExecutorDelegate() + mockTerminal = VT100Terminal() + } + + override func tearDown() { + mockTerminal = nil + mockDelegate = nil + super.tearDown() + } + + func testAccountingInvariantSteadyState() { + // INVARIANT: At rest (no tokens in flight), backpressure should be .none. + + let executor = TokenExecutor( + mockTerminal, + slownessDetector: SlownessDetector(), + queue: iTermGCD.mutationQueue() + ) + executor.delegate = mockDelegate + + XCTAssertEqual(executor.backpressureLevel, .none, + "Fresh executor should have no backpressure") + } + + func testAccountingInvariantAfterEnqueueConsume() { + // INVARIANT: After enqueue + consume cycles, availableSlots returns to initial. + + let executor = TokenExecutor( + mockTerminal, + slownessDetector: SlownessDetector(), + queue: iTermGCD.mutationQueue() + ) + 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 processing complete + for _ in 0..<5 { + waitForMainQueue() + } + + XCTAssertEqual(executor.backpressureLevel, .none, + "Backpressure should return to none after processing") + } + + // NEGATIVE TEST: Accounting should NEVER drift over multiple cycles + func testAccountingNoDriftAfterMultipleCycles() { + // INVARIANT: Running many enqueue/consume cycles should not cause drift. + + let executor = TokenExecutor( + mockTerminal, + slownessDetector: SlownessDetector(), + queue: iTermGCD.mutationQueue() + ) + executor.delegate = mockDelegate + + // Run multiple cycles + for _ in 0..<5 { + let vector = createTestTokenVector(count: 3) + executor.addTokens(vector, lengthTotal: 30, lengthExcludingInBandSignaling: 30) + executor.schedule() + + // Drain mutation queue to let processing complete for this cycle + for _ in 0..<5 { + waitForMutationQueue() + } + XCTAssertEqual(executor.backpressureLevel, .none, + "Cycle should complete with no backpressure") + } + + // Final check - backpressure should be none after all cycles + XCTAssertEqual(executor.backpressureLevel, .none, + "No accounting drift after multiple cycles") + } + + func testAccountingInvariantAfterSessionClose() throws { + // INVARIANT: After session close with pending tokens, availableSlots restored. + + let executor = TokenExecutor(mockTerminal, slownessDetector: SlownessDetector(), queue: iTermGCD.mutationQueue()) + executor.delegate = mockDelegate + + // Register + let sessionId = FairnessScheduler.shared.register(executor) + executor.fairnessSessionId = sessionId + + // Add tokens + for _ in 0..<5 { + let vector = createTestTokenVector(count: 5) + executor.addTokens(vector, lengthTotal: 50, lengthExcludingInBandSignaling: 50) + } + + // Unregister (simulates session close) + FairnessScheduler.shared.unregister(sessionId: sessionId) + + // After close, backpressure should be released + XCTAssertEqual(executor.backpressureLevel, .none, + "Session close should restore available slots") + } +} + +// MARK: - ExecuteTurn Completion Callback Tests + +/// Tests for executeTurn completion callback semantics. +/// These are critical for scheduler correctness - completion must be called exactly once. +final class TokenExecutorCompletionCallbackTests: XCTestCase { + + var mockDelegate: MockTokenExecutorDelegate! + var mockTerminal: VT100Terminal! + var executor: TokenExecutor! + + override func setUp() { + super.setUp() + // Enable fairness scheduler BEFORE creating executor (flag is cached at init) + iTermAdvancedSettingsModel.setUseFairnessSchedulerForTesting(true) + + mockDelegate = MockTokenExecutorDelegate() + mockTerminal = VT100Terminal() + executor = TokenExecutor( + mockTerminal, + slownessDetector: SlownessDetector(), + queue: iTermGCD.mutationQueue() + ) + executor.delegate = mockDelegate + // Skip notifyScheduler so we can test in isolation without registering + executor.testSkipNotifyScheduler = true + } + + override func tearDown() { + executor = nil + mockTerminal = nil + mockDelegate = nil + iTermAdvancedSettingsModel.setUseFairnessSchedulerForTesting(false) + super.tearDown() + } + + func testCompletionCalledExactlyOnce() { + // REQUIREMENT: executeTurn completion must be called exactly once, never zero or multiple times. + + var completionCallCount = 0 + let expectation = XCTestExpectation(description: "Completion called") + + executor.executeTurnOnMutationQueue(tokenBudget: 500) { _ in + completionCallCount += 1 + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + + // Flush queues to ensure any spurious calls would have been made + waitForMutationQueue() + waitForMainQueue() + + XCTAssertEqual(completionCallCount, 1, + "Completion should be called exactly once") + } + + func testCompletionCalledExactlyOnceWithTokens() { + // REQUIREMENT: With tokens in queue, completion still called exactly once. + + var completionCallCount = 0 + let expectation = XCTestExpectation(description: "Completion called") + + // Add some tokens + let vector = createTestTokenVector(count: 10) + executor.addTokens(vector, lengthTotal: 100, lengthExcludingInBandSignaling: 100) + + executor.executeTurnOnMutationQueue(tokenBudget: 500) { _ in + completionCallCount += 1 + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + + // Flush queues to ensure any spurious calls would have been made + waitForMutationQueue() + waitForMainQueue() + + XCTAssertEqual(completionCallCount, 1, + "Completion should be called exactly once even with tokens") + } + + func testCompletionCalledExactlyOnceWhenBlocked() { + // REQUIREMENT: When blocked, completion is called exactly once with .blocked. + + mockDelegate.shouldQueueTokens = true + var completionCallCount = 0 + var receivedResult: TurnResult? + let expectation = XCTestExpectation(description: "Completion called") + + // Add tokens + let vector = createTestTokenVector(count: 5) + executor.addTokens(vector, lengthTotal: 50, lengthExcludingInBandSignaling: 50) + + executor.executeTurnOnMutationQueue(tokenBudget: 500) { result in + completionCallCount += 1 + receivedResult = result + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + + // Flush queues to ensure any spurious calls would have been made + waitForMutationQueue() + waitForMainQueue() + + XCTAssertEqual(completionCallCount, 1, + "Completion should be called exactly once when blocked") + XCTAssertEqual(receivedResult, .blocked, + "Should receive blocked result") + } + + func testCompletionCalledExactlyOnceWhenYielding() { + // REQUIREMENT: When yielding due to budget, completion called exactly once with .yielded. + + var completionCallCount = 0 + var receivedResult: TurnResult? + let expectation = XCTestExpectation(description: "Completion called") + + // Add many tokens to exceed budget + for _ in 0..<20 { + let vector = createTestTokenVector(count: 50) + executor.addTokens(vector, lengthTotal: 500, lengthExcludingInBandSignaling: 500) + } + + executor.executeTurnOnMutationQueue(tokenBudget: 10) { result in + completionCallCount += 1 + receivedResult = result + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + + // Flush queues to ensure any spurious calls would have been made + waitForMutationQueue() + waitForMainQueue() + + XCTAssertEqual(completionCallCount, 1, + "Completion should be called exactly once when yielding") + XCTAssertEqual(receivedResult, .yielded, + "Should receive yielded result") + } + + func testMultipleExecuteTurnCallsEachGetCompletion() { + // REQUIREMENT: Multiple sequential executeTurn calls each get their own completion. + + var completionResults: [TurnResult] = [] + let allDone = XCTestExpectation(description: "All completions") + allDone.expectedFulfillmentCount = 3 + + // First call + executor.executeTurnOnMutationQueue(tokenBudget: 500) { result in + completionResults.append(result) + allDone.fulfill() + + // Second call (nested) + self.executor.executeTurnOnMutationQueue(tokenBudget: 500) { result in + completionResults.append(result) + allDone.fulfill() + + // Third call (nested) + self.executor.executeTurnOnMutationQueue(tokenBudget: 500) { result in + completionResults.append(result) + allDone.fulfill() + } + } + } + + wait(for: [allDone], timeout: 2.0) + + XCTAssertEqual(completionResults.count, 3, + "Each executeTurn call should get exactly one completion") + } +} + +// MARK: - Budget Enforcement Detailed Tests + +/// Detailed tests for budget enforcement behavior. +/// These tests use high-priority vs normal-priority tokens to create guaranteed +/// separate groups (they're in different queues) for proper verification. +final class TokenExecutorBudgetEnforcementDetailedTests: XCTestCase { + + var mockDelegate: MockTokenExecutorDelegate! + var mockTerminal: VT100Terminal! + var executor: TokenExecutor! + + override func setUp() { + super.setUp() + // Enable fairness scheduler BEFORE creating executor (flag is cached at init) + iTermAdvancedSettingsModel.setUseFairnessSchedulerForTesting(true) + + mockDelegate = MockTokenExecutorDelegate() + mockTerminal = VT100Terminal() + executor = TokenExecutor( + mockTerminal, + slownessDetector: SlownessDetector(), + queue: iTermGCD.mutationQueue() + ) + executor.delegate = mockDelegate + // Skip notifyScheduler so we can test in isolation without registering + executor.testSkipNotifyScheduler = true + } + + override func tearDown() { + executor = nil + mockTerminal = nil + mockDelegate = nil + iTermAdvancedSettingsModel.setUseFairnessSchedulerForTesting(false) + super.tearDown() + } + + func testBudgetExceededReturnsYielded() { + // REQUIREMENT: When processing exceeds budget, must return .yielded (not .completed). + // Uses high-priority + normal-priority to guarantee two separate groups. + + // Group 1: High-priority (100 tokens) + let highPriVector = createTestTokenVector(count: 100) + executor.addTokens(highPriVector, lengthTotal: 1000, lengthExcludingInBandSignaling: 1000, highPriority: true) + + // Group 2: Normal-priority (50 tokens) + let normalVector = createTestTokenVector(count: 50) + executor.addTokens(normalVector, lengthTotal: 500, lengthExcludingInBandSignaling: 500, highPriority: false) + + let expectation = XCTestExpectation(description: "ExecuteTurn completed") + var receivedResult: TurnResult? + + executor.executeTurnOnMutationQueue(tokenBudget: 10) { result in + receivedResult = result + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + + XCTAssertEqual(receivedResult, .yielded, + "With small budget and multiple groups, should yield after first group") + } + + func testProgressGuaranteeWithZeroBudget() { + // REQUIREMENT: Even with budget=0, at least one group must execute for progress. + // This prevents starvation. + + // Add a single group + let vector = createTestTokenVector(count: 5) + executor.addTokens(vector, lengthTotal: 50, lengthExcludingInBandSignaling: 50) + + let initialWillExecuteCount = mockDelegate.willExecuteCount + + let expectation = XCTestExpectation(description: "ExecuteTurn completed") + var receivedResult: TurnResult? + executor.executeTurnOnMutationQueue(tokenBudget: 0) { result in + receivedResult = result + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + + // Should have executed at least once for progress guarantee + XCTAssertGreaterThan(mockDelegate.willExecuteCount, initialWillExecuteCount, + "At least one group should execute even with budget=0") + XCTAssertEqual(receivedResult, .completed, + "With single group and budget=0, should complete after progress guarantee") + } + + func testSecondGroupNotExecutedWhenBudgetExceededByFirst() { + // REQUIREMENT: After first group exceeds budget, second group should NOT execute in same turn. + // 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.executeTurnOnMutationQueue(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 only) + XCTAssertEqual(afterFirstTurnExecuteCount, initialExecuteCount + 1, + "First turn should execute only the first group (stop between groups)") + 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.executeTurnOnMutationQueue(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") + } + + func testGroupAtomicity() { + // REQUIREMENT: Groups are atomic - never split mid-execution. + // We verify this by checking that a group with many tokens executes + // fully even with a tiny budget. + + // Add a single group with 100 tokens + let tokenCount = 100 + let vector = createTestTokenVector(count: tokenCount) + let totalLength = tokenCount * 10 + executor.addTokens(vector, lengthTotal: totalLength, lengthExcludingInBandSignaling: totalLength) + + let initialWillExecuteCount = mockDelegate.willExecuteCount + + let expectation = XCTestExpectation(description: "ExecuteTurn completed") + var receivedResult: TurnResult? + executor.executeTurnOnMutationQueue(tokenBudget: 1) { result in + receivedResult = result + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + + // The entire group should have executed atomically + XCTAssertEqual(mockDelegate.willExecuteCount, initialWillExecuteCount + 1, + "Group should execute exactly once (atomically)") + // With only one group, it should complete + XCTAssertEqual(receivedResult, .completed, + "Single group should complete (executed atomically despite tiny budget)") + + // Verify the full length was reported + if !mockDelegate.executedLengths.isEmpty { + let execution = mockDelegate.executedLengths[0] + XCTAssertEqual(execution.total, totalLength, + "Full group length should be reported (group was not split)") + } + } + + func testBudgetUsesTokenCountNotLengthTotal() { + // REQUIREMENT: Budget enforcement must use TOKEN COUNT, not lengthTotal. + // This test uses mismatched values to distinguish the two metrics. + // + // If budget used lengthTotal (bug), this test would fail because: + // - Group1 (50 lengthTotal) + Group2 (5000 lengthTotal) = 5050 > 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.executeTurnOnMutationQueue(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.executeTurnOnMutationQueue(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() + // Enable fairness scheduler BEFORE creating executor (flag is cached at init) + iTermAdvancedSettingsModel.setUseFairnessSchedulerForTesting(true) + + mockDelegate = MockTokenExecutorDelegate() + mockTerminal = VT100Terminal() + executor = TokenExecutor( + mockTerminal, + slownessDetector: SlownessDetector(), + queue: iTermGCD.mutationQueue() + ) + executor.delegate = mockDelegate + // Skip notifyScheduler so we can call executeTurn() directly for unit testing + executor.testSkipNotifyScheduler = true + } + + override func tearDown() { + executor = nil + mockTerminal = nil + iTermAdvancedSettingsModel.setUseFairnessSchedulerForTesting(false) + 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. + + // Block execution during token addition to prevent scheduler from consuming them + mockDelegate.shouldQueueTokens = true + + // 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) + } + + // Unblock execution and capture initial state + mockDelegate.shouldQueueTokens = false + let initialWillExecuteCount = mockDelegate.willExecuteCount + + let expectation = XCTestExpectation(description: "ExecuteTurn completed") + var receivedResult: TurnResult? + executor.executeTurnOnMutationQueue(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. + + // Block execution during token addition + mockDelegate.shouldQueueTokens = true + + // 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) + + // Unblock execution before explicit turns + mockDelegate.shouldQueueTokens = false + + // First turn: budget 10, first group is 100 tokens - should execute only first + let firstExpectation = XCTestExpectation(description: "First turn") + var firstResult: TurnResult? + executor.executeTurnOnMutationQueue(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.executeTurnOnMutationQueue(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.executeTurnOnMutationQueue(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. + + // Block execution during token addition + mockDelegate.shouldQueueTokens = true + + // 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) + + // Unblock execution + mockDelegate.shouldQueueTokens = false + + let expectation = XCTestExpectation(description: "ExecuteTurn completed") + var receivedResult: TurnResult? + executor.executeTurnOnMutationQueue(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.executeTurnOnMutationQueue(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() + // Enable fairness scheduler since tests register with it + iTermAdvancedSettingsModel.setUseFairnessSchedulerForTesting(true) + mockDelegate = MockTokenExecutorDelegate() + mockTerminal = VT100Terminal() + } + + override func tearDown() { + mockTerminal = nil + mockDelegate = nil + iTermAdvancedSettingsModel.setUseFairnessSchedulerForTesting(false) + super.tearDown() + } + + func testSlotsAccountingBalancedAfterFullDrain() throws { + // 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: iTermGCD.mutationQueue() + ) + executor.delegate = mockDelegate + // Prevent auto-execution so we can verify accounting precisely + executor.testSkipNotifyScheduler = true + + // Block execution so tokens accumulate even if scheduler fires + mockDelegate.shouldQueueTokens = true + + // Register with scheduler so executeTurn works + let sessionId = FairnessScheduler.shared.register(executor) + executor.fairnessSessionId = sessionId + executor.isRegistered = true + + let initialSlots = executor.testAvailableSlots + let totalSlots = executor.testTotalSlots + XCTAssertEqual(initialSlots, totalSlots, "Fresh executor should have all slots available") + + // Add more token groups than totalSlots to verify negative slots work. + // With non-blocking addTokens, this returns immediately without blocking. + let addCount = 50 + for _ in 0.. 0 { + XCTAssertGreaterThan(executor.backpressureLevel, .none, + "Should have backpressure with \(queuedTokens) queued tokens") + } else { + XCTAssertTrue(executor.backpressureLevel == .none, + "Backpressure should be .none when no tokens remain") + } + } + + func testRapidAddConsumeAddCycle() { + // REQUIREMENT: Rapid add->consume->add cycles should not cause drift. + + let executor = TokenExecutor( + mockTerminal, + slownessDetector: SlownessDetector(), + queue: iTermGCD.mutationQueue() + ) + executor.delegate = mockDelegate + + // Register so schedule() works + let sessionId = FairnessScheduler.shared.register(executor) + executor.fairnessSessionId = sessionId + + let totalSlots = executor.testTotalSlots + let initialSlots = executor.testAvailableSlots + XCTAssertEqual(initialSlots, totalSlots, "Should start with all slots available") + + for cycle in 0..<20 { + // Add + let vector = createTestTokenVector(count: 5) + executor.addTokens(vector, lengthTotal: 50, lengthExcludingInBandSignaling: 50) + + // 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") + + // Immediately trigger consume + let expectation = XCTestExpectation(description: "Cycle \(cycle)") + executor.executeTurnOnMutationQueue(tokenBudget: 500) { _ in + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + + // After consume, slots should return to totalSlots + let afterConsume = executor.testAvailableSlots + XCTAssertEqual(afterConsume, totalSlots, + "After consume in cycle \(cycle), slots should return to max") + } + + FairnessScheduler.shared.unregister(sessionId: sessionId) + + // Verify no drift after many cycles + let finalSlots = executor.testAvailableSlots + XCTAssertEqual(finalSlots, totalSlots, + "After \(20) add/consume cycles, slots should equal totalSlots (no drift)") + + // 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() + // Enable fairness scheduler BEFORE creating executor (flag is cached at init) + iTermAdvancedSettingsModel.setUseFairnessSchedulerForTesting(true) + + mockDelegate = MockTokenExecutorDelegate() + mockTerminal = VT100Terminal() + executor = TokenExecutor( + mockTerminal, + slownessDetector: SlownessDetector(), + queue: iTermGCD.mutationQueue() + ) + executor.delegate = mockDelegate + // Skip notifyScheduler so we can test in isolation without registering + executor.testSkipNotifyScheduler = true + } + + override func tearDown() { + executor = nil + mockTerminal = nil + mockDelegate = nil + iTermAdvancedSettingsModel.setUseFairnessSchedulerForTesting(false) + 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.executeTurnOnMutationQueue(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.executeTurnOnMutationQueue(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.executeTurnOnMutationQueue(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.executeTurnOnMutationQueue(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.. Int { + guard let delegate = mockDelegate else { return 0 } + return delegate.executedLengths.reduce(0) { $0 + $1.total } + } + + func checkCompletion() { + // Stop polling if test has finished (prevents crash in tearDown) + guard !testFinished else { return } + if getTotalBytes() >= expectedTotalBytes { + condition.fulfill() + } else { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + checkCompletion() + } + } + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + checkCompletion() + } + + wait(for: [condition], timeout: 2.0) + testFinished = true + + // The high-priority task should have added tokens + XCTAssertTrue(tokensAddedByHighPriorityTask, "High-priority task should have run") + + // The key assertion: ALL bytes should have been processed, including those + // added by the high-priority task. This proves the scheduler re-added the + // session via the safety net (workArrivedWhileExecuting) and processed them. + let totalBytesExecuted = getTotalBytes() + XCTAssertGreaterThanOrEqual(totalBytesExecuted, expectedTotalBytes, + "All tokens should be processed including those added by high-priority task. " + + "Expected \(expectedTotalBytes), got \(totalBytesExecuted)") + } + +} + +// MARK: - Session Restoration (Revive) Tests + +/// Tests for the session revive path: disable → preserve tokens → re-enable → tokens drain. +/// Tokens are preserved on unregister to support session revival. +final class TokenExecutorSessionReviveTests: XCTestCase { + + var mockDelegate: MockTokenExecutorDelegate! + var mockTerminal: VT100Terminal! + + override func setUp() { + super.setUp() + iTermAdvancedSettingsModel.setUseFairnessSchedulerForTesting(true) + mockDelegate = MockTokenExecutorDelegate() + mockTerminal = VT100Terminal() + } + + override func tearDown() { + mockTerminal = nil + mockDelegate = nil + super.tearDown() + } + + // MARK: - Test 1: Unregister Preserves Tokens + + func testUnregisterPreservesTokens() throws { + let executor = TokenExecutor(mockTerminal, + slownessDetector: SlownessDetector(), + queue: iTermGCD.mutationQueue()) + executor.delegate = mockDelegate + mockDelegate.shouldQueueTokens = true + + let sessionId = FairnessScheduler.shared.register(executor) + executor.fairnessSessionId = sessionId + executor.isRegistered = true + + for _ in 0..<10 { + let vector = createTestTokenVector(count: 5) + executor.addTokens(vector, lengthTotal: 50, lengthExcludingInBandSignaling: 50) + } + + waitForMutationQueue() + + let tokensBeforeUnregister = executor.testQueuedTokenCount + XCTAssertGreaterThan(tokensBeforeUnregister, 0) + + FairnessScheduler.shared.unregister(sessionId: sessionId) + executor.fairnessSessionId = 0 + executor.isRegistered = false + + waitForMutationQueue() + + let tokensAfterUnregister = executor.testQueuedTokenCount + XCTAssertEqual(tokensAfterUnregister, tokensBeforeUnregister) + + XCTAssertEqual(executor.fairnessSessionId, 0) + XCTAssertFalse(executor.isRegistered) + } + + // MARK: - Test 2: Re-registration Processes Preserved Tokens + + func testReRegistrationProcessesPreservedTokens() throws { + let executor = TokenExecutor(mockTerminal, + slownessDetector: SlownessDetector(), + queue: iTermGCD.mutationQueue()) + executor.delegate = mockDelegate + mockDelegate.shouldQueueTokens = true + + let sessionId1 = FairnessScheduler.shared.register(executor) + executor.fairnessSessionId = sessionId1 + executor.isRegistered = true + + for _ in 0..<10 { + let vector = createTestTokenVector(count: 5) + executor.addTokens(vector, lengthTotal: 50, lengthExcludingInBandSignaling: 50) + } + + waitForMutationQueue() + + FairnessScheduler.shared.unregister(sessionId: sessionId1) + executor.fairnessSessionId = 0 + executor.isRegistered = false + waitForMutationQueue() + + let sessionId2 = FairnessScheduler.shared.register(executor) + executor.fairnessSessionId = sessionId2 + executor.isRegistered = true + waitForMutationQueue() + + XCTAssertNotEqual(sessionId2, sessionId1) + XCTAssertTrue(executor.isRegistered) + + mockDelegate.shouldQueueTokens = false + + let processedExpectation = XCTestExpectation(description: "Tokens processed") + mockDelegate.onWillExecute = { + processedExpectation.fulfill() + } + + executor.schedule() + wait(for: [processedExpectation], timeout: 2.0) + + XCTAssertGreaterThan(mockDelegate.willExecuteCount, 0) + + FairnessScheduler.shared.unregister(sessionId: sessionId2) + } + + // MARK: - Test 4: Full Disable-Preserve-Revive-Drain Cycle + + func testFullDisablePreserveReviveDrainCycle() throws { + let executor = TokenExecutor(mockTerminal, + slownessDetector: SlownessDetector(), + queue: iTermGCD.mutationQueue()) + executor.delegate = mockDelegate + mockDelegate.shouldQueueTokens = true + + let sessionId1 = FairnessScheduler.shared.register(executor) + executor.fairnessSessionId = sessionId1 + executor.isRegistered = true + + // Add tokens while queued + for _ in 0..<20 { + let vector = createTestTokenVector(count: 5) + executor.addTokens(vector, lengthTotal: 50, lengthExcludingInBandSignaling: 50) + } + + waitForMutationQueue() + + let tokensBeforeUnregister = executor.testQueuedTokenCount + XCTAssertGreaterThan(tokensBeforeUnregister, 0, "Should have queued tokens") + + // Unregister (preserve tokens) + FairnessScheduler.shared.unregister(sessionId: sessionId1) + executor.isRegistered = false + executor.fairnessSessionId = 0 + + waitForMutationQueue() + + let tokensAfterUnregister = executor.testQueuedTokenCount + XCTAssertEqual(tokensAfterUnregister, tokensBeforeUnregister, + "Tokens should be preserved after unregister") + + // Re-register + let sessionId2 = FairnessScheduler.shared.register(executor) + executor.fairnessSessionId = sessionId2 + executor.isRegistered = true + + XCTAssertNotEqual(sessionId2, sessionId1, "Should get new session ID") + + // Unpause and verify tokens can be processed + mockDelegate.shouldQueueTokens = false + + let postReviveExpectation = XCTestExpectation(description: "Post-revive execution") + mockDelegate.onWillExecute = { + postReviveExpectation.fulfill() + } + + FairnessScheduler.shared.sessionDidEnqueueWork(sessionId2) + + wait(for: [postReviveExpectation], timeout: 2.0) + + XCTAssertGreaterThan(mockDelegate.willExecuteCount, 0, + "Preserved tokens should be processed after revive") + + FairnessScheduler.shared.unregister(sessionId: sessionId2) + } + + // MARK: - Test 5: schedule() Triggers Processing After Re-Registration + + func testScheduleTriggersProcessingAfterReRegistration() throws { + let executor = TokenExecutor(mockTerminal, + slownessDetector: SlownessDetector(), + queue: iTermGCD.mutationQueue()) + executor.delegate = mockDelegate + mockDelegate.shouldQueueTokens = true + + let sessionId1 = FairnessScheduler.shared.register(executor) + executor.fairnessSessionId = sessionId1 + executor.isRegistered = true + + for _ in 0..<10 { + let vector = createTestTokenVector(count: 5) + executor.addTokens(vector, lengthTotal: 50, lengthExcludingInBandSignaling: 50) + } + + waitForMutationQueue() + + FairnessScheduler.shared.unregister(sessionId: sessionId1) + executor.fairnessSessionId = 0 + executor.isRegistered = false + waitForMutationQueue() + + let sessionId2 = FairnessScheduler.shared.register(executor) + executor.fairnessSessionId = sessionId2 + executor.isRegistered = true + + mockDelegate.shouldQueueTokens = false + let scheduleCalledExpectation = XCTestExpectation(description: "schedule() called") + mockDelegate.onWillExecute = { + scheduleCalledExpectation.fulfill() + } + + executor.schedule() + wait(for: [scheduleCalledExpectation], timeout: 2.0) + + XCTAssertGreaterThan(mockDelegate.willExecuteCount, 0) + + FairnessScheduler.shared.unregister(sessionId: sessionId2) + } + + // MARK: - Test 6: executeTurn After Disable Preserves Tokens (Race Regression) + + /// Regression test for the race between setTerminalEnabled:NO and a + /// previously-scheduled executeTurn. The disable path (running in a joined + /// block) sets delegate=nil and isRegistered=false synchronously, but the + /// FairnessScheduler's async unregister hasn't removed the session yet. + /// If executeNextTurn fires before that unregister block, executeTurn must + /// NOT discard tokens — they need to survive for session revive. + func testExecuteTurnAfterDisablePreservesTokens() throws { + let executor = TokenExecutor(mockTerminal, + slownessDetector: SlownessDetector(), + queue: iTermGCD.mutationQueue()) + executor.delegate = mockDelegate + mockDelegate.shouldQueueTokens = true + + executor.testSkipNotifyScheduler = true + + let sessionId = FairnessScheduler.shared.register(executor) + executor.fairnessSessionId = sessionId + executor.isRegistered = true + + // Enqueue tokens while paused (shouldQueueTokens = true) + for _ in 0..<10 { + let vector = createTestTokenVector(count: 5) + executor.addTokens(vector, lengthTotal: 50, lengthExcludingInBandSignaling: 50) + } + + waitForMutationQueue() + + let tokensBefore = executor.testQueuedTokenCount + XCTAssertGreaterThan(tokensBefore, 0, "Should have queued tokens") + + // Simulate setTerminalEnabled:NO during a joined block: + // delegate is niled and isRegistered is cleared, but the async + // unregister hasn't executed yet (session still in FairnessScheduler). + executor.isRegistered = false + executor.delegate = nil + + // Now simulate the race: executeTurn fires on mutation queue while + // session is still registered in FairnessScheduler but executor + // has delegate=nil and isRegistered=false. + let turnExpectation = XCTestExpectation(description: "executeTurn completed") + iTermGCD.mutationQueue().async { + executor.executeTurn(tokenBudget: 1000) { result in + XCTAssertEqual(result, .completed) + turnExpectation.fulfill() + } + } + wait(for: [turnExpectation], timeout: 2.0) + + // Tokens must be preserved — not discarded by the nil-delegate path. + let tokensAfter = executor.testQueuedTokenCount + XCTAssertEqual(tokensAfter, tokensBefore, + "executeTurn with nil delegate after disable must not discard tokens") + + // Clean up + FairnessScheduler.shared.unregister(sessionId: sessionId) + waitForMutationQueue() + } +} diff --git a/ModernTests/FairnessScheduler/TwoTierTokenQueueTests.swift b/ModernTests/FairnessScheduler/TwoTierTokenQueueTests.swift new file mode 100644 index 0000000000..ab16c68f4d --- /dev/null +++ b/ModernTests/FairnessScheduler/TwoTierTokenQueueTests.swift @@ -0,0 +1,225 @@ +// +// TwoTierTokenQueueTests.swift +// ModernTests +// +// Unit tests for TwoTierTokenQueue grouping and enumeration behavior. +// + +import XCTest +@testable import iTerm2SharedARC + +// MARK: - Group Boundary Tests + +/// Tests for TokenArrayGroup formation within a single queue. +/// These tests verify that enumerateTokenArrayGroups correctly identifies +/// group boundaries based on token coalesceability. +final class TwoTierTokenQueueGroupingTests: XCTestCase { + + func testNonCoalescableTokensFormSeparateGroups() { + // REQUIREMENT: Each TokenArray with non-coalescable tokens (e.g., VT100_UNKNOWNCHAR) + // should form its own group, even when in the same queue. + + let queue = TwoTierTokenQueue() + + // Add 3 token arrays with non-coalescable tokens (VT100_UNKNOWNCHAR) + // Use different lengths to verify each is a separate group + let lengths = [100, 200, 300] + for length in lengths { + let tokenArray = createNonCoalescableTokenArray(tokenCount: 1, lengthPerToken: length) + queue.addTokens(tokenArray, highPriority: false) + } + + // Count how many groups we get and their lengths + var groupCount = 0 + var observedLengths: [Int] = [] + + queue.enumerateTokenArrayGroups { group, priority in + groupCount += 1 + observedLengths.append(group.lengthTotal) + _ = group.consume() + return true // Continue enumerating + } + + // Each TokenArray should be its own group (non-coalescable) + XCTAssertEqual(groupCount, 3, "Each non-coalescable TokenArray should form its own group") + XCTAssertEqual(observedLengths, lengths, "Each group should have the expected length") + } + + func testEnumerateGroupsProcessesInOrder() { + // REQUIREMENT: Groups should be processed in FIFO order within a queue. + + let queue = TwoTierTokenQueue() + + // Add arrays with different lengths to identify them + let lengths = [10, 20, 30] + for length in lengths { + let tokenArray = createNonCoalescableTokenArray(tokenCount: 1, lengthPerToken: length) + queue.addTokens(tokenArray, highPriority: false) + } + + var observedLengths: [Int] = [] + + queue.enumerateTokenArrayGroups { group, priority in + observedLengths.append(group.lengthTotal) + _ = group.consume() + return true + } + + XCTAssertEqual(observedLengths, lengths, + "Groups should be processed in FIFO order") + } + + func testEnumerateGroupsStopsWhenClosureReturnsFalse() { + // REQUIREMENT: Enumeration should stop when closure returns false. + // This is essential for budget enforcement to work. + + let queue = TwoTierTokenQueue() + + // Add 5 groups + for i in 0..<5 { + let tokenArray = createNonCoalescableTokenArray(tokenCount: 1, lengthPerToken: (i + 1) * 10) + queue.addTokens(tokenArray, highPriority: false) + } + + var groupsProcessed = 0 + + queue.enumerateTokenArrayGroups { group, priority in + groupsProcessed += 1 + _ = group.consume() + return groupsProcessed < 2 // Stop after 2 groups + } + + XCTAssertEqual(groupsProcessed, 2, + "Enumeration should stop when closure returns false") + + // Queue should still have remaining groups + XCTAssertFalse(queue.isEmpty, "Queue should still have 3 remaining groups") + } + + func testEnumerateGroupsReturnsCorrectPriority() { + // REQUIREMENT: Enumeration should report correct priority for each group. + + let queue = TwoTierTokenQueue() + + // Add high-priority group first + queue.addTokens(createNonCoalescableTokenArray(tokenCount: 1), highPriority: true) + + // Add normal-priority group + queue.addTokens(createNonCoalescableTokenArray(tokenCount: 1), highPriority: false) + + var priorities: [Int] = [] + + queue.enumerateTokenArrayGroups { group, priority in + priorities.append(priority) + _ = group.consume() + return true + } + + // Priority 0 = high, Priority 1 = normal + XCTAssertEqual(priorities, [0, 1], + "Should process high-priority (0) before normal-priority (1)") + } + + func testHighPriorityExecutesBeforeNormalEvenWhenAddedSecond() { + // REQUIREMENT: High-priority token arrays must execute before normal-priority, + // regardless of insertion order. This is essential for API injection (e.g., report + // responses) to be handled promptly. + + let queue = TwoTierTokenQueue() + + // Add NORMAL-priority first with distinct length (200) + let normalArray = createNonCoalescableTokenArray(tokenCount: 1, lengthPerToken: 200) + queue.addTokens(normalArray, highPriority: false) + + // Add HIGH-priority second with distinct length (100) + let highPriArray = createNonCoalescableTokenArray(tokenCount: 1, lengthPerToken: 100) + queue.addTokens(highPriArray, highPriority: true) + + // Track execution order via (priority, lengthTotal) tuples + var executionOrder: [(priority: Int, length: Int)] = [] + + queue.enumerateTokenArrayGroups { group, priority in + executionOrder.append((priority: priority, length: group.lengthTotal)) + _ = group.consume() + return true + } + + // Should have processed both + XCTAssertEqual(executionOrder.count, 2, "Both arrays should be processed") + + // High-priority (length=100) should execute FIRST despite being added SECOND + XCTAssertEqual(executionOrder[0].priority, 0, + "High-priority (queue[0]) should execute first") + XCTAssertEqual(executionOrder[0].length, 100, + "High-priority array (length=100) should execute first") + + // Normal-priority (length=200) should execute SECOND despite being added FIRST + XCTAssertEqual(executionOrder[1].priority, 1, + "Normal-priority (queue[1]) should execute second") + XCTAssertEqual(executionOrder[1].length, 200, + "Normal-priority array (length=200) should execute second") + } + + func testMultipleGroupsInSameQueueWithBudgetSemantics() { + // REQUIREMENT: Budget enforcement should be able to stop between groups + // in the same queue. This test simulates what executeTurn does. + + let queue = TwoTierTokenQueue() + + // Add 3 groups with 10 tokens each + let tokensPerGroup = 10 + for _ in 0..<3 { + let tokenArray = createNonCoalescableTokenArray(tokenCount: tokensPerGroup, lengthPerToken: 10) + queue.addTokens(tokenArray, highPriority: false) // All normal priority + } + + // Simulate budget enforcement: stop after first group exceeds budget + var tokensConsumed = 0 + var groupsExecuted = 0 + let budget = 5 // Budget that first group (10 tokens) will exceed + + queue.enumerateTokenArrayGroups { group, priority in + // We know each group has exactly tokensPerGroup tokens (our input constant) + let groupTokenCount = tokensPerGroup + + // Budget check BETWEEN groups (not within) + if tokensConsumed + groupTokenCount > 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.. Bool { - return false + false } - func screenUpdateBlock(_ blockID: String?, action: iTermUpdateBlockAction) { + func screenUpdateBlock(_ blockID: String, action: iTermUpdateBlockAction) { } func screenPollLocalDirectoryOnly() { diff --git a/iTerm2.xcodeproj/project.pbxproj b/iTerm2.xcodeproj/project.pbxproj index 3c108cca0a..6e9a5a883f 100644 --- a/iTerm2.xcodeproj/project.pbxproj +++ b/iTerm2.xcodeproj/project.pbxproj @@ -2815,6 +2815,7 @@ 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 */; }; + 375E381EC78004ECB18F67A1 /* FairnessScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 933A3C237022F915E5EE975D /* FairnessScheduler.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 */; }; @@ -7733,6 +7734,7 @@ 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 = ""; }; + 933A3C237022F915E5EE975D /* FairnessScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FairnessScheduler.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 = ""; }; @@ -11483,6 +11485,7 @@ A61220E22E10489000F48E64 /* iTermFocusFollowsMouse.swift */, A6F7D4CE2E038B540065D09C /* PTYSession+Browser.swift */, A69553882DD1AA7B002E694D /* TokenArray.swift */, + 933A3C237022F915E5EE975D /* FairnessScheduler.swift */, A6C811F82DD1A7850088E628 /* TwoTierTokenQueue.swift */, A697CD702D973E370031583F /* iTermApplicationDelegate.swift */, A61E0C4F2D5ACD4C00D4633A /* PTYSession.swift */, @@ -18645,6 +18648,7 @@ A62132D12786109000B80724 /* CapturedOutput.m in Sources */, A667C26027791223006B4DEF /* PTYAnnotation.m in Sources */, A69553892DD1AA7F002E694D /* TokenArray.swift in Sources */, + 375E381EC78004ECB18F67A1 /* FairnessScheduler.swift in Sources */, A653F6BB24D4C9B50062377E /* iTermKeyLabels.m in Sources */, A6EC0B0F2C9C9AA800598D20 /* FoldMark.swift in Sources */, A6A3EDC62E0CDA9D00D711DA /* iTermBrowserActionPerforming.swift in Sources */, diff --git a/sources/FairnessScheduler.swift b/sources/FairnessScheduler.swift new file mode 100644 index 0000000000..4c74a9d5a9 --- /dev/null +++ b/sources/FairnessScheduler.swift @@ -0,0 +1,330 @@ +// +// FairnessScheduler.swift +// iTerm2SharedARC +// +// Round-robin fair scheduler for token execution across PTY sessions. +// See implementation.md for design details. +// +// Thread Safety: +// - ID allocation uses lock-free atomic increment +// - All other state is synchronized via iTermGCD.mutationQueue +// - Public methods dispatch async to avoid blocking callers +// + +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. + /// + /// Threading contract: This method is called on mutationQueue, and the completion + /// callback MUST be invoked synchronously on mutationQueue before returning. + /// FairnessScheduler relies on this guarantee to avoid unnecessary async dispatch. + func executeTurn(tokenBudget: Int, completion: @escaping (TurnResult) -> Void) +} + +/// 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. + /// + /// Future enhancement: This could become adaptive based on session count, + /// backpressure level, frame rate thresholds, or system load to balance + /// responsiveness with throughput. Lower budgets yield more frequently + /// (better responsiveness, lower throughput). Higher budgets process more + /// per turn (better throughput, less responsive). + static let defaultTokenBudget = 1000 + + // MARK: - Private State + + /// Atomic counter for session ID allocation. + /// Uses lock-free atomic increment for thread safety without blocking. + /// Initialized to 0; first ID returned will be 1 (0 reserved as "not registered" sentinel). + private let nextSessionIdAtomic = iTermAtomicInt64Create() + + deinit { + iTermAtomicInt64Free(nextSessionIdAtomic) + } + + // Access on mutation queue only + private var sessions: [SessionID: SessionState] = [:] + // Access on mutation queue only + private var busyQueue = BusyQueue() + + #if ITERM_DEBUG + // Access on mutation queue only + /// Test-only: Records session IDs in the order they executed, for verifying round-robin fairness. + private var _testExecutionHistory: [SessionID] = [] + #endif + + /// Coalesces multiple ensureExecutionScheduled() calls into a single async dispatch. + /// Called from any queue; dispatches work to mutation queue. + private let executionJoiner = IdempotentOperationJoiner.asyncJoiner(iTermGCD.mutationQueue()) + + private struct SessionState { + weak var executor: FairnessSchedulerExecutor? + var isExecuting: Bool = false + var workArrivedWhileExecuting: Bool = false + } + + /// Encapsulates the busy queue (round-robin order) with O(1) membership checks. + /// Invariant: set and list always contain the same session IDs (modulo lazy cleanup). + private struct BusyQueue { + private var list: [SessionID] = [] + private var set: Set = [] + + var isEmpty: Bool { list.isEmpty } + var count: Int { list.count } + + mutating func enqueue(_ id: SessionID) { + guard !set.contains(id) else { + it_fatalError("Session \(id) already in busy queue") + } + set.insert(id) + list.append(id) + } + + mutating func dequeue() -> SessionID? { + guard let id = list.first else { return nil } + list.removeFirst() + set.remove(id) + return id + } + + /// Remove from set only (list cleaned lazily during dequeue). + mutating func removeFromSet(_ id: SessionID) { + set.remove(id) + } + + func contains(_ id: SessionID) -> Bool { + set.contains(id) + } + + mutating func removeAll() { + list.removeAll() + set.removeAll() + } + } + + // MARK: - Registration + + /// Register an executor with the scheduler. Returns a stable session ID. + /// Thread-safe: may be called from any thread, including during joined blocks. + @objc func register(_ executor: FairnessSchedulerExecutor) -> SessionID { + // Allocate ID atomically (lock-free, instant) + let sessionId = SessionID(iTermAtomicInt64Add(nextSessionIdAtomic, 1)) + + // Session creation dispatches async to mutation queue. + // This avoids deadlock when called from joined blocks. + // Safe because callers set isRegistered after this returns, + // and schedule() also dispatches to mutation queue (ordering preserved). + iTermGCD.mutationQueue().async { + self.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 { + guard self.sessions[sessionId] != nil else { return } + self.sessions.removeValue(forKey: sessionId) + self.busyQueue.removeFromSet(sessionId) + } + } + + // 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) { + dispatchPrecondition(condition: .onQueue(iTermGCD.mutationQueue())) + guard var state = sessions[sessionId] else { return } + + if state.isExecuting { + state.workArrivedWhileExecuting = true + sessions[sessionId] = state + return + } + + if !busyQueue.contains(sessionId) { + busyQueue.enqueue(sessionId) + ensureExecutionScheduled() + } + } + + // MARK: - Execution + + /// Must be called on mutationQueue. + private func ensureExecutionScheduled() { + dispatchPrecondition(condition: .onQueue(iTermGCD.mutationQueue())) + guard !busyQueue.isEmpty else { return } + executionJoiner.setNeedsUpdate { [weak self] in + self?.executeNextTurn() + } + } + + /// Must be called on mutationQueue (via executionJoiner). + private func executeNextTurn() { + dispatchPrecondition(condition: .onQueue(iTermGCD.mutationQueue())) + guard let sessionId = busyQueue.dequeue() else { return } + + guard var state = sessions[sessionId], + let executor = state.executor else { + // Dead session - clean up and try next + sessions.removeValue(forKey: sessionId) + ensureExecutionScheduled() + return + } + + state.isExecuting = true + state.workArrivedWhileExecuting = false + sessions[sessionId] = state + + #if ITERM_DEBUG + _testExecutionHistory.append(sessionId) + #endif + + // Completion is called synchronously on mutationQueue (see protocol contract). + // We rely on this to avoid an extra async dispatch per turn. + executor.executeTurn(tokenBudget: Self.defaultTokenBudget) { [weak self] turnResult in + #if ITERM_DEBUG + dispatchPrecondition(condition: .onQueue(iTermGCD.mutationQueue())) + #endif + self?.sessionFinishedTurn(sessionId, result: turnResult) + } + } + + /// Must be called on mutationQueue. + private func sessionFinishedTurn(_ sessionId: SessionID, result: TurnResult) { + dispatchPrecondition(condition: .onQueue(iTermGCD.mutationQueue())) + guard var state = sessions[sessionId] else { + // Session was unregistered while its turn was executing. + // Still need to pump the scheduler so other sessions in busyQueue make progress. + ensureExecutionScheduled() + return + } + + state.isExecuting = false + let workArrived = state.workArrivedWhileExecuting + state.workArrivedWhileExecuting = false + + switch result { + case .completed: + if workArrived { + busyQueue.enqueue(sessionId) + } + case .yielded: + busyQueue.enqueue(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. + @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 busyQueue.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 busyQueue.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() { + // Reset ID counter atomically (set to 0, next ID will be 1) + _ = iTermAtomicInt64GetAndReset(nextSessionIdAtomic) + + // Reset all other state on mutation queue + iTermGCD.mutationQueue().sync { + sessions.removeAll() + busyQueue.removeAll() + _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/PTYSession.m b/sources/PTYSession.m index 572615215a..7eaf462c25 100644 --- a/sources/PTYSession.m +++ b/sources/PTYSession.m @@ -4039,6 +4039,17 @@ - (void)taskDidChangeTTY:(PTYTask *)task { // Main thread - (void)taskDidRegister:(PTYTask *)task { [self updateTTYSize]; + + // Wire up backpressure integration for dispatch sources (fairness scheduler only) + if ([iTermAdvancedSettingsModel useFairnessScheduler]) { + iTermTokenExecutor *executor = _screen.mutableState.tokenExecutor; + task.tokenExecutor = executor; + + __weak PTYTask *weakTask = task; + executor.backpressureReleaseHandler = ^{ + [weakTask updateReadSourceState]; + }; + } } - (void)tmuxDidDisconnect { diff --git a/sources/PTYTask.h b/sources/PTYTask.h index 1137f075a2..ba0051da92 100644 --- a/sources/PTYTask.h +++ b/sources/PTYTask.h @@ -21,7 +21,7 @@ @protocol PTYTaskDelegate // Runs in a background thread. Should do as much work as possible in this // thread before kicking off a possibly async task in the main thread. -- (void)threadedReadTask:(char *)buffer length:(int)length; +- (void)threadedReadTask:(char * _Nonnull)buffer length:(int)length; // Runs in the same background task as -threadedReadTask:length:. - (void)threadedTaskBrokenPipe; @@ -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; +// Any queue (atomic weak read; set on main queue by PTYSession.taskDidRegister:). +// TokenExecutor for backpressure monitoring. +// 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; + +// Any queue. Captures shouldRead snapshot, then dispatches to _ioQueue for source suspend/resume. +// Called when backpressure changes or other read-affecting state changes. +- (void)updateReadSourceState; + + (NSMutableDictionary *)mutableEnvironmentDictionary; - (instancetype)init; @@ -251,3 +262,62 @@ typedef NS_OPTIONS(NSUInteger, iTermJobManagerAttachResults) { queue:(dispatch_queue_t)queue; @end + +// 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 * _Nonnull)data; + +@end diff --git a/sources/PTYTask.m b/sources/PTYTask.m index 0a1719c39d..ac6514d698 100644 --- a/sources/PTYTask.m +++ b/sources/PTYTask.m @@ -72,6 +72,34 @@ @implementation PTYTask { dispatch_queue_t _jobManagerQueue; BOOL _isTmuxTask; + + // Dispatch sources for per-PTY I/O (fairness scheduler integration). + // Created on main queue in setupDispatchSources; nil'd on any queue in teardownDispatchSources. + // Handlers run on _ioQueue. + dispatch_source_t _readSource; + dispatch_source_t _writeSource; + // Created on main queue in setupDispatchSources, constant after setup. + dispatch_queue_t _ioQueue; + // Access on _ioQueue only (suspend/resume state tracking) + BOOL _readSourceSuspended; + // Access on _ioQueue only + BOOL _writeSourceSuspended; + + // Dispatch sources for coprocess I/O (fairness scheduler integration). + // Created/destroyed on main queue or _ioQueue; handlers run on _ioQueue. + dispatch_source_t _coprocessReadSource; // reads coprocess stdout + dispatch_source_t _coprocessWriteSource; // flushes outputBuffer to coprocess stdin + // Access on _ioQueue only + BOOL _coprocessReadSourceSuspended; + // Access on _ioQueue only + BOOL _coprocessWriteSourceSuspended; + + // Test hook to override shouldWrite for testing write source resume. Any queue. + BOOL _testShouldWriteOverride; + + // Test hook to override jobManager.ioAllowed for predicate tests. Any queue. + // nil = use real value, @YES = force true, @NO = force false + NSNumber *_testIoAllowedOverride; } - (instancetype)init { @@ -99,6 +127,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 +170,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]; } @@ -203,13 +236,25 @@ - (Coprocess *)coprocess { return nil; } -// This runs on the task notifier thread +// This runs on the task notifier thread (legacy) or main thread - (void)setCoprocess:(Coprocess *)coprocess { DLog(@"Set coprocess of %@ to %@", self, coprocess); + + // Tear down old coprocess dispatch sources before swapping the ivar. + if ([self useDispatchSource] && _coprocessReadSource) { + [self teardownCoprocessDispatchSources]; + } + @synchronized (self) { coprocess_ = coprocess; self.hasMuteCoprocess = coprocess_.mute; } + + // Set up dispatch sources for the new coprocess (if using dispatch source path). + if ([self useDispatchSource] && coprocess) { + [self setupCoprocessDispatchSources:coprocess]; + } + __weak __typeof(self) weakSelf = self; dispatch_async(dispatch_get_main_queue(), ^{ [weakSelf.delegate taskMuteCoprocessDidChange:self hasMuteCoprocess:self.hasMuteCoprocess]; @@ -368,8 +413,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 { @@ -397,6 +446,11 @@ - (void)brokenPipe { @synchronized(self) { brokenPipe_ = YES; } + // Stop dispatch sources immediately to prevent handlers firing on a dead fd. + // In fairness mode, tasks aren't in TaskNotifier._tasks so deregisterTask: + // won't stop I/O — this is the only teardown path. + // No-op in legacy mode (sources are nil). + [self teardownDispatchSources]; [[TaskNotifier sharedInstance] deregisterTask:self]; [self.delegate threadedTaskBrokenPipe]; } @@ -404,7 +458,14 @@ - (void)brokenPipe { // Main queue - (void)didRegister { DLog(@"didRegister %@", self); + // Wire up backpressure dependencies BEFORE starting dispatch sources. + // taskDidRegister: sets task.tokenExecutor so shouldRead can apply + // backpressure. Starting sources first would allow unconditional reads + // via the executor==nil fallback path in shouldRead. [self.delegate taskDidRegister:self]; + if ([iTermAdvancedSettingsModel useFairnessScheduler]) { + [self setupDispatchSources]; + } } // I did extensive benchmarking in May of 2025 when using the VT100_GANG optimization fully. @@ -478,7 +539,482 @@ - (void)processWrite { [writeLock unlock]; } +#pragma mark - Dispatch Source Management (Fairness Scheduler) + +// Main queue. 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]; +} + +// Any queue. Must resume suspended sources before canceling to avoid crash. +- (void)teardownDispatchSources { + // Also tear down coprocess sources — they share _ioQueue. + [self teardownCoprocessDispatchSources]; + + 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 + +// Any queue. Helper to get effective ioAllowed, considering test override. +- (BOOL)effectiveIoAllowed { + if (_testIoAllowedOverride != nil) { + return _testIoAllowedOverride.boolValue; + } + return self.jobManager.ioAllowed; +} + +// Any queue. Snapshot of whether reading should be enabled. +- (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; +} + +// Any queue. Snapshot of whether writing should be enabled. +- (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; +} + +// Any queue. Captures shouldRead snapshot, then dispatches to _ioQueue for source suspend/resume. +- (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; + } + }); +} + +// Any queue. Captures shouldWrite snapshot, then dispatches to _ioQueue for source suspend/resume. +- (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 + +// _ioQueue (dispatch source event handler) +- (void)handleReadEvent { + ITDebugAssert(strcmp(dispatch_queue_get_label(DISPATCH_CURRENT_QUEUE_LABEL), + dispatch_queue_get_label(_ioQueue)) == 0); + // Match processRead's batching: read up to 4KB (4 * MAXRW) per event + // to reduce dispatch overhead while maintaining responsiveness. + int iterations = 4; + int totalBytesRead = 0; + BOOL gotEOF = NO; + char buffer[MAXRW * iterations]; + + for (int i = 0; i < iterations; ++i) { + ssize_t n = read(self.fd, buffer + totalBytesRead, MAXRW); + if (n < 0) { + if (errno != EAGAIN && errno != EINTR) { + [self brokenPipe]; + return; + } + // EAGAIN/EINTR - stop reading but process what we have + break; + } + if (n == 0) { + // EOF - PTY slave side closed (child exited). + // Deliver any buffered bytes before signaling broken pipe. + gotEOF = YES; + break; + } + totalBytesRead += n; + if (n < MAXRW) { + // Got less than requested - no more data available + break; + } + } + + if (totalBytesRead > 0) { + hasOutput = YES; + + // Send data to delegate via non-blocking path + // (addTokens internally calls notifyScheduler which kicks FairnessScheduler) + [self.delegate threadedReadTask:buffer length:totalBytesRead]; + + // Route PTY output to coprocess (same as readTask:length:) + @synchronized (self) { + if (coprocess_ && !self.sshIntegrationActive) { + [self writeToCoprocess:[NSData dataWithBytes:buffer length:totalBytesRead]]; + } + } + + // Re-check state after read (backpressure may have increased) + [self updateReadSourceState]; + } + + if (gotEOF) { + [self brokenPipe]; + } +} + +// _ioQueue (dispatch source event handler) +- (void)handleWriteEvent { + ITDebugAssert(strcmp(dispatch_queue_get_label(DISPATCH_CURRENT_QUEUE_LABEL), + dispatch_queue_get_label(_ioQueue)) == 0); + [self processWrite]; // Existing method - drains writeBuffer + + // Re-check state after write (buffer may now be empty) + [self updateWriteSourceState]; + + // Write buffer shrank — coprocess read source may now be eligible to resume + // (it suspends when writeBufferHasRoom returns NO). + [self updateCoprocessReadSourceState]; +} + +// Any queue. Called when data is added to writeBuffer. +- (void)writeBufferDidChange { + [self updateWriteSourceState]; +} + +#pragma mark - Coprocess Dispatch Sources (Fairness Scheduler) + +// Main queue or _ioQueue (setCoprocess: can be called from either) +- (void)setupCoprocessDispatchSources:(Coprocess *)coprocess { + NSAssert(_ioQueue != nil, @"setupCoprocessDispatchSources called before _ioQueue created"); + + int readFd = [coprocess readFileDescriptor]; + int writeFd = [coprocess writeFileDescriptor]; + if (readFd < 0 || writeFd < 0) { + return; + } + + __weak typeof(self) weakSelf = self; + + // Read source — reads coprocess stdout, feeds data back as PTY input + _coprocessReadSource = dispatch_source_create(DISPATCH_SOURCE_TYPE_READ, + readFd, 0, _ioQueue); + dispatch_source_set_event_handler(_coprocessReadSource, ^{ + [weakSelf handleCoprocessReadEvent]; + }); + dispatch_resume(_coprocessReadSource); + dispatch_suspend(_coprocessReadSource); + _coprocessReadSourceSuspended = YES; + + // Write source — flushes outputBuffer to coprocess stdin + _coprocessWriteSource = dispatch_source_create(DISPATCH_SOURCE_TYPE_WRITE, + writeFd, 0, _ioQueue); + dispatch_source_set_event_handler(_coprocessWriteSource, ^{ + [weakSelf handleCoprocessWriteEvent]; + }); + dispatch_resume(_coprocessWriteSource); + dispatch_suspend(_coprocessWriteSource); + _coprocessWriteSourceSuspended = YES; + + [self updateCoprocessReadSourceState]; + [self updateCoprocessWriteSourceState]; +} + +// Any queue (handles both main and _ioQueue internally) +- (void)teardownCoprocessDispatchSources { + dispatch_source_t readSource = _coprocessReadSource; + dispatch_source_t writeSource = _coprocessWriteSource; + + _coprocessReadSource = nil; + _coprocessWriteSource = nil; + + if (!_ioQueue) { + return; + } + + // Check if we're already on ioQueue to avoid deadlock. + 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) { + if (readSource) { + if (_coprocessReadSourceSuspended) { + dispatch_resume(readSource); + } + dispatch_source_cancel(readSource); + } + if (writeSource) { + if (_coprocessWriteSourceSuspended) { + dispatch_resume(writeSource); + } + dispatch_source_cancel(writeSource); + } + _coprocessReadSourceSuspended = NO; + _coprocessWriteSourceSuspended = NO; + } else { + __block BOOL readSuspended = NO; + __block BOOL writeSuspended = NO; + dispatch_sync(_ioQueue, ^{ + readSuspended = self->_coprocessReadSourceSuspended; + writeSuspended = self->_coprocessWriteSourceSuspended; + }); + 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); + } + self->_coprocessReadSourceSuspended = NO; + self->_coprocessWriteSourceSuspended = NO; + }); + } +} + +#pragma mark - Coprocess Event Handlers + +// _ioQueue (dispatch source event handler) +- (void)handleCoprocessReadEvent { + ITDebugAssert(strcmp(dispatch_queue_get_label(DISPATCH_CURRENT_QUEUE_LABEL), + dispatch_queue_get_label(_ioQueue)) == 0); + Coprocess *coprocess; + @synchronized (self) { + coprocess = coprocess_; + } + if (!coprocess || [coprocess eof]) { + return; + } + + [coprocess read]; + + if ([coprocess eof]) { + [self handleCoprocessEOF]; + return; + } + + NSData *data = [coprocess.inputBuffer copy]; + [coprocess.inputBuffer setLength:0]; + if (data.length > 0) { + [self writeTask:data coprocess:YES]; + } + + [self updateCoprocessReadSourceState]; +} + +// _ioQueue (dispatch source event handler) +- (void)handleCoprocessWriteEvent { + ITDebugAssert(strcmp(dispatch_queue_get_label(DISPATCH_CURRENT_QUEUE_LABEL), + dispatch_queue_get_label(_ioQueue)) == 0); + Coprocess *coprocess; + @synchronized (self) { + coprocess = coprocess_; + } + if (!coprocess || [coprocess eof]) { + return; + } + + @synchronized (self) { + [coprocess write]; + } + + [self updateCoprocessWriteSourceState]; +} + +// _ioQueue +- (void)handleCoprocessEOF { + Coprocess *coprocess; + @synchronized (self) { + coprocess = coprocess_; + } + if (!coprocess) { + return; + } + + pid_t pid = coprocess.pid; + [coprocess terminate]; + + @synchronized (self) { + coprocess_ = nil; + self.hasMuteCoprocess = NO; + } + + [self teardownCoprocessDispatchSources]; + + if (pid > 0) { + [[TaskNotifier sharedInstance] waitForPid:pid]; + } + + __weak typeof(self) weakSelf = self; + dispatch_async(dispatch_get_main_queue(), ^{ + [weakSelf.delegate taskMuteCoprocessDidChange:weakSelf hasMuteCoprocess:NO]; + [[TaskNotifier sharedInstance] notifyCoprocessChange]; + }); +} + +#pragma mark - Coprocess Source State Management + +// Any queue. Captures coprocess state, then dispatches to _ioQueue for source suspend/resume. +- (void)updateCoprocessReadSourceState { + if (!_ioQueue || !_coprocessReadSource) { + return; + } + BOOL shouldResume; + @synchronized (self) { + shouldResume = coprocess_ && ![coprocess_ eof] && [self writeBufferHasRoom]; + } + dispatch_async(_ioQueue, ^{ + if (shouldResume && self->_coprocessReadSourceSuspended && self->_coprocessReadSource) { + dispatch_resume(self->_coprocessReadSource); + self->_coprocessReadSourceSuspended = NO; + } else if (!shouldResume && !self->_coprocessReadSourceSuspended && self->_coprocessReadSource) { + dispatch_suspend(self->_coprocessReadSource); + self->_coprocessReadSourceSuspended = YES; + } + }); +} + +// Any queue. Captures coprocess state, then dispatches to _ioQueue for source suspend/resume. +- (void)updateCoprocessWriteSourceState { + if (!_ioQueue || !_coprocessWriteSource) { + return; + } + BOOL shouldResume; + @synchronized (self) { + shouldResume = coprocess_ && [coprocess_ wantToWrite]; + } + dispatch_async(_ioQueue, ^{ + if (shouldResume && self->_coprocessWriteSourceSuspended && self->_coprocessWriteSource) { + dispatch_resume(self->_coprocessWriteSource); + self->_coprocessWriteSourceSuspended = NO; + } else if (!shouldResume && !self->_coprocessWriteSourceSuspended && self->_coprocessWriteSource) { + dispatch_suspend(self->_coprocessWriteSource); + self->_coprocessWriteSourceSuspended = YES; + } + }); +} + - (void)stopCoprocess { + [self teardownCoprocessDispatchSources]; + pid_t thePid = 0; @synchronized (self) { if (coprocess_.pid > 0) { @@ -872,6 +1408,13 @@ - (BOOL)wantsWrite { return self.jobManager.ioAllowed; } +// Any queue. 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 [iTermAdvancedSettingsModel useFairnessScheduler]; +} + - (BOOL)hasOutput { return hasOutput; } @@ -880,6 +1423,8 @@ - (void)writeToCoprocess:(NSData *)data { @synchronized (self) { [coprocess_.outputBuffer appendData:data]; } + // Wake the coprocess write source so the buffer gets drained. + [self updateCoprocessWriteSourceState]; } // The bytes in data were just read from the fd. @@ -942,3 +1487,84 @@ - (void)winSizeControllerSetGridSize:(VT100GridSize)gridSize } @end + +@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 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..47dc63f076 100644 --- a/sources/TaskNotifier.m +++ b/sources/TaskNotifier.m @@ -81,15 +81,26 @@ - (void)dealloc { } - (void)registerTask:(id)task { - PtyTaskDebugLog(@"registerTask: lock\n"); - [tasksLock lock]; - PtyTaskDebugLog(@"Add task at %p\n", (void*)task); - [_tasks addObject:task]; - PtyTaskDebugLog(@"There are now %lu tasks\n", (unsigned long)_tasks.count); - tasksChanged = YES; - PtyTaskDebugLog(@"registerTask: unlock\n"); - [tasksLock unlock]; - [self unblock]; + // Check if task uses dispatch_source for I/O (FairnessScheduler path). + // If so, skip adding to _tasks - the task handles its own I/O via dispatch sources. + // We still call didRegister to set up those dispatch sources. + BOOL usesDispatchSource = NO; + if ([task respondsToSelector:@selector(useDispatchSource)]) { + usesDispatchSource = [task useDispatchSource]; + } + + if (!usesDispatchSource) { + // Legacy path: add task to select() loop + PtyTaskDebugLog(@"registerTask: lock\n"); + [tasksLock lock]; + PtyTaskDebugLog(@"Add task at %p\n", (void*)task); + [_tasks addObject:task]; + PtyTaskDebugLog(@"There are now %lu tasks\n", (unsigned long)_tasks.count); + tasksChanged = YES; + PtyTaskDebugLog(@"registerTask: unlock\n"); + [tasksLock unlock]; + [self unblock]; + } __weak __typeof(task) weakTask = task; dispatch_async(dispatch_get_main_queue(), ^{ @@ -98,9 +109,18 @@ - (void)registerTask:(id)task { } - (void)deregisterTask:(id)task { + // Check if task uses dispatch_source (FairnessScheduler path). + // Such tasks were never added to _tasks, but we still need deadpool handling. + BOOL usesDispatchSource = NO; + if ([task respondsToSelector:@selector(useDispatchSource)]) { + usesDispatchSource = [task useDispatchSource]; + } + PtyTaskDebugLog(@"deregisterTask: lock\n"); [tasksLock lock]; PtyTaskDebugLog(@"Begin remove task %p\n", (void*)task); + + // Always handle deadpool - needed for waitpid() cleanup regardless of I/O path PtyTaskDebugLog(@"Add %d to deadpool", [task pid]); pid_t pidToWaitOn = task.pidToWaitOn; if (pidToWaitOn > 0) { @@ -109,14 +129,19 @@ - (void)deregisterTask:(id)task { if ([task hasCoprocess]) { [deadpool addObject:@([[task coprocess] pid])]; } - [_tasks removeObject:task]; - tasksChanged = YES; + + if (!usesDispatchSource) { + // Legacy path: remove from _tasks + [_tasks removeObject:task]; + tasksChanged = YES; + } + PtyTaskDebugLog(@"End remove task %p. There are now %lu tasks.\n", (void *)task, (unsigned long)[_tasks count]); PtyTaskDebugLog(@"deregisterTask: unlock\n"); [tasksLock unlock]; - [self unblock]; + [self unblock]; // Wake up run loop to process deadpool } // NB: This is currently used for coprocesses. @@ -291,19 +316,32 @@ - (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; - } - if ([task wantsRead]) { - FD_SET(fd, &rfds); + // 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 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 for legacy (non-dispatch-source) tasks only. + // Dispatch-source tasks handle their own coprocess I/O via PTYTask's + // coprocess dispatch sources (setupCoprocessDispatchSources:). @synchronized (task) { Coprocess *coprocess = [task coprocess]; if (coprocess) { @@ -382,16 +420,25 @@ - (void)run { [[task retain] autorelease]; [handledFds addObject:@(fd)]; - if ([self handleReadOnFileDescriptor:fd task:task fdSet:&rfds]) { - iter = [_tasks objectEnumerator]; - } - if ([self handleWriteOnFileDescriptor:fd task:task fdSet:&wfds]) { - 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 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 for legacy tasks (dispatch-source tasks handle their own) 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..1ec8f5c9ff 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 on mutation queue when last token is consumed (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,8 @@ class TokenArray: IteratorProtocol, CustomDebugStringConvertible { } defer { nextIndex += 1 - if nextIndex == count, let semaphore = semaphore { - semaphore.signal() - self.semaphore = nil + if nextIndex == count { + signalAndRelease() } } return (CVectorGetObject(&cvector, nextIndex) as! VT100Token) @@ -97,9 +100,8 @@ class TokenArray: IteratorProtocol, CustomDebugStringConvertible { // Returns whether there is another token. func consume() -> Bool { nextIndex += 1 - if nextIndex == count, let semaphore = semaphore { - semaphore.signal() - self.semaphore = nil + if nextIndex == count { + signalAndRelease() } return hasNext } @@ -110,17 +112,29 @@ class TokenArray: IteratorProtocol, CustomDebugStringConvertible { return } nextIndex = count - if let semaphore = semaphore { - semaphore.signal() - self.semaphore = nil - } + signalAndRelease() } private var dirty = true func didFinish() { - semaphore?.signal() + signalAndRelease() + } + + /// Signals the semaphore (if present) and invokes the slot-release callback. + /// The callback fires for all non-high-priority tokens regardless of semaphore: + /// - Legacy path: semaphore present, callback increments availableSlots + /// - Fairness path: no semaphore, callback still increments availableSlots + /// Captures the callback before niling to avoid footguns if the + /// callback assigns to onSemaphoreSignaled. + private func signalAndRelease() { + guard semaphore != nil || onSemaphoreSignaled != nil else { return } + let sem = semaphore + let closure = onSemaphoreSignaled semaphore = nil + onSemaphoreSignaled = nil + sem?.signal() + closure?() } func cleanup(asyncFree: Bool) { @@ -128,7 +142,7 @@ class TokenArray: IteratorProtocol, CustomDebugStringConvertible { return } dirty = false - semaphore?.signal() + signalAndRelease() if asyncFree { TokenArray.destroyQueue.async { [cvector] in CVectorReleaseObjectsAndDestroy(cvector) diff --git a/sources/TokenExecutor.swift b/sources/TokenExecutor.swift index be99bd788b..495d296f2e 100644 --- a/sources/TokenExecutor.swift +++ b/sources/TokenExecutor.swift @@ -94,6 +94,21 @@ 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 { @objc weak var delegate: TokenExecutorDelegate? { @@ -101,13 +116,85 @@ class TokenExecutor: NSObject { 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 + // Set at init, immutable after. Cached from iTermAdvancedSettingsModel. + private let useFairnessScheduler: Bool private static let isTokenExecutorSpecificKey = DispatchSpecificKey() private var onExecutorQueue: Bool { return DispatchQueue.getSpecific(key: Self.isTokenExecutorSpecificKey) == true } + + // Access on mutation queue only + /// 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 + } + } + + // Access on mutation queue only + /// Session ID assigned by FairnessScheduler during registration. + @objc var fairnessSessionId: UInt64 = 0 { + didSet { + impl.fairnessSessionId = fairnessSessionId + } + } + + // Access on mutation queue only + /// Whether this executor is registered with the FairnessScheduler. + /// Set explicitly at registration/unregistration points, not derived from sessionId. + @objc var isRegistered: Bool { + get { impl.isRegistered } + set { impl.isRegistered = newValue } + } + + /// Test hook: when true, notifyScheduler() becomes a no-op. + /// Allows unit tests to call executeTurn() directly without interference. + var testSkipNotifyScheduler: Bool { + get { impl.testSkipNotifyScheduler } + set { impl.testSkipNotifyScheduler = newValue } + } + + /// Test hook: Returns the number of queued token arrays. + /// Used to verify token preservation during cleanup and revive cycles. + @objc var testQueuedTokenCount: Int { + return impl.testQueuedTokenCount + } + + /// Test hook: Returns the total number of slots (buffer depth). + /// Used to verify accounting invariants in tests. + @objc var testTotalSlots: Int { + return totalSlots + } + + /// Test hook: Returns the current number of available slots. + /// Used to verify accounting correctness in tests. + @objc var testAvailableSlots: Int { + return Int(iTermAtomicInt64Get(availableSlots)) + } + + /// Test hook: Number of executeTurn calls that ran to completion. + /// Thread-safe via atomic counter. + @objc var testExecuteTurnCompletedCount: Int64 { + return impl.testExecuteTurnCompletedCount + } + + /// Test hook: Reset all test counters to zero for clean measurement. + @objc func testResetCounters() { + impl.testResetCounters() + } + @objc var isBackgroundSession = false { didSet { #if DEBUG @@ -131,11 +218,39 @@ class TokenExecutor: NSObject { slownessDetector: SlownessDetector, queue: DispatchQueue) { self.queue = queue + self.useFairnessScheduler = iTermAdvancedSettingsModel.useFairnessScheduler() queue.setSpecific(key: Self.isTokenExecutorSpecificKey, value: true) impl = TokenExecutorImpl(terminal, slownessDetector: slownessDetector, semaphore: semaphore, - queue: queue) + queue: queue, + useFairnessScheduler: useFairnessScheduler) + } + + // 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. @@ -155,12 +270,14 @@ class TokenExecutor: NSObject { }() // Flip this to true to measure how much time the TaskNotifier thread spends busy (reading, // parsing, and in select()) vs idle (blocked on TokenExecutor's semaphore). + // Only relevant for the legacy (non-fairness-scheduler) path. private let enableTimingStats = false // 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. + // Legacy path: blocks on semaphore when the queue of tokens gets too large. + // Fairness path: returns immediately; backpressure is handled by dispatch source suspend/resume. @objc func addTokens(_ vector: CVector, lengthTotal: Int, @@ -181,14 +298,33 @@ class TokenExecutor: NSObject { lengthExcludingInBandSignaling: lengthExcludingInBandSignaling, highPriority: highPriority, semaphore: nil) + // 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. + if useFairnessScheduler { + // Dispatch source model: PTYTask suspends the read source when + // backpressureLevel >= .heavy, so blocking here is unnecessary. + // Blocking _ioQueue would stall writes and coprocess I/O for this PTY. + iTermAtomicInt64Add(availableSlots, -1) + reallyAddTokens(vector, + lengthTotal: lengthTotal, + lengthExcludingInBandSignaling: lengthExcludingInBandSignaling, + highPriority: highPriority, + semaphore: nil) + queue.async { [weak self] in + self?.impl.didAddTokens() + } + return + } + // Legacy path: block on semaphore for backpressure to the TaskNotifier read thread. let semaphore = self.semaphore if enableTimingStats { TokenExecutor.addTokensTimingStats.recordEnd() } _ = semaphore.wait(timeout: .distantFuture) + // Track that we've consumed a slot for backpressure monitoring + iTermAtomicInt64Add(availableSlots, -1) if enableTimingStats { TokenExecutor.addTokensTimingStats.recordStart() } @@ -249,10 +385,28 @@ class TokenExecutor: NSObject { lengthExcludingInBandSignaling: Int, highPriority: Bool, semaphore: DispatchSemaphore?) { + // When a slot is released (token array consumed), increment the available slots counter + // and notify if backpressure has eased. This fires for all non-high-priority tokens + // regardless of whether a semaphore is used (legacy path) or not (fairness path). + // High-priority tokens bypass backpressure accounting entirely. + let onSemaphoreSignaled: (() -> Void)? + if !highPriority { + onSemaphoreSignaled = { [weak self, availableSlots] in + guard let self = self else { return } + let newValue = iTermAtomicInt64Add(availableSlots, 1) + // Notify when crossing out of heavy backpressure + if newValue > 0 && self.backpressureLevel < .heavy { + self.impl.backpressureReleaseHandler?() + } + } + } else { + onSemaphoreSignaled = nil + } let tokenArray = TokenArray(vector, lengthTotal: lengthTotal, lengthExcludingInBandSignaling: lengthExcludingInBandSignaling, - semaphore: semaphore) + semaphore: semaphore, + onSemaphoreSignaled: onSemaphoreSignaled) self.impl.addTokens(tokenArray, highPriority: highPriority) } @@ -309,26 +463,64 @@ class TokenExecutor: NSObject { } } +// MARK: - FairnessSchedulerExecutor + +extension TokenExecutor: FairnessSchedulerExecutor { + // Mutation queue only + /// Execute tokens up to the given budget. Calls completion with result. + @objc + func executeTurn(tokenBudget: Int, completion: @escaping (TurnResult) -> Void) { + dispatchPrecondition(condition: .onQueue(iTermGCD.mutationQueue())) + if gDebugLogging.boolValue { DLog("executeTurn(tokenBudget: \(tokenBudget))") } + impl.executeTurn(tokenBudget: tokenBudget, completion: completion) + } + +} + private class TokenExecutorImpl { static let didUnpauseGloballyNotification = Notification.Name("didUnpauseGloballyNotification") private let terminal: VT100Terminal private let queue: DispatchQueue private let slownessDetector: SlownessDetector private let semaphore: DispatchSemaphore + // Set at init, immutable after. Cached from iTermAdvancedSettingsModel. + private let useFairnessScheduler: Bool + // Thread-safe: iTermTaskQueue uses internal locking private var taskQueue = iTermTaskQueue() + // Thread-safe: iTermTaskQueue uses internal locking private var sideEffects = iTermTaskQueue() + // Access on mutation queue only private let tokenQueue = TwoTierTokenQueue() + // Thread-safe: atomic operations private var pauseCount = iTermAtomicInt64Create() + // Access on mutation queue only private var executingCount = 0 + // Thread-safe: MutableAtomicObject private let executingSideEffects = MutableAtomicObject(false) + // Initialized at init; period modified on mutation queue, markNeedsUpdate from any queue private var sideEffectScheduler: PeriodicScheduler! = nil + // Thread-safe: addByteCount from any queue, estimatedThroughput read on mutation queue private let throughputEstimator = iTermThroughputEstimator(historyOfDuration: 5.0 / 30.0, secondsPerBucket: 1.0 / 30.0) + // Access on mutation queue only private var commit = true // Access on mutation queue only private(set) var isExecutingToken = false weak var delegate: TokenExecutorDelegate? + // Access on mutation queue only + /// Closure called when backpressure transitions from heavy to lighter. + var backpressureReleaseHandler: (() -> Void)? + + // Access on mutation queue only + /// Session ID assigned by FairnessScheduler during registration. + var fairnessSessionId: UInt64 = 0 + + // Access on mutation queue only + /// Whether this executor is registered with the FairnessScheduler. + /// Separate from fairnessSessionId because the ID is an identifier, not a state flag. + var isRegistered: Bool = false + // 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()) @@ -339,7 +531,8 @@ private class TokenExecutorImpl { #endif if isBackgroundSession != oldValue { sideEffectScheduler.period = isBackgroundSession ? 1.0 : 1.0 / 30.0 - if isBackgroundSession { + // Legacy prioritization tracking - not needed with FairnessScheduler + if !useFairnessScheduler && isBackgroundSession { Self.activeSessionsWithTokens.mutableAccess { set in set.remove(ObjectIdentifier(self)) } @@ -351,11 +544,13 @@ private class TokenExecutorImpl { init(_ terminal: VT100Terminal, slownessDetector: SlownessDetector, semaphore: DispatchSemaphore, - queue: DispatchQueue) { + queue: DispatchQueue, + useFairnessScheduler: Bool) { self.terminal = terminal self.queue = queue self.slownessDetector = slownessDetector self.semaphore = semaphore + self.useFairnessScheduler = useFairnessScheduler sideEffectScheduler = PeriodicScheduler(DispatchQueue.main, period: 1 / 30.0, action: { [weak self] in guard let self = self else { return @@ -381,8 +576,11 @@ private class TokenExecutorImpl { } deinit { - Self.activeSessionsWithTokens.mutableAccess { set in - set.remove(ObjectIdentifier(self)) + // Legacy prioritization tracking - not needed with FairnessScheduler + if !useFairnessScheduler { + Self.activeSessionsWithTokens.mutableAccess { set in + set.remove(ObjectIdentifier(self)) + } } } @@ -415,7 +613,8 @@ private class TokenExecutorImpl { func addTokens(_ tokenArray: TokenArray, highPriority: Bool) { throughputEstimator.addByteCount(tokenArray.lengthTotal) tokenQueue.addTokens(tokenArray, highPriority: highPriority) - if !isBackgroundSession { + // Legacy prioritization tracking - not needed with FairnessScheduler + if !useFairnessScheduler && !isBackgroundSession { Self.activeSessionsWithTokens.mutableAccess { set in set.insert(ObjectIdentifier(self)) } @@ -423,14 +622,169 @@ private class TokenExecutorImpl { } func didAddTokens() { - execute() + notifyScheduler() + } + + // MARK: - Scheduler Notification + + /// Test hook: when true, notifyScheduler() becomes a no-op. + /// Allows unit tests to call executeTurn() directly without interference. + var testSkipNotifyScheduler = false + + /// Test hook: Returns the number of queued token arrays. + /// Used to verify token preservation during cleanup and revive cycles. + var testQueuedTokenCount: Int { + return tokenQueue.count + } + + /// Test hook: Number of executeTurn calls that ran to completion. + private var _testExecuteTurnCompletedCount = iTermAtomicInt64Create() + var testExecuteTurnCompletedCount: Int64 { + return iTermAtomicInt64Get(_testExecuteTurnCompletedCount) + } + + /// Test hook: Reset all test counters to zero. + func testResetCounters() { + _ = iTermAtomicInt64GetAndReset(_testExecuteTurnCompletedCount) + } + + /// Notify the FairnessScheduler that this session has work, or execute directly if scheduler disabled. + /// Must be called on mutation queue. + func notifyScheduler() { + DLog("notifyScheduler(sessionId: \(fairnessSessionId), isRegistered: \(isRegistered))") +#if DEBUG + assertQueue() +#endif + if testSkipNotifyScheduler { return } + if useFairnessScheduler { + guard isRegistered else { + // Not yet registered - tokens accumulate, processed on registration + return + } + FairnessScheduler.shared.sessionDidEnqueueWork(fairnessSessionId) + } else { + // Legacy behavior (will be removed when fairness scheduler becomes default) + execute() + } } // You can call this on any queue. func schedule() { queue.async { [weak self] in - self?.execute() + self?.notifyScheduler() + } + } + + // MARK: - FairnessSchedulerExecutor Support + + /// Execute tokens up to the given budget. Called by FairnessScheduler. + /// Must be called on mutation queue. + func executeTurn(tokenBudget: Int, completion: @escaping (TurnResult) -> Void) { + DLog("executeTurn(tokenBudget: \(tokenBudget))") +#if DEBUG + assertQueue() +#endif + + var turnResult: TurnResult = .completed + + execution: do { + executingCount += 1 + defer { + executingCount -= 1 + executeHighPriorityTasks() + } + + executeHighPriorityTasks() + + guard let delegate = delegate else { + // Only discard tokens if we're still registered. When unregistered, + // tokens are preserved so they can drain on revive (re-registration). + // A nil delegate with isRegistered==false means setTerminalEnabled:NO + // already ran; discarding here would race with the async unregister. + if isRegistered { + tokenQueue.removeAll() + } + turnResult = .completed + break execution + } + + // Check if we're blocked (paused, copy mode, etc.) - after high-priority tasks + if delegate.tokenExecutorShouldQueueTokens() { + turnResult = .blocked + break execution + } + + let hadTokens = !tokenQueue.isEmpty + var tokensConsumed = 0 + var groupsExecuted = 0 + var accumulatedLength = ByteExecutionStats() + + slownessDetector.measure(event: PTYSessionSlownessEventExecute) { + if gDebugLogging.boolValue { + DLog("Will enumerate token arrays") + } + 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 + } + + if gDebugLogging.boolValue { + DLog("Begin executing a batch of tokens of sizes \(group.arrays.map(\.numberRemaining))") + } + defer { + if gDebugLogging.boolValue { + DLog("Done executing a batch of tokens. Vector has \(group.arrays.map(\.numberRemaining)) remaining.") + } + } + + // 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 + + return shouldContinue && !self.isPaused + } + + // Legacy prioritization tracking - not needed with FairnessScheduler + if !useFairnessScheduler && !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")") + } + } + + if accumulatedLength.total > 0 || hadTokens { + delegate.tokenExecutorDidExecute(lengthTotal: accumulatedLength.total, + lengthExcludingInBandSignaling: accumulatedLength.excludingInBandSignaling, + throughput: throughputEstimator.estimatedThroughput) + } + + // Report back to scheduler + let hasMoreWork = !tokenQueue.isEmpty || taskQueue.count > 0 + turnResult = hasMoreWork ? .yielded : .completed } + + // defer has fired — high-priority tasks completed before completion callback + iTermAtomicInt64Add(_testExecuteTurnCompletedCount, 1) + completion(turnResult) } // Any queue @@ -441,9 +795,11 @@ private class TokenExecutorImpl { assertQueue() #endif if executingCount == 0 { - execute() + notifyScheduler() return } + // Already executing - task will be picked up in current turn + return } schedule() } @@ -601,7 +957,8 @@ private class TokenExecutorImpl { accumulatedLength: &accumulatedLength, delegate: delegate) } - if !isBackgroundSession && tokenQueue.isEmpty { + // Legacy prioritization tracking - not needed with FairnessScheduler + if !useFairnessScheduler && !isBackgroundSession && tokenQueue.isEmpty { DLog("Active session completely drained") Self.activeSessionsWithTokens.mutableAccess { set in set.remove(ObjectIdentifier(self)) @@ -658,9 +1015,9 @@ 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. + // Legacy prioritization: yield to visible session. Not needed with FairnessScheduler + // since fair round-robin scheduling already handles this. + if !useFairnessScheduler && isBackgroundSession && !Self.activeSessionsWithTokens.value.isEmpty { DLog("Stop processing early because active session has tokens") return false } diff --git a/sources/TwoTierTokenQueue.swift b/sources/TwoTierTokenQueue.swift index 228e500bce..2fe7744f4e 100644 --- a/sources/TwoTierTokenQueue.swift +++ b/sources/TwoTierTokenQueue.swift @@ -132,6 +132,12 @@ class TwoTierTokenQueue { } } + var count: Int { + return queues.reduce(0) { total, queue in + total + queue.count + } + } + func addTokens(_ tokenArray: TokenArray, highPriority: Bool) { if gDebugLogging.boolValue { DLog("add \(tokenArray.count) tokens, highpri=\(highPriority)") @@ -221,6 +227,12 @@ fileprivate class Queue: CustomDebugStringConvertible { } } + var count: Int { + mutex.sync { + return arrays.count + } + } + var totalNumberRemaining: Int { mutex.sync { return arrays.map { Int($0.numberRemaining) }.reduce(0, +) diff --git a/sources/VT100ScreenMutableState.m b/sources/VT100ScreenMutableState.m index 78dfea9f68..7fadc26a77 100644 --- a/sources/VT100ScreenMutableState.m +++ b/sources/VT100ScreenMutableState.m @@ -70,6 +70,8 @@ @implementation VT100ScreenMutableState { BOOL _runSideEffectAfterTopJoinFinishes; NSMutableArray *_postTriggerActions; void (^_nextPromptBlock)(void); + // Access on mutation queue only + uint64_t _fairnessSessionId; } // performingJoinedBlock is now centralized in iTermGCD. @@ -122,7 +124,9 @@ - (instancetype)initWithSideEffectPerformer:(id _tokenExecutor = [[iTermTokenExecutor alloc] initWithTerminal:_terminal slownessDetector:_triggerEvaluator.triggersSlownessDetector queue:_queue]; - _tokenExecutor.delegate = self; + // Note: delegate and FairnessScheduler registration happen in setTerminalEnabled:YES + // to maintain symmetry with setTerminalEnabled:NO (which does unregistration). + _echoProbe = [[iTermEchoProbe alloc] initWithQueue:_queue]; _echoProbe.delegate = self; self.unconditionalTemporaryDoubleBuffer.delegate = self; @@ -208,7 +212,28 @@ - (void)setTerminalEnabled:(BOOL)enabled { _commandRangeChangeJoiner = [iTermIdempotentOperationJoiner joinerWithScheduler:_tokenExecutor]; _terminal.delegate = self; _tokenExecutor.delegate = self; + + // Register with FairnessScheduler for round-robin token execution (if enabled). + // This is symmetric with unregistration in the enabled=NO branch. + // On revive, this re-registers and gets a fresh sessionId. + if ([iTermAdvancedSettingsModel useFairnessScheduler]) { + _fairnessSessionId = [iTermFairnessScheduler.shared register:_tokenExecutor]; + _tokenExecutor.fairnessSessionId = _fairnessSessionId; + _tokenExecutor.isRegistered = YES; + // Notify scheduler of any preserved tokens from before disable. + // If no tokens are queued, this is a no-op. + [_tokenExecutor schedule]; + } } else { + // Unregister from FairnessScheduler BEFORE clearing delegate. + // Tokens are preserved (not discarded) to support session revive. + // They drain naturally when re-enabled. + if (_fairnessSessionId != 0) { + [iTermFairnessScheduler.shared unregisterWithSessionId:_fairnessSessionId]; + _fairnessSessionId = 0; + _tokenExecutor.isRegistered = NO; + } + [_commandRangeChangeJoiner invalidate]; _commandRangeChangeJoiner = nil; _tokenExecutor.delegate = nil; diff --git a/sources/iTerm2SharedARC-Bridging-Header.h b/sources/iTerm2SharedARC-Bridging-Header.h index 64ffad114f..3668eb19ad 100644 --- a/sources/iTerm2SharedARC-Bridging-Header.h +++ b/sources/iTerm2SharedARC-Bridging-Header.h @@ -200,6 +200,7 @@ #import "SessionView.h" #import "SessionView+Nullability.h" #import "SolidColorView.h" +#import "Coprocess.h" #import "TaskNotifier.h" #import "TemporaryNumberAllocator.h" #import "ToastWindowController.h" diff --git a/sources/iTermAdvancedSettingsModel.h b/sources/iTermAdvancedSettingsModel.h index 4436cc17e5..14c7d7af10 100644 --- a/sources/iTermAdvancedSettingsModel.h +++ b/sources/iTermAdvancedSettingsModel.h @@ -493,6 +493,10 @@ extern NSString *const iTermAdvancedSettingsDidChange; + (BOOL)useColorfgbgFallback; + (BOOL)useDivorcedProfileToSplit; + (BOOL)useExperimentalFontMetrics; ++ (BOOL)useFairnessScheduler; +#if DEBUG ++ (void)setUseFairnessSchedulerForTesting:(BOOL)value; +#endif + (BOOL)useGCDUpdateTimer; #if ENABLE_LOW_POWER_GPU_DETECTION diff --git a/sources/iTermAdvancedSettingsModel.m b/sources/iTermAdvancedSettingsModel.m index ad9d9fac50..66a2739304 100644 --- a/sources/iTermAdvancedSettingsModel.m +++ b/sources/iTermAdvancedSettingsModel.m @@ -444,6 +444,14 @@ + (BOOL)settingIsDeprecated:(NSString *)name { #define SECTION_GENERAL @"General: " +DEFINE_BOOL(useFairnessScheduler, NO, SECTION_GENERAL @"Use round-robin fair scheduling for terminal output.\nWhen enabled, all sessions get equal CPU time for token processing. Requires restart."); + +#if DEBUG ++ (void)setUseFairnessSchedulerForTesting:(BOOL)value { + sAdvancedSetting_useFairnessScheduler = @(value); +} +#endif + DEFINE_SETTABLE_STRING(searchCommand, SearchCommand, @"https://google.com/search?q=%@", SECTION_GENERAL @"Template for URL of search engine.\niTerm2 replaces the string “%@” with the text to search for. Query parameter percent escaping is used."); DEFINE_SETTABLE_STRING(searchSuggestURL, SearchSuggestURL, @"https://suggestqueries.google.com/complete/search?client=firefox&q=%@", SECTION_GENERAL @"Template of URL for typeahead search suggestions.\niTerm replaces the string “%@” with the text to search for. Query parameter percent escaping is used."); DEFINE_INT(autocompleteMaxOptions, 20, SECTION_GENERAL @"Number of autocomplete options to present.\nA value less than 100 is recommended."); diff --git a/tools/run_fairness_tests.sh b/tools/run_fairness_tests.sh new file mode 100755 index 0000000000..24df53cfde --- /dev/null +++ b/tools/run_fairness_tests.sh @@ -0,0 +1,320 @@ +#!/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" + "FairnessSchedulerSessionRestorationTests" +) + +# Milestone 2: TokenExecutor Fairness (Checkpoint 2) +TOKENEXECUTOR_TEST_CLASSES=( + "TokenExecutorNonBlockingTests" + "TokenExecutorAccountingTests" + "TokenExecutorExecuteTurnTests" + "TokenExecutorBudgetEdgeCaseTests" + "TokenExecutorSchedulerEntryPointTests" + "TokenExecutorLegacyRemovalTests" + "TokenExecutorAccountingInvariantTests" + "TokenExecutorCompletionCallbackTests" + "TokenExecutorBudgetEnforcementDetailedTests" + "TokenExecutorSameQueueGroupBoundaryTests" + "TokenExecutorAvailableSlotsBoundaryTests" + "TokenExecutorHighPriorityOrderingTests" + "TokenExecutorFeatureFlagGatingTests" + "TokenExecutorDeferCompletionOrderingTests" + "TokenExecutorSessionReviveTests" + "TwoTierTokenQueueGroupingTests" +) + +# Milestone 3: PTYTask Dispatch Sources (Checkpoint 3) +PTYTASK_TEST_CLASSES=( + "PTYTaskDispatchSourceLifecycleTests" + "PTYTaskReadStateTests" + "PTYTaskWriteStateTests" + "PTYTaskEventHandlerTests" + "PTYTaskPauseStateTests" + "PTYTaskIoAllowedPredicateTests" + "PTYTaskBackpressureIntegrationTests" + "PTYTaskUseDispatchSourceTests" + "PTYTaskStateTransitionTests" + "PTYTaskEdgeCaseTests" + "PTYTaskReadHandlerPipelineTests" + "PTYTaskWritePathRoundTripTests" + "PTYTaskEOFTests" + "PTYTaskBrokenPipeSourceTeardownTests" +) + +# 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..." +echo "Note: Nominal runtime is <15s. If tests last longer, something is probably wrong." +if [[ -n "$FILTER" ]]; then + echo "Filter: $FILTER" +fi +echo "" + +# Use timestamped result bundle to avoid conflicts with stale results +RESULT_BUNDLE="TestResults/FairnessSchedulerTests-$(date +%Y%m%d-%H%M%S).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 "$RESULT_BUNDLE" \ + $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 "$RESULT_BUNDLE" \ + $SIGNING_FLAGS \ + 2>&1 | tee "$TEST_OUTPUT" | grep -E "(Test Case|passed|failed|error:|\*\*)" + XCODE_EXIT=${PIPESTATUS[0]} +fi + +echo "" + +# Check for missing test classes (graceful warning instead of failure) +MISSING_CLASSES=$(grep -E "Unable to find|Skipping .* no tests" "$TEST_OUTPUT" 2>/dev/null || true) +if [[ -n "$MISSING_CLASSES" ]]; then + echo "==========================================" + echo "WARNING: Some test classes not found (may be from future milestones)" + echo "==========================================" + echo "$MISSING_CLASSES" | head -10 + echo "==========================================" + echo "" + # Don't treat missing classes as failure if some tests actually ran + if grep -q "Test Case.*passed\|Test Case.*failed" "$TEST_OUTPUT" 2>/dev/null; then + # Some tests ran - check if only the missing class issue caused the failure + ACTUAL_FAILURES=$(grep -c "Test Case.*failed" "$TEST_OUTPUT" 2>/dev/null || echo "0") + if [[ "$ACTUAL_FAILURES" == "0" ]]; then + echo "Note: All existing tests passed. Exit code reset to success." + XCODE_EXIT=0 + fi + fi +fi + +# 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