uttrflow / uttrflow/uttrflow-swift

KeyInterceptor ring slots race when the producer laps the consumer

Open
#797 0 comments 0 reactions 0 assignees View on GitHub
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.