fix: load Theia diagrams at every log level and reconnect both heads - #239
Merged
Merged
Conversation
The socket-forwarding handler retried its port command only when it threw. A command that resolved with no port neither resolved the lookup nor asked again, so the head never connected and nothing was logged. - An answer without a port fails the attempt like a throw does: it is asked again after findPortTimeout and counts against findPortAttempts - Once the attempts run out, the lookup rejects with an error naming the command, which the handler logs at error - Each failed attempt is logged at debug with the command and reason Fixes #226
martin-fleck-at
force-pushed
the
fix/glsp-gate-and-empty-port
branch
from
September 30, 2026 10:10
1efb2f3 to
acb2284
Compare
- Poll the port command only while the frontend's channel is open, so a frontend that gives up and reopens leaves no poll loop behind that dials a dead channel once the port is published - Log a lookup the frontend ended at info, without an error notification
The Theia GLSP client waited for a line the server logs at info to appear in an Output channel before it started, with no bound. Above info the line was never logged, so the diagram stayed at "Loading diagram..." and nothing reported a failure. Start - The client starts once a workspace is open and sends its first request at once; the backend handler already holds it until its socket to the server is up - A start that has not finished after startupTimeoutMs (30 s by default) fails, and a dispose ends a start without a report - When the backend handler gives up, it closes the channel, so the frontend fails at once instead of waiting out the bound - The first channel to arrive serves the latest start, so a start after a websocket outage does not hang behind an older held open Reporting and retry - A start still running after 3 s shows a progress notification and ends in one notification: an error with Retry, or a success - A failed diagram keeps its overlay with the error and a Retry, which reopens it as a fresh widget in the same tab position; loading again in place would register GLSP's model source handlers twice, and GLSP's status overlay does not show the loader's report - Reading the client after a failed start starts a fresh one over a fresh channel, so reopening a diagram recovers too - The GLSP backend handler logs a failure without a notification of its own, and the contribution's messages speak of "the diagram server" New public names - ClientContributionOptions.startupTimeoutMs and DEFAULT_GLSP_CLIENT_STARTUP_TIMEOUT_MS - HydraniumGlspClientContribution.restart - AbstractHydraniumGlspDiagramManager.reopen and HydraniumGlspDiagramWidget.onDidRequestReopen - The protected AbstractSocketForwardingConnectionHandler .reportConnectFailure and HydraniumGlspDiagramWidget.retryLoad and createRetryButton Breaking changes - ClientContributionOptions drops readyMarker and channelName, and the contribution drops waitForBackendConnected and its outputChannelManager; @theia/output is no longer a peer - The contribution's start no longer settles glspClient, and its initialize raises no notification - GlspServerConnectionHandler raises no notification, and its serverName defaults to "Diagram Server" - The diagram widget shows every load failure itself, whatever DiagramLoadFailure.surfaced says Fixes #227
The diagram and the data head reported connection trouble differently: the GLSP client started once and never again, and the data head raised a backend toast per failed connect. Each head now keeps reconnecting on its own and reports every attempt through one slot adopters can replace, or leave silent. Reporting - ConnectionReporter is the slot each head reports an attempt through; DefaultConnectionReporter shows progress for an attempt still running after 3 s and ends it in at most one notification, and reports a failure once until the head connects again - An attempt a dispose or a newer attempt ends is cancelled, which takes its progress down without a notification - Neither backend handler raises a notification of its own; both log the failure GLSP - A failed start, and a connection lost after one succeeded, start a fresh client after a delay that grows while clients keep failing and starts over once one stayed up for 30 s - Open diagrams reopen as fresh widgets once their client is lost, and failed ones once a client starts, so a diagram survives a language-server restart - The client is HydraniumGlspClient, upstream's base client without the notifications its Theia subclass raises, which ends a session without the server once the connection is gone instead of throwing, so a diagram reopened after a loss logs no error - Each client's start and loss is logged with its number and the restart delay, beside upstream's line that its client will not be restarted Data - ChannelDataPort.connectionLifecycle reports each connection through the slot, one not ready after 30 s included, and takes over the connection failures the port would otherwise raise itself, logging them with their detail Example - The order-flow data connection passes the port's lifecycle, and a new restart spec shows an open diagram reopening, and taking an edit once, after the language server is killed New public names - ConnectionReporter, ConnectionAttempt, ConnectionTarget, DefaultConnectionReporter and bindConnectionReporter - ChannelDataPort.connectionLifecycle - HydraniumGlspClientContribution.onDidStartClient and onDidLoseClient - HydraniumGlspClient Breaking changes - HydraniumGlspClientContribution and ChannelDataPort inject ConnectionReporter, so a module binding either calls bindConnectionReporter; the GLSP frontend module does it already - HydraniumGlspClientContribution and ChannelDataPort inject ChannelLogger, so a module binding either calls bindChannelLogger at application scope, as bindConnectionDiagnostics already asks - DataServerConnectionHandler raises no notification, and its serverName defaults to "Data Server" - A DataConnection built without the port's connectionLifecycle reports no progress and no 30 s notice
A host that reports its connections through its port had every connection built over it pass the port's lifecycle on, and a connection that did not reported nothing. - DataPort.connectionLifecycle is an optional member a host fills in; RpcConnection calls it for each generation, before the lifecycle its options pass, so an adopter's own hooks add to the port's rather than replacing them - A lifecycle passed both ways is called once - ChannelDataPort's lifecycle reaches every connection over it, so the order-flow data connection no longer passes it
GLSP's status overlay inserts its element into the diagram's base div when the diagram starts, and sprotty's first render then replaces that div with its own. The element was left detached, so no status a server or the loader raised was ever seen in a Theia diagram. - HydraniumStatusOverlay puts a detached element back into the current base div before it shows a status or becomes visible - The diagram container binds it in place of upstream's overlay New public names - HydraniumStatusOverlay
martin-fleck-at
force-pushed
the
fix/glsp-gate-and-empty-port
branch
from
September 30, 2026 13:22
acb2284 to
1eca963
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A Theia diagram never loaded when the server log level was above
info: the GLSP client waited, with no bound, for a line the server logs atinfoto appear in an Output channel, so atwarnit never started and nothing reported a failure (#227). Separately, the socket-forwarding handler waited forever when its port command answered without a port (#226). This removes the gate, bounds the start, and makes both Theia heads reconnect on their own and report every connection attempt through one slot an adopter can replace or silence. A diagram whose client is lost or whose load failed reopens as a fresh widget in its tab, so it survives a language-server restart and stays editable. It also puts GLSP's status overlay back on the page: sprotty's first render detached it, so no server status ever showed in a Theia diagram.Commits
fix(client-theia): retry a port command that answers without a port(A port command that answers without a port leaves the socket head waiting forever #226). No breaking change.fix(client-theia): stop a port lookup once its channel closes. No breaking change:findPortgains an optionalAbortSignal.fix(glsp-client-theia): load a diagram at every server log level(A diagram never loads in Theia when the server log level is above info #227). Breaking:ClientContributionOptionsdropsreadyMarkerandchannelName, and the contribution dropswaitForBackendConnectedand itsoutputChannelManager;@theia/outputis no longer a peer; the contribution'sstartno longer settlesglspClient, and itsinitializeraises no notification;GlspServerConnectionHandlerraises no notification, and itsserverNamedefaults to "Diagram Server"; the diagram widget shows every load failure itself, whateverDiagramLoadFailure.surfacedsays.feat(client-theia): report both heads' connections through one slot. Breaking:HydraniumGlspClientContributionandChannelDataPortinjectConnectionReporter, so a module binding either callsbindConnectionReporter(the GLSP frontend module does it already);HydraniumGlspClientContributionandChannelDataPortinjectChannelLogger, so a module binding either callsbindChannelLoggerat application scope, asbindConnectionDiagnosticsalready asks;DataServerConnectionHandlerraises no notification, and itsserverNamedefaults to "Data Server".feat(protocol): call a port's own connection lifecycle by default. No breaking change:DataPort.connectionLifecycleis optional.fix(glsp-client-theia): keep GLSP's status overlay on the page. No breaking change.What changes
Port lookup (1, 2)
findPortTimeout, counts againstfindPortAttempts, and the lookup rejects with an error naming the command once the attempts run out. Each failed attempt is logged at debug.Diagram start (3)
startupTimeoutMs(30 s by default) fails. When the backend handler gives up, it now closes the channel, so the frontend fails at once instead of waiting out the bound. A dispose ends a start in flight without a report.AbstractHydraniumGlspDiagramManager.reopenfor a fresh widget in the same tab position, with the old one's viewport, rather than loading again in place. The old widget is taken down as a close does, detached and then disposed, without the save prompt a dirty diagram with no server would raise; disposing it while attached would empty its container before upstream's detach reads the viewport from it, which logs "No matching bindings found for serviceIdentifier: EditorContextService". Not in place, because GLSP'sGLSPModelSource.configureregisters its server-action handlers once per load, so a second load in one container sends every edit twice. Reopens run one at a time: each places its replacement next to a neighbour, and a reopen running alongside could take that neighbour out of the layout (two adjacent diagrams reopened at once left one missing, with Lumino's "Reference widget is not in the layout"). A diagram a queued reopen already replaced is skipped.listenwithout Theia's replay, and whichever channel arrives serves the start waiting now. Theia holds each open until its websocket is back, and after an outage every held open past the first throws "already open" without reaching its handler, so the latest start would otherwise hang behind an older one's open.Disposable.is(channel)is false for aForwardingChannel), so the contribution closes it; otherwise the next channel on the same path cannot open.Connection reporting (4)
ConnectionReporterin@hydranium/client-theiais the slot each head reports an attempt through, reconnects included.DefaultConnectionReportershows a progress notification for an attempt still running after 3 s, ends it in at most one notification, and reports a failure once until the head connects again. An attempt that a dispose or a newer attempt ends is cancelled, which takes its progress down without a notification. An adopter rebinds the slot to report differently or not at all; the heads keep their retries either way.onDidStartClientandonDidLoseClientannounce each client; on a loss, the client the contribution hands out is already the replacement.ChannelLogger, numbered because every client carries the contribution's id: "[order-flow-contribution] Diagram client 1 lost; starting a fresh one in 1000 ms.", then "Diagram client 2 started." Checked in the app: the lines land in order-flow's "Order Flow Connection" Output channel.HydraniumGlspClient: upstream's base client, without the notifications its Theia subclass raises, and ending a session without the server once the connection is gone. Upstream throws "JsonrpcGLSPClient is not ready yet" there, and every diagram reopened after a loss would log it as an error; the throw also endsGLSPModelSource's teardown loop early, though a probe with the real model source and Theia's Inversify shows the container still tears down fully, so only a listener on the dead client is skipped.ChannelDataPort.connectionLifecyclefeeds the protocol'sRpcConnectionLifecycleinto the reporter, a data server not ready after 30 s included, and takes over the two connection failures the port would otherwise raise as notifications of their own; it logs them at warn to the application'sChannelLoggerwith their detail, which the reporter's sentence leaves out.Port lifecycle (5)
DataPortgains an optionalconnectionLifecycle, andRpcConnectioncalls it for each generation before the lifecycle its options pass, so every connection over a reporting port reports without being handed its hooks, and an adopter's own hooks add to the port's rather than replacing them. A lifecycle passed both ways is called once.Status overlay (6)
StatusOverlayinserts its element into the diagram's base div in itspreInitializehook, and sprotty's first render replaces that div (ModelViewer.updatepatches the placeholder with a new root), so the element was left detached. Measured in the app: the live base div is a different element from the widget's original container, which is no longer in the document, and the overlay's element hangs off that original.HydraniumStatusOverlayputs a detached element back into the current base div before it shows a status or becomes visible; the diagram container binds it in place of upstream's.What this leaves out
warn(a new browser e2e case pins that). The reporter slot is Theia-only.handleConnectionClosed.How I know it works
Failing first
order-flow-log-level.spec.mts(the diagram atwarn) againstmain: no diagram node after 31 s.main: the lookup never settled and the test timed out at 5 s.order-flow-diagram-restart.spec.mts's edit case against the in-place reload this PR had before (its glsp-client-theia sources restored): one palette create after the kill produced two tasks (expected 1, received 2). A first version of the case passed and failed by turns on the new code, because a model update right after the load disarms the palette's tool; it now re-arms until a click creates something, and passed six runs in a row.order-flow-diagram.spec.mtswith upstream's overlay bound: no.sprotty-statuselement on the page (expected 1, received 0). A first attempt at this control passed, because the mutated build had failed and the old bundle ran; rebinding upstream's overlay to itself compiles and fails as stated.this.lifecycle = options.lifecycle: the port's hooks were never called.Red controls, each run and reverted
cancelled()that does nothing reddens the cancel test; the GLSP backend toasting again, and the data backend toasting again, redden their log-only tests; dropping the data port's suppression reddens the duplicate test, dropping its log reddens it too, dropping its follow-up attempt reddens the recovery test, and dropping an attempt without cancelling it reddens the cancel test.In the running app
replaying 1 pre-forward message(s): the frontend's first request arrived before the socket and was replayed.findPortAttempts: 8so that the backend gives up at all; with the default the lookup never does): one "Connecting to the diagram server…" progress, then one "Could not connect to the diagram server." with Retry, while the backend gave up five times as the client kept retrying; the canvas showed the same failure with its own Retry.Fixes #226 and #227. The six commits are meant to be merged with Rebase and merge, so each keeps its own message and Breaking notes.