A window
that could not paint.
Waiting for the frame after this one is the standard way to know a page has rendered. It deadlocks if the window is hidden — and hiding the window until it has rendered is the standard way to avoid a white flash.
Reports came in on two consecutive releases saying the same thing: opening Ampersand does nothing. Click the icon, wait, nothing happens. Click the Dock icon a second time and the window appears.
That second click is the tell. It means the process was running the whole time. The application had launched perfectly and then sat there, invisible, for ten seconds.
- invisible, on an affected launch
- 10 sinvisible, on an affected launchthe failsafe delay; measured firing 10.3–10.6 s after the launch log line
- the deadline that replaced it
- 150 msthe deadline that replaced ita real painted frame still wins whenever one arrives
- releases reported from the field
- 2releases reported from the fieldv0.2.28 and v0.2.29 — the bug was intermittent, so most launches looked fine
Why the window starts hidden
Ampersand’s window is transparent, drawn over one of macOS’s native background materials, so the chrome picks up whatever is behind it. That means the window is created with "visible": false and something has to reveal it once the interface is ready — as it has been since the first commit, for the ordinary reason: a window that appears before its contents exist is a white rectangle, and a white rectangle is worse than a slightly later window.
So the question is only ever when. And the standard answer — the one you will find in every discussion of this problem — is to wait for a painted frame. React having committed is not the same as pixels existing, so you schedule an animation frame callback, and inside it schedule another. The second one runs after the browser has produced a frame. Then you show the window.
That reasoning is correct. It is also, in this exact situation, a deadlock.
the frontend: wait for a painted frame, then show the window
the window: cannot paint until it is shown
// requestAnimationFrame(() =>
// requestAnimationFrame(() => showWindow()))
//
// callback 1: sometimes runs.
// callback 2: does not. WebKit suspends a page's
// scripted-animation callbacks while its
// NSWindow is off screen.
WebKit suspends a page’s scripted-animation callbacks while its window is off screen. That is a sensible optimisation — there is no point driving animations nobody can see — and it is fatal here, because it makes the two halves of the launch depend on each other. The frontend waits for a paint that cannot happen until the window is shown. The window is not shown until that paint.
We had a failsafe for exactly this shape of problem: if the frontend has not revealed the window within a fixed delay, the native side reveals it anyway. That delay had been half a second. In the same change that introduced the frame-based reveal, it was raised to ten seconds — reasonably, since the new signal was expected to be slower. The combination turned a deadlock into a ten-second stare at a Dock icon.
It worked often enough to ship
The reason this got past us is that it was intermittent. A minority of launches revealed normally — the animation frame callbacks sometimes do run before the page is marked hidden, and when they do everything behaves. Run the app on your own machine a handful of times during development and you may well never see it. There is no error, no exception, no failed promise. The app just sometimes takes a while.
Our browser-based end-to-end suite could not see it either, and for a structural reason rather than a missing assertion: it drives a visible page with no native window lifecycle at all. There is no hidden window in that environment, so the deadlock cannot occur there. This is the second time we have written that sentence about that suite — the first was a security-policy bug it was equally incapable of reproducing.
The app had been reporting it all along
Ampersand keeps a structured local log — daily files, kept for a week, on your machine and nowhere else. It is the same log a diagnostics bundle carries if you send us a bug report. It had been printing the answer on nearly every launch since the change shipped.
# the app's own log, on nearly every launch, for weeks
09:14:02.118 INFO launch: logging initialized
09:14:12.464 WARN launch: frontend did not reveal the window
within 10s -- revealing via fallback
# 10.346 seconds. that is the whole bug, printed,
# in a file we ship a reader for.
# after the fix, a healthy launch:
08:02:41.902 INFO launch: logging initialized
(no warning -- window on screen ~150ms later)
A warning ten-point-something seconds after the launch line, over and over. Nobody had looked, because nothing was crashing.
The useful property is that this makes the fix verifiable: the absence of that warning is the test. One caveat we had to learn — it has to be checked in a packaged build. A development build shares its application-support directory with an installed copy, so running both at once produces a race on the session file and a launch that tells you nothing.
The fix is a race, not a replacement
The obvious repair is to delete the frame-based reveal and show the window on a timer. We did not do that, because the original reasoning was right: when a frame really is available, revealing on it is better than revealing on a guess.
So both signals now run, against each other, and whichever arrives first wins.
// two signals. whichever arrives first wins; the loser is a no-op.
requestAnimationFrame(() =>
requestAnimationFrame(reveal), // a real frame, when one can happen
);
setTimeout(reveal, 150); // a deadline, when one cannot
// reveal() is idempotent -- it shows the window once and
// returns immediately every time after that. a real frame is
// still preferred when one arrives; when none can, the wait
// is 150ms instead of ten seconds.
Revealing without a confirmed paint is only safe because of something we had already done for an unrelated reason: a few lines of CSS inlined in the HTML shell paint the document transparent from the first byte. So an unpainted reveal shows the window’s native material, not white. That is the same inline style tag that broke the app’s Content Security Policy — it caused one bug and it is load-bearing for the fix to another, which is a fair summary of how these things go.
The regression tests stub the animation frame API so it never calls back and assert the window is still revealed; then let frames arrive and assert it happens on the painted frame; then fire both and assert it happens exactly once.
The second bug on the same seam
With the deadlock gone, a subtler version of “it didn’t open” remained: the window would appear behind whatever the user was looking at.
Showing a window and activating an application are different operations, and it is easy to assume the first implies the second. Underneath, showing a window is AppKit’s makeKeyAndOrderFront:, which orders the window to the front within its application but never brings the application forward. Bringing the application forward is activateIgnoringOtherApps:, which our show-the-window path did not call.
Normally that does not matter, because launching an app activates it. But our window is hidden at launch, and by the time the reveal happens the launch activation may already have lapsed. The window then arrives correctly, on screen, underneath a browser — which to the person watching is indistinguishable from nothing happening.
This also explains a detail that had been confusing us: every other path into the window always worked. The Dock icon of a running app, opening a file with it, the menu-bar item, a second launch — all four already did show, unminimise and focus together. Only the launch path did two of the three.
The third bug, which the fix created
Then someone wrote to say that Ampersand had started opening itself while they were working in another app.
Ampersand checks for updates in the background and installs them silently. Installing one replaces the running process — and to every other line of code, the replacement is an ordinary cold launch. So it ran the ordinary reveal, which we had just made fast and had just taught to activate the application. An update landing mid-sentence yanked the editor to the front. Worse, closing the main window on macOS only hides it, so an update could re-open a window somebody had deliberately closed.
Our reveal fix did not create this — relaunching already brought the app forward — but it sharpened it considerably, from a ten-second unfocused reappearance to a 150 ms one that steals the keyboard.
The underlying problem is that not every launch is one the user asked for, and a launching process cannot tell the difference. So the process that is about to be replaced writes down what the app looked like — was the main window visible, was the app frontmost — and leaves a small marker for its successor: stay hidden, show without focus, or activate normally. The replacement reads it once and behaves accordingly. Every other route into the window is untouched and still shows, unminimises and focuses, because those really are the user asking.
Two details that are not optional
What we took from it
- Do not gate a reveal on a paint alone. If the thing you are waiting for can only happen after the thing you are waiting to do, you have written a deadlock, however idiomatic each half looks in isolation.
- Prefer a race to a replacement. The better signal is still better when it arrives. Running it against a deadline keeps the good case and bounds the bad one, and costs one idempotency check.
- An intermittent bug with no error is the most shippable kind. It works often enough on the developer’s machine to look fine, and it produces no artifact anyone would go looking for.
- Read your own logs. Ours had been naming the failure, with a timestamp, on nearly every launch for weeks. A log nobody checks is a note left for a person who never arrives.
- Showing a window is not activating an app, and the reverse of that asymmetry — an app that activates itself when nobody asked — is a worse bug than the one you were fixing.
The technical specifications cover how the rest of the app is put together, and the bug that argued for its own dismissal is a companion post about a different launch failure — one that happened before a single line of our code could run.
Built carefully.
Fixed in public.
Ampersand is a native markdown editor that keeps your notes as plain files on your own computer. Try it, and hold us to this standard.
Free to start · Mac, Windows & Linux · No account required