Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
82fe50d
This brings the new Ghostty-based IO.
migueldeicaza Jul 8, 2026
222400c
Add terminal lock infrastructure with full view-layer coverage
migueldeicaza Jul 8, 2026
282a7bd
Marshal terminal delegate callbacks off the parse path
migueldeicaza Jul 9, 2026
d06bf29
Parse terminal output on the IO thread
migueldeicaza Jul 9, 2026
1a910e4
Merge branch 'main' into new-io
migueldeicaza Aug 9, 2026
f053afb
Fix display invalidation after IO merge
migueldeicaza Aug 9, 2026
2300af1
Merge remote-tracking branch 'origin/main' into new-io
migueldeicaza Aug 10, 2026
5479986
Merge branch 'main' into new-io
migueldeicaza Aug 10, 2026
8a97d39
Run terminal state checks under the terminal lock
migueldeicaza Aug 10, 2026
03e64e1
Fix lock-contract violations in code merged from main
migueldeicaza Aug 10, 2026
59b3017
Render the CoreGraphics path from terminal snapshots (Stage B WO-B1)
migueldeicaza Aug 11, 2026
23aa7ef
Render the Metal path from terminal snapshots (Stage B WO-B2)
migueldeicaza Aug 11, 2026
970eeb4
Drive frames from a display link; retire the legacy schedulers (Stage…
migueldeicaza Aug 11, 2026
b55eb75
Prevent terminal lock starvation during continuous parsing
migueldeicaza Aug 11, 2026
a965c75
Add IO profiling and baseline diagnostics
migueldeicaza Aug 11, 2026
34402b6
Prevent bell floods and add IO performance diagnostics
migueldeicaza Aug 11, 2026
a3293df
Bell alignment with Ghostty
migueldeicaza Aug 11, 2026
9cf9e94
The binary case, end to end
migueldeicaza Aug 11, 2026
c0eb347
Avoid a double Metal render.
migueldeicaza Aug 11, 2026
c1e9763
Fix the attribute cache.
migueldeicaza Aug 11, 2026
5d647b7
WIP: structural work to move some functionality to a rendered-owned s…
migueldeicaza Aug 11, 2026
892594c
1. MetalRenderTarget — the eight things the renderer actually needs f…
migueldeicaza Aug 11, 2026
54b9e35
Testing hooks
migueldeicaza Aug 11, 2026
38772bd
Add a render loop for the Metal layer surface
migueldeicaza Aug 11, 2026
0e88ce9
Move macOS Metal layer rendering off the main thread
migueldeicaza Aug 11, 2026
534f03b
We no longer render when we are occluded.
migueldeicaza Aug 11, 2026
10c584f
Run synchronized-output timeout off the main queue
migueldeicaza Aug 11, 2026
38d688f
Allow terminal input from transport threads
migueldeicaza Aug 12, 2026
b50e3b6
Coalesce terminal resizes during live macOS window drags
migueldeicaza Aug 12, 2026
d84ddd2
Update docs, provide additional api for tecolot
migueldeicaza Aug 12, 2026
6b51164
tests
migueldeicaza Aug 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/xcode.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,15 @@ jobs:
swift test --enable-code-coverage
BINDIR=$(swift build --show-bin-path)
xcrun llvm-cov report "$BINDIR/SwiftTermPackageTests.xctest/Contents/MacOS/SwiftTermPackageTests" --instr-profile=.build/debug/codecov/default.profdata -use-color

thread-sanitizer:

runs-on: macos-15

steps:
- uses: actions/checkout@v4

- name: Stage A Thread Sanitizer Tests
env:
SWIFT_TEST_DISABLE_PARALLELIZATION: "1"
run: swift test --sanitize=thread --filter "TerminalLocking|SynchronizedOutput|TerminalIOPipeline"
49 changes: 49 additions & 0 deletions Docs/prototypes/futex-ticket-lock/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# TerminalLock alternatives: prototypes and measurements

Reference material for `io-gaps.md` section G3. **Nothing here is part of the
package build** — `Package.swift` does not reference this directory. It is kept
so that a future revisit of `TerminalLock` is a lookup and not a new
investigation.

Outcome: **no change to `TerminalLock`**. The current `NSCondition` ticket lock
won. Details and the reasoning are in `io-gaps.md` G3, G3.1 and G3.2.

## `noshim.swift` — the pure-Swift alternative (G3.2)

Compares the current `NSCondition` ticket lock with a targeted-wake ticket lock
built on `DispatchSemaphore`, which needs no C code.

swiftc -O -o noshim noshim.swift && ./noshim

Measured on an M-series machine: the semaphore version is about 7 ns per
acquisition **slower**, with contended figures inside the noise band. Rejected.

## `shim.c` / `shim.h` / `main.swift` — the futex approach (G3.1)

A ticket lock over `os_sync_wait_on_address`, which is what Ghostty's fairness
handoff uses (through Zig's `std.Thread.Futex`).

swiftc -O -o probe main.swift shim.c \
-Xcc -fmodule-map-file=module.modulemap -import-objc-header shim.h
./probe

Findings:

- `os_sync_wait_on_address` is **public** API (`__API_AVAILABLE(macos(14.4),
ios(17.4), ...)`) but is **not** in `os.modulemap`, so Swift cannot see it.
A C shim is mandatory, which is why this approach is deferred.
- The shim compiles at a macOS 11 deployment floor using `__builtin_available`
plus a weak-import null check.
- Measured 18.2 ns per uncontended acquisition versus 27.8 ns for the current
lock in the same run — real, but worth about 9 microseconds per second at
SwiftTerm's acquisition rate.
- **Trap**: the first version called `os_sync_wake_by_address_all`
unconditionally in `unlock`, making every release a syscall. That measured
128.5 ns per acquire, five times worse than the current lock. Gating the wake
on an atomic waiter count is what makes this design work at all.

## Reading the numbers

Absolute figures drift between runs on the same machine (the current lock
measured 27.8, 30.7 and 35.5 ns across three runs). Only compare values
produced inside a single run.
140 changes: 140 additions & 0 deletions Docs/prototypes/futex-ticket-lock/main.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import Foundation

final class FutexTicketLock {
private let storage = UnsafeMutablePointer<UInt32>.allocate(capacity: 3)
private var next: UnsafeMutablePointer<UInt32> { storage }
private var serving: UnsafeMutablePointer<UInt32> { storage + 1 }
private var waiters: UnsafeMutablePointer<UInt32> { storage + 2 }
private var owner: ObjectIdentifier?

init() { storage[0] = 0; storage[1] = 0; storage[2] = 0 }
deinit { storage.deallocate() }

func lock() {
let ticket = swiftterm_atomic_fetch_add(next, 1)
while true {
let now = swiftterm_atomic_load_acquire(serving)
if now == ticket { break }
_ = swiftterm_atomic_fetch_add(waiters, 1)
let r = swiftterm_sync_wait(serving, now)
_ = swiftterm_atomic_fetch_add(waiters, UInt32(bitPattern: -1))
if r < 0 && errno != EINTR && errno != EFAULT && errno != EAGAIN && errno != ENOMEM {
// Unexpected: spin rather than deadlock (fallback latch in real code)
}
}
owner = ObjectIdentifier(Thread.current)
}

func unlock() {
owner = nil
let now = swiftterm_atomic_load_acquire(serving)
swiftterm_atomic_store_release(serving, now &+ 1)
if swiftterm_atomic_load_acquire(waiters) > 0 {
_ = swiftterm_sync_wake_all(serving)
}
}
}

print("os_sync available: \(swiftterm_sync_available())")

// 1. Correctness: N threads incrementing a shared counter under the lock.
let lk = FutexTicketLock()
var counter = 0
let group = DispatchGroup()
for _ in 0..<8 {
DispatchQueue.global().async(group: group) {
for _ in 0..<50_000 { lk.lock(); counter += 1; lk.unlock() }
}
}
group.wait()
print("counter = \(counter) (expect 400000)")

// 2. Starvation: one thread in a tight lock loop, another measuring worst-case wait.
let lk2 = FutexTicketLock()
var stop = false
let hog = Thread {
while !stop { lk2.lock(); var s = 0; for i in 0..<2000 { s &+= i }; _ = s; lk2.unlock() }
}
hog.qualityOfService = .userInitiated
hog.start()
Thread.sleep(forTimeInterval: 0.05)
var worstNs: UInt64 = 0
var totalNs: UInt64 = 0
let samples = 2000
for _ in 0..<samples {
let t0 = DispatchTime.now().uptimeNanoseconds
lk2.lock()
let t1 = DispatchTime.now().uptimeNanoseconds
lk2.unlock()
worstNs = max(worstNs, t1 - t0)
totalNs += t1 - t0
}
stop = true
print(String(format: "contended wait: mean %.1f us, worst %.1f us", Double(totalNs)/Double(samples)/1000.0, Double(worstNs)/1000.0))

// 3. Uncontended cost.
let lk3 = FutexTicketLock()
let iters = 2_000_000
let s0 = DispatchTime.now().uptimeNanoseconds
for _ in 0..<iters { lk3.lock(); lk3.unlock() }
let s1 = DispatchTime.now().uptimeNanoseconds
print(String(format: "futex uncontended: %.1f ns/acquire", Double(s1-s0)/Double(iters)))

// 4. Compare with the current NSCondition ticket lock.
final class ConditionTicketLock {
private let condition = NSCondition()
private var nextTicket: UInt64 = 0
private var nowServing: UInt64 = 0
private var waiters = 0
private var owner: ObjectIdentifier?
func lock() {
let current = ObjectIdentifier(Thread.current)
condition.lock()
let ticket = nextTicket; nextTicket &+= 1
if ticket != nowServing {
waiters += 1
repeat { condition.wait() } while ticket != nowServing
waiters -= 1
}
owner = current
condition.unlock()
}
func unlock() {
condition.lock()
owner = nil
nowServing &+= 1
if waiters > 0 { condition.broadcast() }
condition.unlock()
}
}
let nc = ConditionTicketLock()
let n0 = DispatchTime.now().uptimeNanoseconds
for _ in 0..<iters { nc.lock(); nc.unlock() }
let n1 = DispatchTime.now().uptimeNanoseconds
print(String(format: "NSCondition uncontended: %.1f ns/acquire", Double(n1-n0)/Double(iters)))


// 5. Contended comparison for the NSCondition lock (same shape as case 2).
let nc2 = ConditionTicketLock()
var stop2 = false
let hog2 = Thread {
while !stop2 { nc2.lock(); var s = 0; for i in 0..<2000 { s &+= i }; _ = s; nc2.unlock() }
}
hog2.qualityOfService = .userInitiated
hog2.start()
Thread.sleep(forTimeInterval: 0.05)
var worst2: UInt64 = 0
var total2: UInt64 = 0
for _ in 0..<samples {
let t0 = DispatchTime.now().uptimeNanoseconds
nc2.lock()
let t1 = DispatchTime.now().uptimeNanoseconds
nc2.unlock()
worst2 = max(worst2, t1 - t0)
total2 += t1 - t0
}
stop2 = true
print(String(format: "NSCondition contended wait: mean %.1f us, worst %.1f us", Double(total2)/Double(samples)/1000.0, Double(worst2)/1000.0))

// 6. Put it in context: cost per 64 KiB batch at realistic rates.
print(String(format: "at 1000 acquisitions/sec, 9 ns saved per acquire = %.1f us/sec", 1000.0 * 9.0 / 1000.0))
1 change: 1 addition & 0 deletions Docs/prototypes/futex-ticket-lock/module.modulemap
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module CShim { header "shim.h" export * }
118 changes: 118 additions & 0 deletions Docs/prototypes/futex-ticket-lock/noshim.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import Foundation

// Current implementation: ticket lock over NSCondition, broadcast on release.
final class ConditionTicketLock {
private let condition = NSCondition()
private var nextTicket: UInt64 = 0
private var nowServing: UInt64 = 0
private var waiters = 0
private var owner: ObjectIdentifier?
func lock() {
let current = ObjectIdentifier(Thread.current)
condition.lock()
let ticket = nextTicket; nextTicket &+= 1
if ticket != nowServing {
waiters += 1
repeat { condition.wait() } while ticket != nowServing
waiters -= 1
}
owner = current
condition.unlock()
}
func unlock() {
condition.lock()
owner = nil
nowServing &+= 1
if waiters > 0 { condition.broadcast() }
condition.unlock()
}
}

// Option A, pure Swift: ticket lock with a ring of per-waiter semaphores.
// Guard is an NSLock held only for the counter update. Exactly one waiter is
// woken per release.
final class SemaphoreTicketLock {
private let guardLock = NSLock()
private static let slotCount = 64
private let slots: [DispatchSemaphore]
private var nextTicket: UInt64 = 0
private var nowServing: UInt64 = 0
private var parked = [Bool](repeating: false, count: SemaphoreTicketLock.slotCount)
private var owner: ObjectIdentifier?

init() { slots = (0..<Self.slotCount).map { _ in DispatchSemaphore(value: 0) } }

func lock() {
let current = ObjectIdentifier(Thread.current)
guardLock.lock()
let ticket = nextTicket; nextTicket &+= 1
if ticket != nowServing {
parked[Int(ticket) % Self.slotCount] = true
guardLock.unlock()
slots[Int(ticket) % Self.slotCount].wait()
guardLock.lock()
}
owner = current
guardLock.unlock()
}

func unlock() {
guardLock.lock()
owner = nil
nowServing &+= 1
let next = nowServing
let slot = Int(next) % Self.slotCount
let wake = parked[slot]
if wake { parked[slot] = false }
guardLock.unlock()
if wake { slots[Int(next) % Self.slotCount].signal() }
}
}

func benchUncontended<L>(_ name: String, _ make: () -> L, _ lk: (L) -> () -> Void, _ ul: (L) -> () -> Void) {
let l = make()
let iters = 2_000_000
let lockFn = lk(l), unlockFn = ul(l)
let t0 = DispatchTime.now().uptimeNanoseconds
for _ in 0..<iters { lockFn(); unlockFn() }
let t1 = DispatchTime.now().uptimeNanoseconds
print(String(format: "%@ uncontended: %.1f ns/acquire", name, Double(t1-t0)/Double(iters)))
}

func benchContended<L: AnyObject>(_ name: String, _ l: L, _ lk: @escaping () -> Void, _ ul: @escaping () -> Void) {
var stop = false
let hog = Thread { while !stop { lk(); var s = 0; for i in 0..<2000 { s &+= i }; _ = s; ul() } }
hog.qualityOfService = .userInitiated
hog.start()
Thread.sleep(forTimeInterval: 0.05)
var worst: UInt64 = 0, total: UInt64 = 0
let samples = 2000
for _ in 0..<samples {
let t0 = DispatchTime.now().uptimeNanoseconds
lk()
let t1 = DispatchTime.now().uptimeNanoseconds
ul()
worst = max(worst, t1-t0); total += t1-t0
}
stop = true
Thread.sleep(forTimeInterval: 0.05)
print(String(format: "%@ contended: mean %.1f us, worst %.1f us", name, Double(total)/Double(samples)/1000.0, Double(worst)/1000.0))
}

benchUncontended("NSCondition ticket", { ConditionTicketLock() }, { l in l.lock }, { l in l.unlock })
benchUncontended("Semaphore ticket ", { SemaphoreTicketLock() }, { l in l.lock }, { l in l.unlock })

let c = ConditionTicketLock()
benchContended("NSCondition ticket", c, { c.lock() }, { c.unlock() })
let s = SemaphoreTicketLock()
benchContended("Semaphore ticket ", s, { s.lock() }, { s.unlock() })

// Correctness of the semaphore variant.
let lk = SemaphoreTicketLock()
var counter = 0
let g = DispatchGroup()
for _ in 0..<8 {
DispatchQueue.global().async(group: g) { for _ in 0..<50_000 { lk.lock(); counter += 1; lk.unlock() } }
}
g.wait()
print("semaphore ticket counter = \(counter) (expect 400000)")
46 changes: 46 additions & 0 deletions Docs/prototypes/futex-ticket-lock/shim.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#include "shim.h"
#include <stdatomic.h>
#include <errno.h>
#include <os/os_sync_wait_on_address.h>

bool swiftterm_sync_available(void) {
if (__builtin_available(macOS 14.4, iOS 17.4, *)) {
return &os_sync_wait_on_address != NULL; // weak import null check
}
return false;
}

int swiftterm_sync_wait(uint32_t *addr, uint32_t value) {
if (__builtin_available(macOS 14.4, iOS 17.4, *)) {
return os_sync_wait_on_address(addr, (uint64_t)value, 4,
OS_SYNC_WAIT_ON_ADDRESS_NONE);
}
errno = ENOTSUP;
return -1;
}

int swiftterm_sync_wake_one(uint32_t *addr) {
if (__builtin_available(macOS 14.4, iOS 17.4, *)) {
return os_sync_wake_by_address_any(addr, 4, OS_SYNC_WAKE_BY_ADDRESS_NONE);
}
errno = ENOTSUP;
return -1;
}

int swiftterm_sync_wake_all(uint32_t *addr) {
if (__builtin_available(macOS 14.4, iOS 17.4, *)) {
return os_sync_wake_by_address_all(addr, 4, OS_SYNC_WAKE_BY_ADDRESS_NONE);
}
errno = ENOTSUP;
return -1;
}

uint32_t swiftterm_atomic_fetch_add(uint32_t *addr, uint32_t delta) {
return atomic_fetch_add_explicit((_Atomic uint32_t *)addr, delta, memory_order_relaxed);
}
uint32_t swiftterm_atomic_load_acquire(const uint32_t *addr) {
return atomic_load_explicit((const _Atomic uint32_t *)addr, memory_order_acquire);
}
void swiftterm_atomic_store_release(uint32_t *addr, uint32_t value) {
atomic_store_explicit((_Atomic uint32_t *)addr, value, memory_order_release);
}
14 changes: 14 additions & 0 deletions Docs/prototypes/futex-ticket-lock/shim.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#ifndef SWIFTTERM_SYNC_H
#define SWIFTTERM_SYNC_H
#include <stdint.h>
#include <stdbool.h>

bool swiftterm_sync_available(void);
int swiftterm_sync_wait(uint32_t *addr, uint32_t value);
int swiftterm_sync_wake_one(uint32_t *addr);
int swiftterm_sync_wake_all(uint32_t *addr);

uint32_t swiftterm_atomic_fetch_add(uint32_t *addr, uint32_t delta);
uint32_t swiftterm_atomic_load_acquire(const uint32_t *addr);
void swiftterm_atomic_store_release(uint32_t *addr, uint32_t value);
#endif
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ on iOS to run) and includes a login UI to configure the connection.

## Companion Apps

[Tecolot](https://github.com/migueldeicaza/Tecolot) is a MacOS Terminal.app
[Tecolot](https://github.com/migueldeicaza/Tecolot) is a MacOS Terminal.app
replacement.

[SwiftTermApp](https://github.com/migueldeicaza/SwiftTermApp) builds
Expand Down
Loading
Loading