For two weeks, a bug report on my open-source project said the same thing in slightly different words: a tab receipt worked, and a minute later it didn't.
The project is Safari MCP, an MCP server that lets AI agents drive your real, logged-in Safari on macOS. When an agent opens a tab, the Safari Web Extension hands it a receipt, an opaque token that pins every later call to that exact tab. A receipt is documented to survive reconnects and restarts. In issue #143 it didn't survive two minutes:
Tab safety: this extension has no record of that receipt.
The tab was still open. The agent just couldn't reach it anymore, and it couldn't close it either, because closing a tab you can't prove is yours is how you close the user's tab instead.
I had a theory, and it was wrong. The real cause was something I had never seen described anywhere: Safari keeps the extension context of every app generation it has ever loaded. Reinstalling the extension app doesn't replace the running extension. It adds another one.
The theory that didn't hold
A Safari Web Extension's background worker is not long-lived. Safari stops it when idle and starts it again on the next event. My extension keeps its receipts in storage.local, tagged with a "browser session epoch" so that receipts from a previous Safari launch are thrown away. The epoch lived in storage.session.
So the first theory was: Safari clears storage.session every time it terminates the worker, the epoch rotates, and every receipt from the old epoch is dropped.
That's partly true. Every worker instance does get its own storage.session. But it didn't explain the data. I read the profile's LocalStorage.db and its WAL read-only and listed every envelope the extension wrote. In five minutes on 13 October there were four different epochs. Four envelopes were written in the same millisecond, each under a different epoch.
One worker restarting doesn't write four envelopes in one millisecond. Several workers alive at the same time do.
What was actually running
safari_doctor (the project's diagnostic tool) already counted worker connections. In one five-minute window, 16 workers had connected to the daemon. The extension keeps a one-minute keepalive alarm, so I expected roughly one worker per minute. I was getting three.
Then I looked at what the Safari process had mapped. It held resources from about a dozen different builds of the extension app. Some of those builds had already been deleted from disk.
That machine had been through a lot of extension reinstalls, because I build the extension from source while developing it. Each reinstall registered a new app. Safari started the new one and never stopped the old ones. Every one of those contexts:
- wakes on its own one-minute alarm as a fresh worker instance,
- connects to the local daemon,
- verifies itself, and
- competes for the daemon's single lease, the right to receive commands.
About half of them were older generations, running code I had long since fixed. The other half were current, and even those couldn't agree with each other: each context has tab and window ids of its own. A receipt minted in one context resolves only in that context. When a different context wrote the shared envelope, it wrote back only the records it could resolve, which could be none. I watched the envelope go empty after a single close_tab from a different session.
So a receipt "expired" whenever the lease moved to a context that had never heard of it.
The fix that didn't work
My first fix was the obvious one: when a worker that doesn't announce the current build takes the lease, tell it to reload. A reload restarts the extension from the app on disk, so the old code should be gone.
It wasn't. Reloading an old context restarts that same old generation. In 90 minutes the daemon sent 45 reloads and never ran out of old contexts to reload.
What shipped instead
The daemon now decides who leads, instead of hoping the old contexts go away. This is in v2.22.17:
- The extension announces its build when it connects. A worker that announces no build never takes the lease while a current one has verified in the last 15 minutes, and a current worker takes the lease from an old holder at once.
- Current contexts are told apart by extension origin. The first current context to lead becomes the home context, and no other origin takes the lease while the home context is alive. The daemon logs every handover, so the next time this happens there is a line that says which context lost.
- The new build stores its state under storage keys no older build touches. An old context that is still alive can't overwrite what the current one stored.
-
The daemon names the browser session, not
storage.session. It sends the identity of the running Safari process with every response, so every worker instance agrees on the epoch and only a real Safari restart changes it.
And the honest part: the only thing that actually removes the old contexts is quitting Safari and opening it again. Reinstalling, reloading, and disabling and re-enabling the extension don't.
What's still open
The issue is still open. One context can still write an envelope without records it can't resolve. The real fix is merge-on-write, so a write never drops what it doesn't understand, and I'm keeping #143 open until that lands and a full day passes with no refusal on release code.
If you ship a Safari Web Extension
I can't tell you this is how Safari behaves on every machine. I measured it on one Mac after many developer reinstalls. A user who installs once from the App Store may never see two generations side by side. But a few things are worth checking in your own extension:
-
Don't assume one background context. If you share state through
storage.local, more than one live instance of your extension may be writing it, some running older code. - Version your storage keys when the shape changes, so an older generation can't overwrite a newer one's data.
-
Don't trust
storage.sessionas a session identity. Each worker instance gets its own. - If your extension talks to a native app or a local server, let that side decide which instance is authoritative. It's the only party that sees all of them.
- Tell users who see strange state after an update to quit Safari once. It sounds like folk advice. Here it's the documented fix.
The full timeline, with the storage epochs and the daemon log lines, is in issue #143, and the changes are in the changelog. If you've seen the same thing in your own extension, or you know where Safari documents this, I'd like to hear about it in the comments.
Top comments (0)