The Case of the Vanishing Blink
October 20, 2026
For years, picking an item on the Phosphor main menu did a small, satisfying thing: the row flashed — inverted video for about 150 milliseconds — and then the next screen took over. It’s a 65-byte courtesy. Two tiny frames: highlight the row, unhighlight the row, go.
Then one day the flash was just gone in Terminal.app’s telnet client. Not intermittently. Gone. MuffinTerm still showed it. The web client showed it, but only one flash instead of two. Three different clients, three different answers, one unchanged server. By the time we sat down to hunt it, every standard explanation had already been eliminated: the code was untouched since the feature shipped, the server was provably running the current build, the user’s preferences and theme tested innocent, and the same binary rendered the blink perfectly against a test instance. Same client, same server, same account. Different port. Blink on one, gone on the other.
The suspect that wasn’t
The first theory was session state: logins reattach to your last screen (Phosphor persists your session the way tmux does — disconnect, and the exact window you were in waits for you). A stale reattach, went the theory, could land you somewhere the blink doesn’t happen, or eat the input that triggers it.
So the ask was: exit fully with Q — not disconnect, log out, which tears the persisted session down — and reconnect fresh. The user did, as both a regular account and sysop. Nothing changed. Dead end, definitively.
Which was almost worse. Session state was the last suspect that could explain a difference between two instances of identical code. Everything else — window size, theme, animations, reattach behavior, the exact telnet binary — had already been re-tested and passed. When every variable you can name tests innocent, and the code is byte-identical, there are only two possibilities left: the bug is state you haven’t identified yet, or the difference is in how the two instances were observed.
The client had been making a promise
The thread that finally pulled was a detail everyone had walked past, including us, several times. Modern terminal emulators can do synchronized updates — DEC mode 2026. The server brackets a chunk of screen updates between two escape sequences (BSU/ESU) and says to the client: everything I just sent belongs together; don’t let the user’s eyes catch it half-painted.
Terminal.app supports this. tmux mostly ignores it. That difference is exactly the symptom: the web client and MuffinTerm painted whatever arrived, promptly. Terminal.app kept the deal.
Phosphor’s telnet server probes every client at login: do you support synchronized updates? And homebrew’s telnet — the client on the affected machine — answers something remarkable. Not “no.” Not even “yes.” It answers the strict form of the query that means: “mode exists on this terminal, and it’s available.” That’s a yes, and the server had been bracketing the user’s frames for their whole session.
Wait — but the blink deltas weren’t bracketed. We had the captures: the two highlight frames went out bare, no brackets around them. So bracketing couldn’t be the villain. Except that conclusion was built on a capture from a client that never declared support. The blink frames reaching this user were bracketed, because their client said yes — and once we replayed the login honestly (a probe that answers the capability query the way a real Terminal.app does), the wire told the same story: two 65-byte highlight frames, wrapped, atomic, bracketed — followed roughly a hundred milliseconds later by the flood of frames that paints the next screen.
What a terminal does with a promise
Here’s the part that turns a standards-compliant optimization into a vanished feature. Bracketed updates are a paint-cycle contract. Terminals draw on their own clock — roughly 16 milliseconds a frame at normal refresh. A synchronized region is a set the renderer is entitled to commit atomically — and nothing obliges it to show every set it receives before the next one lands. If the next frame follows within a couple of paint cycles, an intermediate state can get absorbed into the repaint and never shown.
Understand what kind of state the blink carries: it exists for 150 milliseconds and then contradicts itself. Highlight on, then off, then a different screen entirely. Any update that supersedes its predecessor can be coalesced client-side — that’s usually a feature, and for persistent state it’s invisible, because the final value always paints. The blink is the one kind of update where coalescing is not lossless compression but outright information destruction: the server told you something, briefly, and the terminal never showed it. Nagle’s algorithm for packets, the same shape: batch the small stuff for efficiency, and discover that some of the small stuff was the whole message.
MuffinTerm doesn’t implement 2026, so it painted every frame immediately and showed the blink. The web client does, via a bridge that delivers the bracketed frames to xterm.js — and xterm’s renderer showed one flash of the two. Terminal.app does it most thoroughly of all, and showed zero. Three clients, one protocol, three degrees of the same coalescing.
The fix: an opt-out for states that die young
The bracket policy in the jterm toolkit had one honest trade at its core: atomicity is worth it for big updates, where a tear means half an old screen painted over a new one. It had three gates — frame kind, client capability, and size. Small deltas were already exempt: anything under 16 changed cells paints immediately, on the reasoning that a couple of cells can’t tear meaningfully and latency (chat echo, command reflection) beats atomicity.
The blink fell in a dead zone: about 20 cells. Too big for the small-delta exemption, far too small to hit the big-frame cap. It got bracketed, because the size rule said so — and the size rule had no way of knowing that these particular 20 cells would be dead in 150 milliseconds.
The fix is not a threshold adjustment. It’s a semantic one, and it fit in three small pieces:
// jterm: the toolkit learns that some refreshes are transient
public void setSyncBracketSuppressed(boolean suppressed);
// the GUI delegates down to the screen
gui.setSyncBracketSuppressed(true);
try {
render(); // highlight ON — paints immediately, unbracketed
Thread.sleep(BLINK_ON_MS);
render(); // highlight OFF — same
} finally {
gui.setSyncBracketSuppressed(false);
}
The suppression window opens around exactly the two blink renders and closes itself. Every other frame in the system brackets exactly as before — the size policy is untouched, because bending it would have unbracketed every small-but-persistent delta in the codebase, status bars included. The exception is declared by the caller, at the one place that knows the difference between a state that will persist and a state that will be dead before anyone’s eyes can find it.
Verified three ways: a unit law (a mid-size delta brackets by default, paints bare inside the window, brackets again after it closes), the full suites on both layers of the stack (over 11,000 tests, all green), and the wire itself — a probe client that negotiates like Terminal.app, logged in, pressed a menu key, and captured the two highlight frames arriving with their brackets gone, transition frames untouched.
The bill, in full
What did the trade cost? Two frames per menu selection lose atomicity — 65 bytes each, a couple of cells, painted into a quiet moment right before a full repaint. If a client catches one mid-frame, it’s a one-row flicker for a single paint cycle, and the OFF phase plus the next screen heal it immediately. The failure mode flipped from invisible — a user standing in front of input the server acknowledged and the screen never showed — to transient and self-healing.
The general lesson is the one that cost a week to learn:
Intermediate states are the only things coalescing can destroy, and they’re also the only things users watch for. Persistent state survives any amount of client-side batching, because the final value always paints. Transient state — a flash, a caret, a highlight — exists to be seen for an instant. If your protocol has an optimization that can merge visual updates, transient states need an exemption, or you’ll ship the feature that works everywhere except on the most standards-compliant client your users have.
And a smaller, sharper one for anyone standing where we stood: when the same code behaves differently in two places, audit how the difference is being observed, not just the state you’re comparing. Our test clients ignored a standard that the user’s client obeyed. The bug was never in the blink. It was in the promise the client had made — and in us, for replaying the negotiation without listening to the answer.