I went looking for a speech-to-text app to build and found out the one I wanted already exists. What I found instead was a bug that every tool in the category has, including the paid ones.
Here is how they all deliver text into whatever app you are typing in:
- save the current clipboard
- overwrite it with the transcript
- synthesize ctrl+v
sleep(120)- restore the saved clipboard
Step 4 and step 5 are the problem. The target application reads the clipboard whenever its message pump gets round to the paste, which is not when you sent the keystroke. If the app is busy, the restore wins, and the app reads back the thing you copied an hour ago instead of the sentence you just said.
This is handy issue #502: open since december, 52 comments, labelled critical. The shipped mitigation is a slider so you can tune the delay yourself. One user is at 400ms and still hitting it. The maintainer’s own comment is “I still don’t have a reasonable idea because I can’t reproduce this myself.”
reproducing it
Nobody had a repro, so I wrote one. A real win32 window, a real WM_PASTE, and a knob for how long the app waits before reading the clipboard. That knob stands in for message pump backlog under load.
At a 120ms restore delay:
| app lag | what the target received |
|---|---|
| 10 ms | transcript |
| 60 ms | transcript |
| 150 ms | previous clipboard |
| 400 ms | previous clipboard |
There is the bug, deterministically, in about a hundred lines. Any fixed delay has a lag that beats it. Tuning the number moves the threshold, it does not remove it.
my first fix was wrong
Windows gives you GetClipboardSequenceNumber, which increments on every clipboard write. My first idea was to only restore if the sequence number still matched our own write. If somebody else took the clipboard in the meantime, skip the restore.
I wrote it, shipped it into the crate, and put it in the readme.
Then I ran it through the repro:
| algorithm | 150 ms | 400 ms |
|---|---|---|
| unconditional restore | wrong text | wrong text |
| sequence gated restore | wrong text | wrong text |
Identical. Obvious in hindsight: the sequence number is still ours, because nobody else wrote to the clipboard. We clobber ourselves. The gate protects against a third party stealing the clipboard mid paste, which is a real but completely different problem.
I had already written it up as the fix before testing it against the thing it was supposed to fix.
the actual fix
The mistake in all of this is treating the clipboard as a place to put text. It is also a protocol, and the protocol has a message for exactly this situation.
Instead of publishing the transcript, publish a promise. SetClipboardData(CF_UNICODETEXT, NULL) with your own window as the clipboard owner. The clipboard now advertises that unicode text is available, but holds nothing.
When a consumer actually asks for the data, windows sends WM_RENDERFORMAT to the owner window, and the owner supplies the text at that moment. This is called delayed rendering and it has been in win32 since forever.
That message is the signal. It fires when the target reads, not when you guessed it might. The restore is sequenced after the read rather than racing it, and there is no delay constant anywhere in the code.
Back through the repro:
| app lag | 10 ms | 60 ms | 150 ms | 400 ms |
|---|---|---|---|---|
| delayed render | ok | ok | ok | ok |
Four for four, and the user’s clipboard is still restored afterwards, which the “just never restore” workaround does not give you.
then i tested it against chrome
The repro passed. I wrote it up. Then I pointed it at a real chrome window with a textarea, injected, and read the dom back.
The textarea contained USER PREVIOUS CLIPBOARD.
Same bug. With delayed rendering on. With the read confirmed.
Chromium touches the clipboard more than once per paste. There is an early probe, then the read that actually populates the field. My code restored after the first WM_RENDERFORMAT, which lands neatly between the two. The original bug wearing a different hat.
My mock read the clipboard exactly once, so it could not have caught this. Real chrome caught it on the first attempt.
The fix is to wait for renders to go quiet rather than for the first one. Note this is still not a race-the-target delay, because the clock starts from an observed read instead of from a guess.
clipboard managers break the signal entirely
One more layer. If a clipboard manager is running and it archives every clipboard change, it reads your promise the moment you publish it.
Two consequences, and the second one is nastier than it looks.
First, it captures your transcript. The four opt out formats that keep text out of clipboard history are a cooperative protocol, not enforcement. Windows’ own clipboard history honours them. A third party manager might not.
Second, and worse: once anything renders the promise, the clipboard holds real data, and windows sends no further WM_RENDERFORMAT. The target’s read is not delayed at that point. It is gone. You are waiting for a signal that can never arrive.
I found this by writing a fake clipboard manager in about 130 lines and running the injection with it live. Without handling it, the clipboard was never restored at all while a manager was running, which permanently destroys the user’s clipboard on every single dictation. That is worse than the bug I started with.
So the code now records a baseline when the paste is sent, ignores reads before that point, and detects when the promise was already consumed. In that case it falls back to the timer and reports read_confirmed: false honestly instead of pretending it knows.
Three cases, each handled differently:
- read after the paste: confident, restore now
- promise consumed early: target unobservable, fall back to the timer
- no read at all: the paste never landed, so leave the transcript on the clipboard rather than discarding it
the bonus bug
While testing chords against real applications I had vs code down as needing shift+insert, on the strength of a vendor support page that says so for ides.
Shift+insert does nothing in the vs code editor. No render fires, the paste never happens at all. Ctrl+v works fine. That advice appears to describe the integrated terminal, not the editor.
Every chord in the table is now established by measurement against the running application. The final matrix, verified by making each app copy its own field back, which is the only readback that works across win32, chromium and electron:
| application | chord | delivered | clipboard kept |
|---|---|---|---|
| chrome | ctrl+v | yes | yes |
| vs code | ctrl+v | yes | yes |
| notepad | ctrl+v | yes | yes |
| windows terminal | ctrl+shift+v | yes | yes |
then i actually used it
At this point I had 27 unit tests, a synthetic harness, four real applications, a fake clipboard manager, and a green ci run. I published the crate and opened the pull request.
Then I wired it into the real app, put on a microphone, and dictated four sentences.
One paste in four did this:
[WARN] win-text-inject failed, falling back to timer paste:
clipboard operation failed: Element not found. (0x80070490)
SetClipboardData returns NULL on success when you publish a delayed render promise. There is no data to hand back yet, so NULL is the correct return. The rust bindings see a null handle and report an error, so I had written a guard: treat it as an error only if the last error code is non-zero.
Win32 does not reset the thread’s last error on success. It only sets it on failure. So the code sitting there was whatever some unrelated earlier call had left behind, and roughly a quarter of the time that leftover was non-zero and my guard rejected a write that had worked perfectly.
The return value cannot tell you anything here. The fix is to stop asking it and check the clipboard instead: do we own it, and is CF_UNICODETEXT advertised. Both are facts about the world rather than a status code that may be stale.
Four more dictations after the fix, zero fallbacks.
what i actually take from this
Four times in one afternoon I had a fix that passed my tests and was wrong.
The sequence gate passed unit tests and did not address the bug. The first delayed render implementation passed a synthetic harness and failed against chrome. The chord table matched the documentation and did not match the software. The last error guard passed everything I had and failed against a human speaking into a microphone.
Each one was caught by running the thing against something more real than the last. And notice the ordering: the tests caught nothing, the harness caught what the tests missed, chrome caught what the harness missed, and actual use caught what all of it missed. Every layer of realism found a bug the previous layer could not see.
None of them would have been caught by thinking harder, and I did think carefully about all four. The sequence gate in particular felt obviously correct, which is why I wrote it up as the fix before testing it.
The uncomfortable part is that I had already shipped. The crate was published and the pull request was open, on the strength of a test suite that was green and wrong.
The code is on github and published as win-text-inject. The repro is cargo run --example repro_502 if you want to watch it fail.