You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
Log into Pimcore Studio and leave the browser tab open, without reloading, for slightly more
than one hour.
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.
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):
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).
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.
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.
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
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:
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.
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.
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.
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.
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.
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.2and2026.xof bothstudio-ui-bundleandstudio-backend-bundle, so all supported lines that ship theStudio
GlobalMessageBusare affected.Affected capability
Core (Studio UI real-time updates / Mercure)
Steps to reproduce
than one hour.
publishes a live update to that user, for example:
Server side verification, if the hub logs are available:
Authorised subscriptions log a
topic_selectorsfield. Subscriptions that were accepted asanonymous 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):
In that second window the tab ran anonymous for about five hours across roughly 31 reconnects.
Mechanism:
mercureAuthorizationis minted exactly once per page load.app-loader.tsxcallsfetchMercureCookie()before starting the bus (correctly, sinceFix: private per-user Mercure updates dropped on first login (cookie set after subscription) studio-ui-bundle#3676), and that is the only caller of
useMercureCreateCookieMutationin the whole bundle. Nothing renews the cookie afterwards.HubService::createCookie(),$cookieLifetime = 3600), and theJWT inside carries a matching
exp(LcobucciFactorywith lifetime0, which resolves tosession.cookie_lifetimeor 3600).write_timeout(default 600s), so the browserreconnects 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.
EventSourcereconnects after the one hour mark goes out withoutthe cookie. With
MERCURE_EXTRA_DIRECTIVES: anonymous(the value shipped in the Pimcoredocker-compose.yaml) the hub answers200 OKand authorises nothing.PublishService::publish()defaults to$private = trueand no caller passesfalse, soevery Studio update is a private update and is dropped for that subscriber.
Verified against a running hub, for both
studio-backend-defaultandstudio-backend-default/user/{id}:200 OK200 OK401Without the
anonymousdirective the situation is not better, only louder: the hub returns401,AbstractMercureProcessgoes toreadyState === CLOSEDand retries with the same dead cookiethrough 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()isreadyState === EventSource.OPEN, which istruefor the anonymous stream.200never firesonerror, so no backoff and no restart is triggered.visibilitychangeandonlinehandlers in the global message bus loader only restart theprocess when it is disconnected, and a restart would reuse the expired cookie anyway.
sendMessage({ type: 'error' })emitted byAbstractMercureProcesshas no consumeranywhere in the bundle.
useSessionPingkeeps the PHP session alive indefinitely, so the REST API keeps workingperfectly. 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:
pimcore-agent-bundle)turn-started; the turn appears stuck in loadingrefetchOnMountOrArgChangewhen the notification panel is opened, and page reloadsJobRunPolling, which polls the API with an exponential backoff from 10s up to 300sFailureSubscriber,handleFinishedWithErrors)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-bundleplus one small addition instudio-backend-bundle:{"expiresAt": <unix timestamp>}fromJwtController::auth()instead of an empty200, sothere is a single source of truth that stays correct when
cookie_lifetimeis overridden.Alternatively expose the lifetime next to
mercureUrlin the app config.EventSourcereconnect is what goes outunauthenticated, and JavaScript never sees it, so
AbstractMercureProcessshould take thatreconnect over: on
onerrorwithreadyState === CONNECTING, close the source, refresh thecookie, then call
start()again.start()already re-sendslastEventIDfromsessionStorage, so nothing is lost by dropping the nativeLast-Event-IDhandling. ThereadyState === CLOSEDbranch (the401case) needs the same refresh before its backoff retry.use-session-ping.ts,as a backstop for hubs where
write_timeoutis disabled and the stream outlives the cookie.the visibility and online handlers restart on
!isAuthorized(), and give the existingtype: 'error'bus message a consumer so a dead live channel becomes visible.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.