DEV Community

Cover image for The tool that said yes and did nothing: ydotool 0.1.8 exits 0 when it refuses
Mohsen Seyedkazemi Ardebili
Mohsen Seyedkazemi Ardebili

Posted on AI-assisted

The tool that said yes and did nothing: ydotool 0.1.8 exits 0 when it refuses

For about ten and a half hours on 24 September, my dictation app typed nothing at all on Ubuntu under Wayland.

You held the key, you spoke, the speech was transcribed correctly, and then... nothing landed in the window. No error in the log. The health check printed [OK] Injection: ydotool. The tests were green.

This is the story of that bug, because the lesson in it applies to almost every program that shells out to another program.

Some context

I work on YazSes, an open-source, offline hold-to-talk dictation tool. You hold a key, talk, release, and the text is typed into whatever app has focus. On Wayland you cannot just "send keystrokes" to another window the way you can on X11, so one common route is ydotool, which writes events to a virtual uinput keyboard.

The catch: there are two ydotools. Version 1.x and version 0.1.x share a name and a purpose, but they are different command-line tools. And distributions did not all move at the same pace. As of today (I checked Launchpad and sources.debian.org on 2026-10-22):

Distribution ydotool version
Ubuntu 22.04, 24.04 0.1.8
Debian 11, 12 0.1.8
Ubuntu 26.04, Debian testing 1.0.4

YazSes only spoke 1.x.

How a correct fix broke things

The bug did not come from a careless change. It came from a correct one.

In v2.39.0 I taught the socket discovery to find /tmp/.ydotool_socket, which is where the 0.1.8 daemon listens. Before that, on Ubuntu, ydotool was simply never selected, and YazSes used another backend that worked fine.

After the fix, ydotool became reachable. It is first in the backend order. So on Ubuntu it pushed aside a path that worked and took over, speaking 1.x syntax to a 0.1.8 binary.

Three command lines were now wrong:

  • ydotool type -d 6 -H 6 was refused. 0.1.x uses --key-delay and has no hold time.
  • ydotool key 29:1 47:1 47:0 29:0 (Ctrl+V as raw keycodes): 0.1.x wants symbolic names.
  • ydotool key 97:0, a "release any stuck modifier" guard that ran after every dictation, typed the digit 9.

Why nothing noticed

Here is the part that matters. When 0.1.8 refuses an option, it does this:

$ ydotool type -d 6 -H 6 "hello"
error: unrecognised option '-d'
$ echo $?
0
Enter fullscreen mode Exit fullscreen mode

It prints an error and exits 0.

My code ran it with subprocess.run(..., check=True). check=True only raises on a non-zero exit code. So no exception, so the clipboard fallback never ran, so nothing was logged, so the health check (which asked "is ydotool there and does it run?") said OK.

Every layer was telling the truth about what it was asked. Nobody was asking whether text reached the window.

Every layer told the truth about what it was asked: exit 0, no exception, no fallback, doctor OK, nothing typed

It gets better. 0.1.8's key command has no error path for unknown names at all. It types the first letter of whatever you gave it. I measured this by grabbing the virtual keyboard device and counting events:

You send 0.1.8 types
Return r
Escape e
space s
KEY_BACKSPACE k
29:1 2

So "new line" would have typed r.

The fix

Two changes, released in v2.40.0 the same evening.

1. Stop trusting the exit code. The output is classified instead. 0.1.8 writes normal progress messages to stderr ("Key delay was set to 6 milliseconds"), so not every line on stderr is an error, but an explicit error is:

def _classify(stderr: str, returncode: int) -> type[YdotoolCliError] | None:
    """The exception for this result, or None when it succeeded.

    0.1.8 writes ordinary progress to stderr ("notice: Using ydotoold backend",
    "Key delay was set to 6 milliseconds."), so only an explicit error is an error.
    """
    low = stderr.lower()
    if any(marker in low for marker in _OPTION_ERROR_MARKERS):
        return YdotoolOptionError
    if ": error:" in low or returncode != 0:
        return YdotoolCliError
    return None
Enter fullscreen mode Exit fullscreen mode

Now a refused command raises, and the fallback backend runs like it was always supposed to.

2. Detect the dialect, and only send what it understands. YazSes probes which ydotool it is talking to and keeps an allowlist of key names that were measured to work on 0.1.8. Anything else raises and goes to the fallback, instead of typing a random first letter. yazses doctor now says "ydotool 0.1.x" or "ydotool 1.x" instead of a bare OK.

And there is a regression test with an honest name: test_an_option_error_is_a_failure_even_though_ydotool_exits_zero.

What I took away

An exit code is a claim, not evidence. It tells you what the tool thinks happened. When a subprocess is the last step before something the user sees, read its stderr, and if you possibly can, measure the effect itself. For keyboard injection, that meant grabbing the uinput device and counting events. For your tool it might be "read the file back" or "query the API you just wrote to".

Making something reachable makes it load-bearing. My socket fix was right. But it promoted a backend from "never used on Ubuntu" to "first choice on Ubuntu", and I verified the probe, not the backend. If a change widens where code runs, test the code in its new home.

"Health check: OK" should name what it checked. "ydotool found" and "text arrives in the window" are very different sentences. Only one of them was being said.

If you maintain anything that wraps ydotool, xdotool, wtype or similar, I'd love to hear how you verify that input actually landed. That is still the hardest part of this whole project.


YazSes is an offline, open-source (Apache-2.0) dictation tool for Linux, macOS and Windows: github.com/MSKazemi/yazses. The full fix is in the v2.40.0 release notes.

I wrote this with help from an AI assistant for drafting and editing; the bug, the fix, the measurements and the opinions are mine, and I checked every fact against the code and release notes.

Top comments (0)