Skip to content

Renew the Mercure authorization instead of reconnecting without it - #4009

Open
fashxp wants to merge 4 commits into
2026.2from
fix/pv346-mercure-cookie-renewal-2026.2
Open

Renew the Mercure authorization instead of reconnecting without it#4009
fashxp wants to merge 4 commits into
2026.2from
fix/pv346-mercure-cookie-renewal-2026.2

Conversation

@fashxp

@fashxp fashxp commented Aug 20, 2026

Copy link
Copy Markdown
Member

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; authorised
subscriptions log topic_selectors, anonymous ones do not):

08-19 13:50:32  authorized   <- page load
08-19 14:50:33  anonymous    <- exactly +3600s
08-20 06:55:05  authorized   <- reload
08-20 07:55:05  anonymous    <- exactly +3600s
08-20 12:48:24  authorized   <- reload

Mechanism:

  1. mercureAuthorization is minted once per page load. app-loader.tsx fetches it before
    starting 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.
  2. Its lifetime is 3600s (HubService::createCookie()), and the JWT inside carries a matching
    exp.
  3. The hub closes every SSE stream on its write_timeout (600s by default), so the browser
    reconnects on its own roughly every nine minutes. Measured: 96 connections, median 531s, max
    593s. No sleep, network change or hub restart needed.
  4. That reconnect is implicit - the browser does it, JavaScript never sees it - and it reuses
    whatever cookie exists at that moment. After the one hour mark there is none, and a hub with
    MERCURE_EXTRA_DIRECTIVES: anonymous answers 200 OK and authorises nothing.
  5. Every Studio update is published private (PublishService::publish() defaults to
    $private = true), so all of them are dropped for that subscriber.

Nothing surfaces it: isConnected() is readyState === OPEN, which is true; a 200 never fires
onerror, so no backoff and no restart; the visibility/online handlers only restart a
disconnected process, and a restart would have reused the expired cookie anyway. Meanwhile
useSessionPing keeps the PHP session alive indefinitely, so the REST API keeps working perfectly
and only the live layer dies.

Change

Take the reconnect over from the browser and re-authorise on every attempt.
AbstractMercureProcess now closes the source on onerror (both CONNECTING, the ordinary
write_timeout drop, and CLOSED, 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 connectionGeneration counter drops a pending
reconnect that start() or cancel() has superseded, so a renewal in flight can never open a
second 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.ts owns the cookie: one renewMercureAuthorization()
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.tsx fetches the first cookie through the same call, which is also what teaches the
schedule 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 works
against a backend without that change and the two can be merged in either order. The generated
mercure-api-slice.gen.ts still types the response as unknown; it will pick up the real type at
the next build-api-client run and nothing here depends on that happening.

Verification

  • 12 new unit tests, all of which fail against the previous behaviour:
    abstract-mercure-process.test.ts (renews before reconnecting a dropped stream, reconnects and
    reports a refused one, reconnects anyway when the renewal fails, does not reconnect after
    cancel(), drops a superseded reconnect) and mercure-authorization.test.ts (learns the
    lifetime, keeps the last known one, renews at 80%, survives a failed renewal, idempotent start,
    stop)
  • npm test green: 42 suites, 356 tests
  • npm run check-types clean
  • npm run lint clean
  • The hub side of the diagnosis was verified directly against a running dunglas/mercure: no
    cookie gives 200 OK with the private update not delivered, a valid JWT gets it, an expired
    one 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-enterprise and pimcore/skeleton drop MERCURE_EXTRA_DIRECTIVES: anonymous, so
    an unauthorised subscription fails loudly with 401 instead of silently receiving nothing. With
    this PR the client recovers from that 401 on its own.

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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +82 to +84
.finally(() => {
scheduleRenewal()
})

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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".

Comment on lines +120 to +125
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.
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

fashxp and others added 2 commits August 21, 2026 12:00
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>
@sonarqubecloud

Copy link
Copy Markdown

@fashxp
fashxp requested a review from markus-moser August 21, 2026 11:59
@jcPimcore jcPimcore modified the milestones: 2026.2.7, 2026.2.8 Aug 25, 2026

@markus-moser markus-moser left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Studio UI] Mercure subscription silently falls back to anonymous 1 hour after page load, all live updates stop

4 participants