← Build log

The Second-Paste Bug: A CGEventTap Race That Made Every Recording Paste the Last One

2026-06-24

A suppression window is a timing guess; identity is a fact — when you have to make a CGEventTap ignore your own synthetic keystroke, tag the event, don’t race the clock. That one sentence is the whole lesson of a bug that had me convinced, for the better part of an evening, that VoiceCatcher had stopped transcribing. It hadn’t. It transcribed perfectly. It just pasted the wrong perfect transcription — the one from the recording before — every single time after the first.

TLDR

What You’ll Gain:

  • The concrete macOS technique for making a CGEventTap ignore keystrokes you synthesized yourself: stamp kCGEventSourceUserData with a magic value via CGEventSetIntegerValueField, then read it back in the tap.
  • A worked example of why a time-based suppression window loses a race against the very async event it’s trying to suppress.
  • A debugging tell worth stealing: if your “wrong output” is correct somewhere else in the system, the bug is downstream of the thing you suspect.
  • Bottom Line Value: If you post synthetic input events on macOS and also watch for real ones, identity-tag your own events. A 500 ms timer will betray you the day the OS delivers your event 501 ms late.

Honest framing up front, because it matters: this fix landed on an unsigned local build while signing and notarization are still in progress (that saga is post 147). 197 Rust tests pass, clippy is clean, and I live-verified it across five-plus back-to-back recordings. It is not yet validated on a notarized build in a stranger’s hands. Date-stamped 2026-06-24, macOS 26.5.1, Tauri 2, Rust.

What “smart paste” is actually doing behind your back

VoiceCatcher’s nicest trick is the one you’re not supposed to notice. When a transcription finishes, here’s the sequence it runs on macOS:

  1. Save whatever’s currently on your clipboard.
  2. Write the transcription to the clipboard.
  3. Auto-paste it for you — synthesize a Cmd+V so the text drops into wherever your cursor is.
  4. Arm a global CGEventTap so that the next time you hit Cmd+V yourself, it restores your original clipboard first.

That last step is the whole point. You had a URL copied. VoiceCatcher borrows the clipboard for half a second to deliver your dictated text, then quietly gives the URL back the next time you paste. You never lose what you had. It’s the kind of feature that’s invisible when it works and infuriating when it doesn’t.

It wasn’t working.

Why does my macOS app paste the previous clipboard instead of the new one?

Here’s exactly what I saw. First recording: flawless. I dictate, the text appears, life is good. Second recording: I dictate something completely different, and what lands on screen is… the first recording’s text. Third recording pastes the second’s. Every recording was one step behind, like a band where the drummer is playing the previous song.

My first instinct — and I’ll own how wrong it was — was that the second recording hadn’t transcribed. The output was stale, so the transcriber must have hung, right?

Then I checked History.

VoiceCatcher writes every transcription to its History pane. And History showed the correct, new text for every recording. Recording #2’s history entry was recording #2’s words, verbatim. So the transcription was fine. The model ran. The text was right.

That was the tell, and it’s the part I want you to steal:

History is written from the transcription text, never from the clipboard. So if History is correct but the paste is wrong, the bug lives entirely in the clipboard-and-paste path — not in transcription.

The “wrong output” was correct somewhere else in the system. That single fact dragged the investigation 20 feet sideways, away from the transcriber I’d been glaring at and straight into the clipboard machinery. If I’d trusted the symptom instead of the data, I’d have spent the night profiling Whisper.

The race, in slow motion

Here’s the mechanism, and it’s a clean little tragedy.

Step 3 (auto-paste) synthesizes a Cmd+V. But step 4’s CGEventTap is also listening for Cmd+V — that’s how it knows when to do the restore. So the tap has to ignore the auto-paste’s own synthetic Cmd+V, or it’ll mistake our own paste for “the user’s next paste” and fire the restore immediately, clobbering the text we just delivered.

The original defense was a timing window. arm_ignore_window() set a field called ignore_until to roughly now-plus-500ms. Any paste event the tap saw inside that window got ignored as “probably our own.” Reasonable on paper.

The problem was the ordering. Look at what trigger_auto_paste did:

  1. Fire the synthetic Cmd+V (CGEventPost).
  2. Immediately call mark_auto_pasted().

And mark_auto_pasted(), in the old code, did two things: set first_paste_done = true and clear ignore_until. It tore down the suppression window right after firing the paste.

But — and this is the entire bug — CGEventPost is asynchronous. You post the event; the OS delivers it to the tap whenever the OS feels like it. In practice, the tap callback for our own synthetic Cmd+V frequently arrived after mark_auto_pasted() had already run. By the time our own event showed up at the tap:

  • ignore_until had been cleared, so the suppression window no longer protected it.
  • first_paste_done was now true, so on_paste_detected took the restore branch.

So the tap looked at our own auto-paste, decided it was the user’s next manual paste, and dutifully restored the previous clipboard right on top of the transcription we’d just placed. The text flickered in and got overwritten by whatever the “previous clipboard” was.

And here’s why it marched one-behind: by recording #2, that “previous clipboard” lineage was recording #1’s text. The restore put #1’s text back. Recording #3 restored #2’s. The stale baton kept getting passed down the line.

A guard built to suppress our own event lost a footrace to the very event it was guarding against. The clock-based window was always going to lose that race eventually; async delivery just made “eventually” happen most of the time.

How do you make a CGEventTap ignore your own synthetic keystroke?

Stop guessing at timing. Tag the event so it carries proof of who made it.

macOS CGEvents have a field called kCGEventSourceUserData — an app-defined 64-bit integer that survives CGEventPost and is readable by an event tap downstream. Real hardware keystrokes carry 0 there. Nobody else is using it. So you stamp your synthetic event with a magic number on the way out, and read it back on the way in.

In VoiceCatcher that magic value is:

pub const AUTO_PASTE_MAGIC: i64 = 0x5643_0050_4153_5445u64 as i64; // "VC\0PASTE"

When autopaste_macos.rs builds the synthetic Cmd+V, it stamps the event before posting:

CGEventSetIntegerValueField(event, kCGEventSourceUserData, AUTO_PASTE_MAGIC);

And the tap in paste_monitor_macos.rs reads that field first thing — before any timing logic, before any state machine:

let user_data = CGEventGetIntegerValueField(event, kCGEventSourceUserData);
if user_data == AUTO_PASTE_MAGIC {
    // Our own auto-paste. Ignore it, full stop — no matter how late it arrived.
    return event;
}
// user_data == 0 here for a real hardware Cmd+V → process normally.

That’s it. Our own paste can never be mistaken for a user paste, because it carries its own birth certificate. It doesn’t matter if the OS delivers it 5 ms late or 5 seconds late — the identity check holds for all of eternity, where the timing window held for 500 milliseconds and then surrendered.

This replaces a timing guess with an identity fact. The clock asks “was this event recent enough to probably be mine?” The stamp asks “is this event literally mine?” Only one of those questions has a correct answer.

Before and after, the ordering itself

I fixed it two ways on purpose — defense in depth, two independent mechanisms, so a regression in one doesn’t resurrect the bug:

1. Identity stamp (primary, race-free). The kCGEventSourceUserData tag above. This is the real fix.

2. Ordering fix (belt and suspenders). mark_auto_pasted() no longer clears ignore_until. The suppression window is left alone to expire on its own clock. Even with the stamp removed, a late own-paste now stays inside an intact window instead of falling through a window that was demolished the instant the paste fired.

The before/after that matters is one deleted line of behavior:

Old mark_auto_pasted()New mark_auto_pasted()
Sets first_paste_done = trueyesyes
Touches ignore_untilclears itleaves it to expire
Late own-paste callback lands ina torn-down window → restore firesan intact window → still suppressed

The comment I left in clipboard_history.rs says it more bluntly than I will here: we deliberately do not clear ignore_until, because the auto-paste’s synthetic Cmd+V is delivered late, and clearing the window early was the original bug.

How do you write a regression test for an async race you can’t easily reproduce on demand?

You don’t test the async delivery. You test the decision the callback makes, and you drive the state into the exact shape the race produced. The race’s whole damage was: a paste callback arrives after mark_auto_pasted() ran. So you call them in that order and assert the callback doesn’t restore.

Two tests now guard this, both in clipboard_history.rs:

  • own_auto_paste_callback_after_mark_does_not_restore — arms the ignore window, calls mark_auto_pasted(), then fires the paste-detected callback (the late-arrival ordering), and asserts it returns None (no restore). This is the bug, frozen.
  • real_user_paste_after_window_restores_once — proves I didn’t fix the bug by breaking the feature: a genuine user Cmd+V after the window still restores the previous clipboard exactly once. The whole point of smart paste still works.

That second test is the one that lets me sleep. It’s easy to kill a race by smothering the feature that caused it. The restore is supposed to happen on a real user paste — I just needed it to stop happening on my own fake one.

The Human Reality

I spent the first 40 minutes of this debugging the wrong organ. I was so sure the second recording had silently failed to transcribe that I added logging to the Whisper pipeline, watched it produce perfect text, and briefly concluded I was hallucinating. The thing that broke the spell wasn’t cleverness — it was glancing at the History pane and seeing the correct words sitting right there, mocking my theory. The data was correct in the one place that reads from text instead of clipboard. The system was telling me where the bug wasn’t, and I’d been too annoyed to listen.

There’s a specific flavor of stupid-feeling that comes from realizing the OS function you’ve used a hundred times — CGEventPost — has the word “asynchronous” sitting in its contract the whole time, and you built a synchronous mental model on top of it anyway. The bug wasn’t exotic. It was me assuming “fire the event, then mark it done” runs in the order I wrote it. The CPU agreed with me. The OS event queue did not.

What This Means for Us

If you write anything that both injects synthetic input and observes real input on the same machine — auto-typers, paste helpers, macro tools, accessibility utilities — you have this race latent in your design whether you’ve hit it yet or not. The instinct to “ignore my own events for a little while” is the trap. A little while is a timing assumption, and timing assumptions against an async event queue are bets you will eventually lose, usually in front of a user.

Tag your own events. kCGEventSourceUserData on macOS is purpose-built for exactly this and it’s almost free. Real keystrokes carry zero; yours carry a fingerprint; the tap checks the fingerprint before it checks anything else. You trade a fragile timing window for an identity check that’s correct by construction.

And trust your instruments. When the wrong output shows up correct somewhere else in your system, that “somewhere else” is a map straight to the actual bug. Mine was a History pane. Yours might be a log line, a database row, an API response. Follow the copy that’s right — it’s pointing at the part of the pipeline that’s still honest.


Next steps you can take today:

  • If you post synthetic input events on macOS: stamp kCGEventSourceUserData with a non-zero magic value via CGEventSetIntegerValueField, and read it in your tap before any timing or state logic. Real events carry 0.
  • If you’re already using a suppression window: keep it as a second line of defense, but don’t tear it down right after firing the event you’re suppressing — let it expire on its own clock.
  • If you’ve got a race you can’t reproduce on demand: don’t test the async delivery, test the decision the callback makes, driving state into the exact ordering the race produced. Then add a paired test proving the legitimate path still fires.
  • Watch this space: the next real test is whether this holds on a notarized, signed build on a machine that isn’t mine. Today’s verification is on an unsigned local build (197 tests, clippy clean, 5+ live back-to-back recordings each pasting their own text). The signing story is still cooking — see post 147.