enhancement
major
minor
major
minor
major
#29869
A React view window that lost its SSE connection keeps every later KB revision in memory until its session ends
An application with an AI agent that commits often grows its heap without bound while a user has a disconnected view window: in the application Pietro about 40 update-chain links per chat turn stay reachable, and evaluation runs grew the post-GC heap by about 165 MB/h. Found in Openium/Pietro#772.
Problem
Observed: after a browser tab of a /view/ page is gone without an unload (tab killed, browser closed, network lost) or after its session ended, UpdateChainLink`s are retained from that window's last position on: one link per later commit, with the `UpdateEvent and the object values of each revision (in Pietro a 190 KB chat-session JSON per agent step). A heap dump of a small reproduction had 227 live links (rev 832-945); 10 of 11 window cursors and 5 of 6 user TLContext._revision values were stale.
Expected: a window that is not connected holds no position in the update chain, or loses it after a grace period; a closed window's tree and sub-session are released.
Reproduce:
- Log in, open a view page (/view/...), then close the browser without unloading the page (or kill the tab).
- In another session, commit many changes (an AI agent turn makes about 40 commits).
- jcmd <pid> GC.class_histogram | grep UpdateChainLink grows by one per commit and never falls until the first session times out.
Environment: tl-parent-app 8.0.0-alpha10 (tl-core, tl-layout-react, tl-layout-view), tl-ai 2.1.1.
Cause
Each window holds a cursor on the update chain: WindowEntry._modelScope (GlobalModelEventForwarder._updateChain, an UpdateChainView, moved in createEventBuilder) and its sub-session's TLContext._revision (set in updateSessionRevision). They move only when the window handles a request or synthesizes events, and for an idle window that happens only in SSEUpdateQueue.sendHeartbeat / synthesizeModelEventsIfPossible (l. 563/573), which need _connection != null. A disconnected window therefore pins every later commit. Holders seen in the dump:
- Ended sessions: an idle keep-alive Jetty HttpConnection keeps ServletChannelState._asyncListeners with the anonymous AsyncListener of SSEServlet (l. 95), which captures the SSEUpdateQueue; SSEUpdateQueue.shutdown() (l. 511) does not release _rootControl, and cleanupTree() (ReactWindowRegistry.valueUnbound l. 679, windowClosed) does not release FormControl._modelScope (tl-layout-view FormControl.java:111).
- Live sessions: the window's sub-session stays in DefaultSessionContext._subSessions with its stale _revision (also after windowClosed, which removes no sub-session), and the WindowEntry keeps unloadedAt=0 when no unload arrived.
- (tl-chat, minor: ChatSSEHandler flush tasks of such windows run until the session ends, because isWindowShown() does not look at the connection.)
Proposal
- suggested A window without a connection (after a grace period) gives up its chain position and re-syncs from the current revision on reconnect, or is swept like an unloaded window (sweepUnloadedWindows, l. 656).
- suggested windowClosed removes the window's sub-session; SSEUpdateQueue.shutdown() drops _rootControl; the SSEServlet async listener does not keep the queue after complete/error/timeout; cleanupTree() releases the form's model scope.
Solution
A view window whose client is gone is torn down after a grace period, the same way a window whose page was unloaded and did not come back is torn down.
- Abandoned windows are torn down. The SSEUpdateQueue knows since when it has no client (clientGoneSince): the moment its event stream was lost (closed, or a write to it failed), the moment a polling client was orphaned (last poll plus POLL_ORPHAN_MILLIS), or the moment the page was rendered if no client connects at all (expectClient, called by the ViewServlet after rendering). A stream connecting or a poll arriving makes the client present again. The heartbeat keeps running while the client is away (it was cancelled on disconnect before) and, once the client has been gone for ReactWindowRegistry.DISCONNECT_GRACE_MILLIS (60 seconds), has the registry tear the window down (tearDownIfAbandoned): the close callback runs, the control tree is disposed, the queue is shut down - in the window's own sub-session and as one interaction, like sweepUnloadedWindows, with the client's absence checked once more inside the interaction so that a client reconnecting at that moment keeps its window. Since the loss of a stream is noticed at the latest by the next heartbeat write and the teardown is decided by a heartbeat as well, a killed tab is collected about 60 to 90 seconds after it died. A browser reconnects its event stream within seconds, so a network loss or a proxy timeout never tears a live page down.
- Reconnecting to a torn-down window reloads the page. A connection never creates a window; a window is created by rendering its page. An event stream for a window the registry does not know is answered 410 Gone (SSEServlet; the PollServlet answered so already), and a queue that is shut down refuses a late connection. The client then switches to polling, whose first poll is answered 410 as well, upon which it reloads the page. The reloaded page renders a fresh tree and opens an event stream again: the client clears its polling mark on such a reload, because the server itself refused the stream, not a proxy.
- Tearing a window down releases its position in the update chain. GlobalModelEventForwarder.release() lets a forwarder give up its update-chain cursor; afterwards it delivers nothing. windowClosed releases the window's model scope this way and removes the window's sub-session from the TLSessionContext; the end of the session releases the scopes of all its windows. SSEUpdateQueue.shutdown() lets go of the root control, the dialog and route managers, the session context and the registry; the SSEServlet async listener lets go of the queue once the stream has ended; a disposed FormControl lets go of its model scope. The update-chain links of commits after the teardown are therefore collectable even while an idle HTTP connection or a stray reference keeps a dead window's objects alive.
The tl-chat ChatSSEHandler note is outside this ticket (tl-ai repository).
Tests: TestGlobalModelEventForwarderRelease (tl-core), TestAbandonedWindowTeardown (the UpdateChainLink of a commit after the teardown is collectable; without the teardown it stays reachable), TestSSEUpdateQueueClientPresence, TestSSEUpdateQueueShutdown, TestSSEServletUnknownWindow, extended TestReactWindowRegistry and TestWindowModelEventsOfEndedSession (tl-layout-react). Checked in tl-demo-react with jcmd GC.class_histogram: with a killed tab the count of UpdateChainLink grew from 12 to 22 over 8 commits, the window was torn down 75 seconds after the stream loss was noticed, and the count fell to 1 while 5 more commits were made.
Done when
- Committing after a window disconnected does not grow the number of live `UpdateChainLink`s beyond a grace period; a test that fails today.