Renew the Mercure authorization instead of reconnecting without it - #4009
Renew the Mercure authorization instead of reconnecting without it#4009fashxp wants to merge 4 commits into
Conversation
The cookie was minted once per page load and lives an hour, while the hub closes every stream on its write timeout. After an hour the browser's own reconnect therefore subscribed anonymously, which a hub that allows anonymous subscribers accepts silently, and every private update was dropped until a full reload. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Renews Mercure authorization during reconnects to prevent private live updates from silently stopping after cookie expiry.
Changes:
- Adds authorization renewal and lifetime-based scheduling.
- Takes control of EventSource reconnection with race protection.
- Adds unit tests for reconnect and renewal behavior.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
abstract-mercure-process.ts |
Renews authorization before reconnecting. |
abstract-mercure-process.test.ts |
Tests reconnect and cancellation behavior. |
mercure-authorization.ts |
Manages authorization renewal scheduling. |
mercure-authorization.test.ts |
Tests authorization lifetime and scheduling. |
global-message-bus/loader.ts |
Starts background authorization renewal. |
app-loader.tsx |
Uses shared authorization renewal before bus startup. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| .finally(() => { | ||
| scheduleRenewal() | ||
| }) |
There was a problem hiding this comment.
Correct, fixed in 32f02c9. The reschedule is now guarded by a renewalScheduleActive flag that stop() clears, rather than by the timeout handle, so stopping takes effect even mid-request. Regression test: "stops renewing when cancelled while a renewal is in flight".
| } | ||
|
|
||
| public start (): void { | ||
| const generation = ++this.connectionGeneration |
There was a problem hiding this comment.
Agreed, fixed in 32f02c9. start() stays synchronous (it is the public process API) but no longer opens anything itself: it bumps the generation and delegates to a private connect(), which checks the authorization first and only then calls openStream(). Every path that reaches a connection - initial subscription, visibility change, online, and the backoff reconnect - now goes through it, so no EventSource can be created without that check.
To avoid a second cookie request on app load (the app loader has just fetched one), connect() skips the renewal while isAuthorizationFresh() holds, i.e. inside the first 80% of the lifetime, the same threshold the background schedule uses. Tests: "renews the authorization before opening the stream" and "skips the renewal while the authorization is still fresh".
| try { | ||
| await renewMercureAuthorization() | ||
| } catch { | ||
| // Reconnecting with the cookie we already hold still beats staying silent, and the backoff | ||
| // covers a backend or hub that stays unreachable. | ||
| } |
There was a problem hiding this comment.
Good catch, and the sharpest of the three - fixed in 32f02c9. The fallback is now conditional on isAuthorizationValid():
- renewal fails but the cookie is still valid → connect with it, as before;
- renewal fails and the cookie has expired → do not open a stream at all, and retry the renewal on the backoff instead.
That closes exactly the path you describe: without it, the anonymous 200 OK resets the backoff in onopen and the tab sits on a healthy looking, permanently silent stream. Test: "opens no stream at all when the renewal fails and the cookie has expired" asserts zero EventSource instances, then one after the retry succeeds.
The module now tracks authorizedUntil from the lifetime the server returns, which is what makes both this and the freshness check possible, and concurrent renewals share a single request.
Review found three holes: start() opened directly from the visibility, online and initial paths without renewing; a failed renewal still connected, which an anonymous-enabled hub accepts and starves silently; and stopping the background renewal did not take while a request was in flight. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|



Fixes pimcore/platform-version#346.
Problem
A Studio tab stops receiving every live update exactly one hour after the page was loaded, and
does not recover until the page is fully reloaded. Notifications, job progress (batch edit, export,
ZIP, clone, delete, bulk import, ...) and agent chat are all affected; jobs and notifications hide
it behind their polling and refetch-on-mount fallbacks, agent chat has none and simply hangs.
Measured on a real installation (
docker compose logs mercure, one tab, user 21; authorisedsubscriptions log
topic_selectors, anonymous ones do not):Mechanism:
mercureAuthorizationis minted once per page load.app-loader.tsxfetches it beforestarting the bus (correctly, since Fix: private per-user Mercure updates dropped on first login (cookie set after subscription) #3676), and that was the only call site. Nothing renewed it.
HubService::createCookie()), and the JWT inside carries a matchingexp.write_timeout(600s by default), so the browserreconnects on its own roughly every nine minutes. Measured: 96 connections, median 531s, max
593s. No sleep, network change or hub restart needed.
whatever cookie exists at that moment. After the one hour mark there is none, and a hub with
MERCURE_EXTRA_DIRECTIVES: anonymousanswers200 OKand authorises nothing.PublishService::publish()defaults to$private = true), so all of them are dropped for that subscriber.Nothing surfaces it:
isConnected()isreadyState === OPEN, which is true; a200never firesonerror, so no backoff and no restart; the visibility/online handlers only restart adisconnected process, and a restart would have reused the expired cookie anyway. Meanwhile
useSessionPingkeeps the PHP session alive indefinitely, so the REST API keeps working perfectlyand only the live layer dies.
Change
Take the reconnect over from the browser and re-authorise on every attempt.
AbstractMercureProcessnow closes the source ononerror(bothCONNECTING, the ordinarywrite_timeoutdrop, andCLOSED, a refusal) and reconnects itself through the existing backoff,after requesting a fresh cookie. This is the actual fix: renewing immediately before a connect is
the only moment guaranteed to be early enough. A
connectionGenerationcounter drops a pendingreconnect that
start()orcancel()has superseded, so a renewal in flight can never open asecond stream. Failure to renew still reconnects with the cookie we hold, since that beats staying
silent, and the backoff covers a backend that stays unreachable.
New
modules/app/mercure/mercure-authorization.tsowns the cookie: onerenewMercureAuthorization()used by both the app loader and the reconnect path, plus a background renewal at 80% of the
lifetime. The timer is a backstop for hubs configured without a write timeout, where a single
connection can outlive the cookie; with a write timeout it never fires, because reconnects renew
first.
app-loader.tsxfetches the first cookie through the same call, which is also what teaches theschedule the configured lifetime instead of the fallback.
The lifetime comes from
pimcore/studio-backend-bundle#2012, which returns{"cookieLifetime": N}from
POST /mercure/auth. It is read defensively and falls back to one hour, so this PR worksagainst a backend without that change and the two can be merged in either order. The generated
mercure-api-slice.gen.tsstill types the response asunknown; it will pick up the real type atthe next
build-api-clientrun and nothing here depends on that happening.Verification
abstract-mercure-process.test.ts(renews before reconnecting a dropped stream, reconnects andreports a refused one, reconnects anyway when the renewal fails, does not reconnect after
cancel(), drops a superseded reconnect) andmercure-authorization.test.ts(learns thelifetime, keeps the last known one, renews at 80%, survives a failed renewal, idempotent start,
stop)
npm testgreen: 42 suites, 356 testsnpm run check-typescleannpm run lintcleandunglas/mercure: nocookie gives
200 OKwith the private update not delivered, a valid JWT gets it, an expiredone gives
401.Not verified in a browser end to end: doing so takes a >1h old tab, which is what the unit tests
stand in for.
See also
pimcore/demo-enterpriseandpimcore/skeletondropMERCURE_EXTRA_DIRECTIVES: anonymous, soan unauthorised subscription fails loudly with
401instead of silently receiving nothing. Withthis PR the client recovers from that
401on its own.