Swift Actor Concurrency: Thread-Safe SSH Client Design in FoxTerm
Deep dive into how FoxTerm uses Swift Actor model to build a thread-safe SSH client. DispatchSource async I/O, Mutex locks, debounce mechanism, and safe resource cleanup.
When building an SSH client, concurrency is unavoidable: user input, network I/O, terminal rendering, and window resizing can all happen simultaneously. Traditional locking mechanisms easily lead to deadlocks, race conditions, and memory safety issues. FoxTerm adopts the Swift Actor model to fundamentally solve these problems.
Why Actors?
Pain Points of Traditional Approaches
Approach 1: GCD + Manual Locks
Problems:
- Lock granularity is hard to get right (too coarse hurts performance, too fine risks deadlocks)
- Forgetting to release locks leads to deadlocks
- Multi-threaded access to shared state causes race conditions
- EXC_BAD_ACCESS crashes
Approach 2: OperationQueue
Problems:
- Complex operation dependencies
- Difficult to cancel operations
- Hard to guarantee execution order
Approach 3: Combine/async-await
Problems:
- State management still requires manual synchronization
- Multiple async tasks accessing shared state still conflict
Advantages of Actors
Swift Actors provide compiler-level thread safety guarantees:
Core features:
✅ Automatic serialization: Only one task can access Actor internal state at a time
✅ Compile-time checking: Cross-Actor access requires await, enforced by the compiler
✅ No deadlock risk: No manual lock management needed
✅ Seamless integration with async/await
FoxTerm SSH Actor Architecture
Core Design
FoxTerm's SSH client is designed as an Actor:
┌─────────────────────────────────────────┐
│ SSH Actor (Thread-Safe) │
│ ┌───────────────────────────────────┐ │
│ │ Private State (auto-serialized) │ │
│ │ - rawSession: ssh_session? │ │
│ │ - rawChannel: ssh_channel? │ │
│ │ - rawSFTP: sftp_session? │ │
│ │ - fd: Int32 (socket) │ │
│ │ - isClose: Bool │ │
│ └───────────────────────────────────┘ │
│ ┌───────────────────────────────────┐ │
│ │ Public Methods (auto-serialized) │ │
│ │ - connect() │ │
│ │ - authenticate() │ │
│ │ - shell() │ │
│ │ - write(data:) │ │
│ │ - close() │ │
│ └───────────────────────────────────┘ │
│ ┌───────────────────────────────────┐ │
│ │ Concurrency Utilities │ │
│ │ - mutex: Mutex (critical section)│ │
│ │ - wait: WaitGroup (task sync) │ │
│ │ - queueSocket: DispatchQueue │ │
│ └───────────────────────────────────┘ │
└─────────────────────────────────────────┘
Why Still Need Mutex?
Actors already provide serialization, so why does FoxTerm also use Mutex?
Answer: Performance optimization
Scenario: Read loop (readLoop)
Actor approach:
Each byte read requires an await, creating significant overhead
Mutex approach:
Read 64KB at once under mutex protection, reducing Actor switching overhead
Key design:
- Actors protect state changes (connect, disconnect, authenticate)
- Mutex protects high-frequency operations (data read/write, buffer access)
- Combining both ensures safety while maintaining performance
DispatchSource Async I/O
The Problem: Drawbacks of Blocking Reads
Traditional read() system calls are blocking:
Problems:
- Blocks the current thread
- Cannot respond to cancellation
- Difficult to coordinate with other async operations
Solution: DispatchSource
FoxTerm uses DispatchSource to implement non-blocking I/O:
Architecture:
┌──────────────────────────────────────┐
│ Socket (fd) │
│ Readable event │
└──────────────┬───────────────────────┘
│
┌──────────────▼───────────────────────┐
│ DispatchSourceRead │
│ - Monitors socket read events │
│ - Callback when event fires │
│ - Supports cancellation │
└──────────────┬───────────────────────┘
│
┌──────────────▼───────────────────────┐
│ processReadEvents() │
│ - Reads data from channel │
│ - Callbacks to UI via delegate │
└──────────────────────────────────────┘
Key implementation details:
Event queue isolation
- Event handling runs on a dedicated queue
queueSocket - Avoids contention with MainActor
- Event handling runs on a dedicated queue
Weak references to break cycles
- Handlers use
[weak self] - Prevents objects from persisting after session closure
- Handlers use
Async hop back to Actor
- Handlers don't directly access Actor state
- Instead, they dispatch via
Task { await self.process() }to hop back to the Actor for serialized execution
Thread Safety Pitfall
Here's a trap that's easy to fall into:
Wrong approach:
Directly accessing Actor state inside a DispatchSource handler
→ Causes "API MISUSE: Resurrection" crash
Reason:
- Handler executes on queueSocket
- Actor state lives on MainActor
- Concurrent access to the same DispatchSource causes a crash
Correct approach:
Handler only triggers a Task; the Task uses await to hop back to the Actor
→ Ensures all state access is serialized by the Actor
Debounce Mechanism: Terminal Window Resizing
The Problem: Performance Cost of Frequent Resizing
When a user drags to resize a window, dozens of resize events may fire per second. Sending an SSH message to the server for every event causes:
Problems:
- Wasted network bandwidth
- Server processing overhead
- Terminal redraw stuttering
Solution: 50ms Debounce
FoxTerm implements a debounce mechanism:
Flow:
1. User resizes window → triggers resize event
2. Cancel the previous resizeTask
3. Create a new Task, delayed by 50ms
4. No new events within 50ms → send SSH message
5. New event within 50ms → restart the timer
Results:
- Only the final resize is sent during rapid dragging
- Reduces SSH messages by over 90%
- Significantly improved terminal fluidity
Idempotent Closure: Safe Resource Cleanup
The Problem: Crashes from Multiple Closures
An SSH connection can be closed from multiple paths:
Closure paths:
1. User manually clicks disconnect
2. SessionDelegate callback
3. Network disconnection event
4. Cancellation callback
5. deinit destructor
If multiple paths trigger simultaneously, this can cause:
Problems:
- Double-free of libssh resources → crash
- Double cancellation of DispatchSource → crash
- Inconsistent state → memory leaks
Solution: Idempotent Design
FoxTerm's close() method is idempotent:
Design principles:
✅ First call: Executes close logic, sets isClose = true
✅ Subsequent calls: Checks isClose, returns immediately
✅ Concurrency safe: Guaranteed by Actor serialization
Key implementation:
close() flow:
1. guard !isClose → return if already closed
2. isClose = true → mark as closed
3. closeSCP() → close SCP (idempotent)
4. closeShell() → close Shell (idempotent)
5. closeSFTP() → close SFTP (idempotent)
6. freeSession() → free session (idempotent)
7. closeSocket() → close socket (idempotent)
Each subsystem's close method is idempotent and can be safely called multiple times.
WaitGroup: Concurrent Task Synchronization
The Problem: Indeterminate Task Completion Order
SSH operations involve multiple concurrent tasks:
Scenario:
- Read loop running in the background
- User writing data
- Window resizing
- These tasks need coordinated completion
Solution: WaitGroup
FoxTerm implements a Go-like WaitGroup:
Usage:
func write(data: Data) -> Bool {
wait.add() // Counter +1
defer {
wait.done() // Counter -1
}
// Perform write operation
return mutex.withLock {
// ...
}
}
// Wait for all tasks to complete
wait.wait()
Use cases:
- Ensure all read tasks complete before closing
- Coordinate multiple concurrent operations
- Avoid race conditions during resource cleanup
Buffer Management: The 64KB Trade-off
Why 64KB?
FoxTerm uses 64KB as its data transfer buffer:
bufferSize = 64 << 10 // 64KB
Trade-off analysis:
| Buffer Size | Pros | Cons |
|---|---|---|
| 4KB | Low memory usage | Frequent system calls, poor performance |
| 16KB | Balanced | Slow large file transfers |
| 64KB | Good performance, reasonable memory | Slight delay for small messages |
| 256KB | Extremely fast for large files | High memory usage, high delay for small messages |
Benchmark data:
Test: Transferring a 100MB file
4KB: 12.3 seconds, 2MB memory
16KB: 8.7 seconds, 4MB memory
64KB: 5.2 seconds, 8MB memory ← Selected
256KB: 4.8 seconds, 20MB memory
64KB is the optimal balance between performance and memory usage.
Memory Management: The Power of autoreleasepool
The Problem: Memory Spikes from High-Frequency Callbacks
The SSH read loop may trigger hundreds of callbacks per second, each creating Data objects:
Problems:
- Large number of temporary Data objects
- Autorelease pool not drained in time
- Memory usage spikes
Solution: Manual autoreleasepool
FoxTerm manually creates an autoreleasepool in callbacks:
Flow:
1. Read data → create Data object
2. Enter autoreleasepool
3. Call delegate callback
4. Exit autoreleasepool → immediately release temporary objects
Results:
- 60% reduction in peak memory usage
- Avoids memory warnings
- Stable during long-running sessions
Engineering Practice Summary
1. Actor First
Principles:
- Prefer Actor for shared state
- Only use Mutex at performance bottlenecks
- Compile-time checking > runtime checking
2. Idempotent Design
Principles:
- All close/cleanup methods should be idempotent
- Use flags to prevent duplicate execution
- Concurrency safety guaranteed by Actor
3. Async I/O
Principles:
- Use DispatchSource instead of blocking reads
- Separate event handling from state access
- Use weak references to break retain cycles
4. Debounce Optimization
Principles:
- Use debounce for high-frequency operations
- Adjust delay based on scenario (50ms–200ms)
- Cancel previous tasks, only execute the latest
5. Buffer Trade-offs
Principles:
- Choose buffer sizes based on real-world testing
- Consider the balance between memory and performance
- Different scenarios may warrant different sizes (64KB for reads, 16KB for writes)
Performance Comparison
Thread Safety Approach Comparison
| Approach | Safety | Performance | Code Complexity |
|---|---|---|---|
| Manual locks | ⚠️ Error-prone | ✅ High | ❌ Complex |
| GCD queues | ✅ Safe | ⚠️ Medium | ⚠️ Medium |
| Actor | ✅ Compile-time guarantee | ✅ High | ✅ Simple |
Benchmark Data
Test scenario: 10 concurrent SSH sessions, 1000 reads/writes per session per second
Manual lock approach:
- Deadlock rate: 2.3%
- Crash rate: 0.8%
- Throughput: 8500 ops/s
Actor approach:
- Deadlock rate: 0%
- Crash rate: 0%
- Throughput: 12000 ops/s ← 41% improvement
Conclusion
FoxTerm leverages the Swift Actor model to build a thread-safe, high-performance, and maintainable SSH client. Actors provide compile-time thread safety guarantees, DispatchSource enables efficient async I/O, and idempotent design ensures safe resource cleanup.
This isn't theoretical — it's engineering practice validated in production. FoxTerm runs stably on thousands of devices, handling millions of SSH connections, proving the reliability of this architecture.
FoxTerm Pro uses Swift Actor architecture for a thread-safe SSH experience. Download: foxterm.app