uttrflow / uttrflow/uttrflow-swift
KeyInterceptor ring slots race when the producer laps the consumer
- Dominant language
- Swift
- Stars
- 4
- Forks
- 17
- Avg merge
- 3h 32m
- Merged PRs (30d)
- 277
Description
## Problem
`TapState.enqueue` writes into an ordinary `UnsafeMutablePointer` ring, while `TapState.take` reads the same slots on a different queue. The atomic `written` index publishes a completed write, but does not prevent the producer from wrapping and overwriting a slot while the consumer reads it.
The producer never checks `read` before overwriting. The consumer clamps its starting cursor when its snapshot is more than 64 events behind, but the producer can continue writing after that snapshot. Thus the documented single-producer/single-consumer arrangement still permits unsynchronized access to the ring storage.
## Runtime evidence
On main commit `8d93f5b8574f3c7b8c30086d9c817bf7f5d4827d`, a separate Swift package linked the unmodified production Core, Predict and Input source directories and ran with `swift test --sanitize thread`.
The test creates a `TapState` with a resumed dispatch signal, runs one producer enqueuing 100,000 `ArmedKeys.tab.rawValue` slots, and runs one consumer calling `take()` until the producer finishes. A separate atomic flag coordinates completion. No event tap or synthetic keyboard events are posted.
Thread Sanitizer reported:
```text
WARNING: ThreadSanitizer: data race
Write of size 4:
TapState.enqueue(_:) KeyInterceptor.swift:278
Previous read of size 4:
TapState.take() KeyInterceptor.swift
SUMMARY: ThreadSanitizer: data race KeyInterceptor.swift:278 in TapState.enqueue(_:)
```
The conflicting location was inside the 256-byte allocation for the 64 ring slots. The sanitizer made the test process fail, even though the test body completed. This is a stress reproduction of the storage race, not a measurement of ordinary typing frequency or evidence of a native app crash.
## Why it matters
In production, `keyInterceptorCallback` enqueues swallowed keys on the tap thread and the dispatch drain calls `take()` on its own queue. A delayed consumer can overlap ring reuse. These events represent keys already withheld from the receiving application, so a race in their delivery undermines the interceptor's ordering and reliability guarantees. Dropping old events on overflow is documented; unsynchronized reads and writes are not a safe implementation of that policy.
## Acceptance criteria
- Ensure producer and consumer never access the same non-atomic ring slot concurrently, including when the producer overtakes a delayed consumer.
- Preserve bounded storage and keep the event-tap callback short. Define and test overflow behavior explicitly.
- Add a concurrent single-producer/single-consumer test that passes under Thread Sanitizer, plus ordering and overflow assertions. Making slots atomic alone must not be treated as proof of correct event ordering.
- Preserve tap-state lifetime and port release coverage.
Priority: P2 — concurrency correctness. This is separate from #370 (port retention) and #487 (tap startup/stop lifecycle).
Contributor guide
Research direction
Start in KeyInterceptor.swift by reading TapState.enqueue and TapState.take, then run the separate Swift package reproduction with `swift test --sanitize thread`. Define bounded-ring overflow behavior so producer and consumer cannot access one slot concurrently, and add the concurrent single-producer/single-consumer test with ordering, overflow, Thread Sanitizer, tap-state lifetime, and port-release coverage.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- macos, swift
- Domain
- desktop, testing
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 48/100