Skip to content

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

Description

@fashxp

Affected Version

2026.2 (analysed on a 2026.1 installation running pimcore/pimcore: 2026.x-dev).
The relevant code is identical on 2025.4 (LTS), 2026.2 and 2026.x of both
studio-ui-bundle and studio-backend-bundle, so all supported lines that ship the
Studio GlobalMessageBus are affected.

Affected capability

Core (Studio UI real-time updates / Mercure)

Steps to reproduce

  1. Log into Pimcore Studio and leave the browser tab open, without reloading, for slightly more
    than one hour.
  2. From a second session (another browser, the API, or a background job), trigger anything that
    publishes a live update to that user, for example:
    • create a notification for the user,
    • start a long running job such as a batch edit, an export or a ZIP download,
    • send a message in an Agent Chat session.
  3. Watch the long-open tab.

Server side verification, if the hub logs are available:

docker compose logs mercure | grep "New subscriber"

Authorised subscriptions log a topic_selectors field. Subscriptions that were accepted as
anonymous do not. After the one hour mark, every reconnect of the long-open tab logs without
topic_selectors.

Actual Behavior

Exactly 3600 seconds after the page was loaded, the tab's Mercure subscription silently becomes
anonymous and stops receiving every private update until the page is fully reloaded.

Measured on a real installation (docker compose logs mercure, subscriptions of one Studio tab,
user id 21):

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

In that second window the tab ran anonymous for about five hours across roughly 31 reconnects.

Mechanism:

  1. mercureAuthorization is minted exactly once per page load. app-loader.tsx calls
    fetchMercureCookie() before starting the bus (correctly, since
    Fix: private per-user Mercure updates dropped on first login (cookie set after subscription) studio-ui-bundle#3676), and that is the only caller of
    useMercureCreateCookieMutation in the whole bundle. Nothing renews the cookie afterwards.
  2. Its lifetime is 3600 seconds (HubService::createCookie(), $cookieLifetime = 3600), and the
    JWT inside carries a matching exp (LcobucciFactory with lifetime 0, which resolves to
    session.cookie_lifetime or 3600).
  3. The Mercure hub closes every SSE stream at its write_timeout (default 600s), so the browser
    reconnects roughly every nine minutes on its own. Measured on the same installation: 96 browser
    SSE connections, median duration 531s, maximum 593s. No sleep, network change or hub restart is
    needed.
  4. The first of those implicit EventSource reconnects after the one hour mark goes out without
    the cookie. With MERCURE_EXTRA_DIRECTIVES: anonymous (the value shipped in the Pimcore
    docker-compose.yaml) the hub answers 200 OK and authorises nothing.
  5. PublishService::publish() defaults to $private = true and no caller passes false, so
    every Studio update is a private update and is dropped for that subscriber.

Verified against a running hub, for both studio-backend-default and
studio-backend-default/user/{id}:

Subscriber Hub response Private update received
no cookie (anonymous) 200 OK no
valid subscriber JWT 200 OK yes
expired subscriber JWT 401 no

Without the anonymous directive the situation is not better, only louder: the hub returns 401,
AbstractMercureProcess goes to readyState === CLOSED and retries with the same dead cookie
through its exponential backoff, up to a 300s interval, forever. Neither path recovers without a
full page reload, because start() never re-mints the cookie.

Nothing surfaces the failure:

  • isConnected() is readyState === EventSource.OPEN, which is true for the anonymous stream.
  • A 200 never fires onerror, so no backoff and no restart is triggered.
  • The visibilitychange and online handlers in the global message bus loader only restart the
    process when it is disconnected, and a restart would reuse the expired cookie anyway.
  • The sendMessage({ type: 'error' }) emitted by AbstractMercureProcess has no consumer
    anywhere in the bundle.
  • useSessionPing keeps the PHP session alive indefinitely, so the REST API keeps working
    perfectly. Only the live layer dies, which makes the symptom look like a broken feature rather
    than an expired login.

Impact per feature, ordered by how visible it is:

Feature Lost Masked by
Agent Chat (pimcore-agent-bundle) every event after turn-started; the turn appears stuck in loading only its own record catch-up, after 20s to 40s, or the manual reload button
Notifications the live popup and the live unread counter refetchOnMountOrArgChange when the notification panel is opened, and page reloads
Job runs: batch edit, CSV/XLSX export, ZIP upload and download, clone, delete, patch, rewrite references, bulk import, ownership live progress and completion events JobRunPolling, which polls the API with an exponential backoff from 10s up to 300s
Job failures (FailureSubscriber, handleFinishedWithErrors) the error messages, which only exist in the Mercure payload polling still reports the terminal state, without the details

This is why the defect has gone unnoticed: the core's own consumers degrade to polling instead of
failing, so on any tab older than one hour every job in Studio is in fact driven by polling alone.
Third party bundles that publish to the per-user topic and have no polling fallback lose their
updates outright.

Expected Behavior

A Studio tab keeps receiving private Mercure updates for as long as it is open and authenticated,
just as it keeps its PHP session alive through useSessionPing.

Suggested direction for a fix, all in studio-ui-bundle plus one small addition in
studio-backend-bundle:

  1. Let the client know when its authorisation expires. Preferably by returning
    {"expiresAt": <unix timestamp>} from JwtController::auth() instead of an empty 200, so
    there is a single source of truth that stays correct when cookie_lifetime is overridden.
    Alternatively expose the lifetime next to mercureUrl in the app config.
  2. Refresh the cookie on every reconnect. The implicit EventSource reconnect is what goes out
    unauthenticated, and JavaScript never sees it, so AbstractMercureProcess should take that
    reconnect over: on onerror with readyState === CONNECTING, close the source, refresh the
    cookie, then call start() again. start() already re-sends lastEventID from
    sessionStorage, so nothing is lost by dropping the native Last-Event-ID handling. The
    readyState === CLOSED branch (the 401 case) needs the same refresh before its backoff retry.
  3. Add a proactive refresh timer at roughly 80% of the lifetime, modelled on use-session-ping.ts,
    as a backstop for hubs where write_timeout is disabled and the stream outlives the cookie.
  4. Stop treating "connected" as "working": track whether the current connection was authorised, let
    the visibility and online handlers restart on !isAuthorized(), and give the existing
    type: 'error' bus message a consumer so a dead live channel becomes visible.
  5. Test coverage: the current unit tests mock the bus, which is the same blind spot that let
    Fix: private per-user Mercure updates dropped on first login (cookie set after subscription) studio-ui-bundle#3676 ship. A browser based test that moves past the cookie expiry and
    asserts that a private per-user publish still arrives would catch both defects.

Related: pimcore/studio-ui-bundle#3676 fixed the same silent anonymous end state for the
first login ordering race. This is the same failure mode with a different trigger, so it is worth
covering both with one regression test.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Fields

    Affected capability

    None yet

    Platform Version

    None yet

    Galaxy

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions