Skip to content

fix: load Theia diagrams at every log level and reconnect both heads - #239

Merged
martin-fleck-at merged 6 commits into
mainfrom
fix/glsp-gate-and-empty-port
Sep 30, 2026
Merged

martin-fleck-at merged 6 commits into
mainfrom
fix/glsp-gate-and-empty-port

Conversation

@martin-fleck-at

@martin-fleck-at martin-fleck-at commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

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 at info to appear in an Output channel, so at warn it 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

  1. 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.
  2. fix(client-theia): stop a port lookup once its channel closes. No breaking change: findPort gains an optional AbortSignal.
  3. 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: 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.
  4. feat(client-theia): report both heads' connections through one slot. Breaking: 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".
  5. feat(protocol): call a port's own connection lifecycle by default. No breaking change: DataPort.connectionLifecycle is optional.
  6. fix(glsp-client-theia): keep GLSP's status overlay on the page. No breaking change.

What changes

Port lookup (1, 2)

  • A port command that resolves with no port fails the attempt like one that throws: it is asked again after findPortTimeout, counts against findPortAttempts, and the lookup rejects with an error naming the command once the attempts run out. Each failed attempt is logged at debug.
  • The lookup stops once the frontend closes its channel. The default lookup polls forever, so before this every frontend that gave up and opened a fresh channel left a poll loop behind, and each loop dialled a dead channel once the port was published.

Diagram start (3)

  • The client starts once a workspace is open and sends its first request at once. The backend handler already buffers the channel until its socket to the server is up, so no readiness signal is awaited, and none the log level can filter.
  • A start that has not finished after 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.
  • A failed diagram keeps its overlay with the error and a Retry. The Retry asks AbstractHydraniumGlspDiagramManager.reopen for 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's GLSPModelSource.configure registers 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.
  • Each start asks for its channel through Theia's listen without 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.
  • Upstream's teardown never closes a Theia channel (Disposable.is(channel) is false for a ForwardingChannel), so the contribution closes it; otherwise the next channel on the same path cannot open.

Connection reporting (4)

  • ConnectionReporter in @hydranium/client-theia is the slot each head reports an attempt through, reconnects included. DefaultConnectionReporter shows 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.
  • The GLSP contribution starts a fresh client after a failed start and after a started client loses its connection, for as long as it lives. The delay grows while clients keep failing and starts over only once a client stayed up for 30 s, so a server that fails right after starting is not restarted as fast as it can fail. onDidStartClient and onDidLoseClient announce each client; on a loss, the client the contribution hands out is already the replacement.
  • The diagram manager reopens every diagram of the contribution when its client is lost, and every failed one when a client starts, so a tab that failed while the server was away recovers without a Retry.
  • The contribution logs each client it starts and loses to the application's 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.
  • The client is 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 ends GLSPModelSource'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.connectionLifecycle feeds the protocol's RpcConnectionLifecycle into 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's ChannelLogger with their detail, which the reporter's sentence leaves out.
  • Neither backend handler raises a notification; both log. The user-facing texts say "the diagram server" and "the data server", matching the protocol's existing data-server messages.

Port lifecycle (5)

  • DataPort gains an optional connectionLifecycle, and RpcConnection calls 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)

  • GLSP's StatusOverlay inserts its element into the diagram's base div in its preInitialize hook, and sprotty's first render replaces that div (ModelViewer.update patches 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.
  • 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.

What this leaves out

  • VS Code and the browser host keep their own reporting. VS Code already gates on the port lookup, bounded and treating an empty answer as a failure; the browser page needs no gate and loads at warn (a new browser e2e case pins that). The reporter slot is Theia-only.
  • The data head's failure notice carries no Retry: its connection reconnects by itself on the panel's next request, so a button would have nothing to trigger. Its 30 s bound is a reporting bound; the connection keeps trying.
  • A failure notification stays up after the head recovers; Theia hands back no handle to close it, and its Retry does nothing once the head is connected.
  • A diagram alone in its tab bar reopens where a new diagram opens, since its split closes with the old widget.
  • Upstream's client still logs "Connection to server got closed. Server will not be restarted." on a loss. It holds for that client object alone, and the contribution's Output-channel line says that a fresh one starts; rewording upstream's would mean copying its handleConnectionClosed.
  • The framework overlay still reports a failed load itself, on top of the status overlay, because it carries the Retry.
  • Whether an unmodified GLSP Theia app loses its status overlay the same way is not checked; the hook order is upstream's.

How I know it works

Failing first

  • order-flow-log-level.spec.mts (the diagram at warn) against main: no diagram node after 31 s.
  • The A port command that answers without a port leaves the socket head waiting forever #226 unit test against main: the lookup never settled and the test timed out at 5 s.
  • The lookup-cancel unit test against the lookup without it: the channel's close never ended the lookup, 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.
  • The status-overlay case in order-flow-diagram.spec.mts with upstream's overlay bound: no .sprotty-status element 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.
  • The lifecycle tests against the old this.lifecycle = options.lifecycle: the port's hooks were never called.

Red controls, each run and reverted

  • Port lookup: dropping the debug log reddens the empty-answer test (debug called 0 times); dropping the command name from the rejection reddens its message match. Dropping the per-poll abort check, the abort listener, or passing no signal each redden the lookup-cancel test; a first version of that test passed with the per-poll check removed, because its stub answered faster than the poll interval, so its stub now answers after 5 ms.
  • Diagram start: dropping the backend's channel close reddens the handler's close test; settling the current promise rather than the one the start began with reddens the overtaken-start test; no restart on read reddens the fresh-client tests; no bound reddens the timeout tests; dropping the frontend's channel close reddens its test; uncovering the canvas for a reported failure reddens the overlay test; dropping the close listener reddens the connection-closed test. A dispose that leaves the start running, or a report after dispose, reddens the dispose test. Handing a channel to the start that asked for it rather than to the latest, or turning Theia's replay back on, reddens the channel test.
  • Log: dropping the loss line, or the counter, reddens the log test.
  • Client: calling upstream's session end without the guard reddens the gone-connection test; never sending it reddens the connected test.
  • Reopen: dropping the manager's subscription, the detach before the dispose, or the dispose before the open each redden the reopen test; always inserting after the reference reddens the first-tab test; reopening without the queue reddens the one-at-a-time test (which failed first against the concurrent version: the second diagram was detached while the first was still opening), and dropping the disposed check reddens the skip test.
  • Port lifecycle: calling each lifecycle however often it is passed reddens the called-once test.
  • Reporting: showing a failure per attempt reddens the failure-once test; dropping the recovery notice reddens it too; a quick start that announces itself reddens the silent-start test; a 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.
  • GLSP restart: no automatic restart, and no restart on a lost connection, redden the restart tests. Announcing the loss before the replacement is in place, or not announcing a start, reddens the announce test; resetting the delays on every start reddens the backoff test, and never resetting them reddens the reset test; a loss handler without the dispose guard reddens the dispose-stop test; a dispose that does not cancel its attempt reddens the cancel test. Reopening on a start without the failed filter, or only the failed diagrams on a loss, reddens the manager's event tests.
  • The test a review found unable to fail ("does not take the old client stopping during a restart for a loss") covered a guard no path could reach: the loss listener disposes itself on its first stop, and a restart replaces the client only after a failed start. The guard is gone, and its test now covers the reachable one, a dispose that stops the client.

In the running app

  • With the gate removed, the backend logged replaying 1 pre-forward message(s): the frontend's first request arrived before the socket and was replayed.
  • A backend that keeps failing (a port command that does not exist, and findPortAttempts: 8 so 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.
  • After the language server is killed, the diagram reopens on the replacement server, and a task created from its palette appears exactly once.
  • The existing data restart spec now sees the diagram reach the replaced document first, so its restore assertion accepts the panel attaching as well as opening it.

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.

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
martin-fleck-at force-pushed the fix/glsp-gate-and-empty-port branch from 1efb2f3 to acb2284 Compare September 30, 2026 10:10
@martin-fleck-at martin-fleck-at changed the title fix: load a diagram at every log level, retry an empty port answer, and report both heads' connections through one slot fix: load a diagram at every log level, retry an empty port answer, report both heads' connections through one slot, and keep GLSP's status overlay on the page Sep 30, 2026
- 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
martin-fleck-at force-pushed the fix/glsp-gate-and-empty-port branch from acb2284 to 1eca963 Compare September 30, 2026 13:22
@martin-fleck-at martin-fleck-at changed the title fix: load a diagram at every log level, retry an empty port answer, report both heads' connections through one slot, and keep GLSP's status overlay on the page fix: load Theia diagrams at every log level and reconnect both heads Sep 30, 2026
@martin-fleck-at
martin-fleck-at merged commit 30b7b26 into main Sep 30, 2026
10 checks passed
@martin-fleck-at
martin-fleck-at deleted the fix/glsp-gate-and-empty-port branch September 30, 2026 13:31
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.

A port command that answers without a port leaves the socket head waiting forever

1 participant