All posts

3 min read

My multiplexer lied about what it could do

kitty icat drew nothing when tuios ran in a browser client. The graphics passthrough said yes to file transmission without asking the host.

GGGaurav Gosain

Images displayed fine in my terminal. Fine in the multiplexer running inside it. Fine in the browser client. Stack all three (the browser client hosting a shell that runs the multiplexer) and icat produced nothing at all. No error, no placeholder, no partial image. Just the prompt, sitting where the image should have been.

What icat is actually doing

The kitty graphics protocol has a capability query. Before sending anything real, icat asks the terminal whether it supports a given transmission medium, and it asks about three of them:

_Ga=q,f=24,s=1,v=1,S=3,i=1        can you take pixels inline
_Ga=q,f=24,t=t,s=1,v=1,S=47,i=2   can you read a temp file
_Ga=q,f=24,t=s,s=1,v=1,S=18,i=3   can you read shared memory

It uses the first medium the terminal answers OK to, which is sensible. A file is much cheaper than inline base64, so if the terminal can read files, everyone wins.

The lie

The multiplexer's passthrough answered OK to all three. Unconditionally.

response := vt.BuildKittyResponse(true, cmd.ImageID, "")
ptyInput(response)

It never asked its own host anything. It just said yes.

So icat picked a file medium, wrote the image to /tmp, and sent a path. The multiplexer forwarded that path out to the browser. The browser cannot read /tmp on the server, so it dropped the transmission and drew nothing.

Captured at the browser, the failure looks like this:

what actually arrived at the browser client
a=t,t=f,i=1,f=100,s=64,v=64,q=2,c=8,r=4
payload=132 "L3RtcC9jbGF1ZGUtMTAwMC8t"
 
a=p,i=1,p=1,c=8,r=4,q=2 place image 1
images:0 placements:0 canvases:0
 
That base64 decodes to a filesystem path. The placement then references an image that was never stored, because the transmission carrying it was dropped.

Why each layer alone was fine

In a real terminal, the host genuinely can read the path, so the lie is true by accident.

In the browser client alone, nothing lies. The client answers ENOTSUPPORTED to the file media, icat falls back to streaming the bytes inline, and everything works.

To hit the bug you need a dishonest middle layer in front of a host that cannot do the thing, and that only happens in the three-layer stack. Each component was correct in the environment its author tested it in. That is why staring at any one of them got me nowhere.

Try the combinations yourself:

host outside the multiplexer
multiplexer passthrough
guest program
a=q,t=d inline pixels OK
a=q,t=t temp file OK
a=q,t=s shared memory OK
guest sends a file path
host receives the path, as sent
nothing drawn, nothing loggedThe yes was never checked. The browser drops the path and draws nothing.
Only one pairing fails: the old passthrough in front of a host that cannot read the server's files. Change either and the image appears, which is why each layer passed its own tests. The replies here are the passthrough's, not the host's: the guest never talks to the host directly.

The fix

Probe the host for file transmission alongside the direct probe, record the answer, and pass it on:

if isFileMedium(cmd.Medium) && !kp.hostReadsFiles() {
    ok = false
    errMsg = "ENOTSUPPORTED:host terminal cannot read files from this machine"
}

A guest that never asks is covered too. If a file transmission arrives and the host cannot read files, the multiplexer re-encodes it as direct data rather than forwarding a path that will be silently discarded. A file-capable host still gets the plain path with no extra copy, so the fast case stays fast.

There was a second bug hiding in the same function. Quiet mode has two levels, q=1 to suppress success responses and q=2 to suppress everything, and the code treated both as "say nothing". A guest that asked for q=1 and hit an error got silence, waited out its timeout, and never tried the next medium. An error has to reach a guest that only suppressed successes.

Ask, or say no

A proxy answering capability questions on behalf of something else has two honest options: ask the far end, or say no and let the guest fall back to the medium that always works. Saying yes because yes usually works gets you a failure that only appears when the layers are combined, only for people running unusual stacks, with nothing logged anywhere and every component pointing at its own passing tests.

What annoys me is that the multiplexer already had the machinery to handle this. A function that re-encodes file transmissions as inline data was sitting in the codebase, gated behind a build-specific flag instead of behind "can the host actually read files". I did not so much write the fix as move it.

It is the same protocol as the query bugs in an earlier post, with a new failure: there the answer went missing, and here it was made up. What tuios now tells programs about graphics, in a native terminal and in the browser, is on the architecture page and the web terminal page.