@hydranium/glsp-server 1.0.0-next.14 → 1.0.0-next.140

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +20 -5
  2. package/lib/browser/start-glsp-server-in-worker.d.ts +30 -39
  3. package/lib/browser/start-glsp-server-in-worker.d.ts.map +1 -1
  4. package/lib/browser/start-glsp-server-in-worker.js +43 -8
  5. package/lib/browser/start-glsp-server-in-worker.js.map +1 -1
  6. package/lib/command/hydranium-glsp-command-stack.d.ts +27 -0
  7. package/lib/command/hydranium-glsp-command-stack.d.ts.map +1 -0
  8. package/lib/command/hydranium-glsp-command-stack.js +53 -0
  9. package/lib/command/hydranium-glsp-command-stack.js.map +1 -0
  10. package/lib/command/hydranium-glsp-recording-command.d.ts +22 -22
  11. package/lib/command/hydranium-glsp-recording-command.d.ts.map +1 -1
  12. package/lib/command/hydranium-glsp-recording-command.js +25 -26
  13. package/lib/command/hydranium-glsp-recording-command.js.map +1 -1
  14. package/lib/command/index.d.ts +1 -0
  15. package/lib/command/index.d.ts.map +1 -1
  16. package/lib/command/index.js +1 -0
  17. package/lib/command/index.js.map +1 -1
  18. package/lib/launcher/abstract-hydranium-glsp-diagram-module.d.ts +12 -3
  19. package/lib/launcher/abstract-hydranium-glsp-diagram-module.d.ts.map +1 -1
  20. package/lib/launcher/abstract-hydranium-glsp-diagram-module.js +18 -2
  21. package/lib/launcher/abstract-hydranium-glsp-diagram-module.js.map +1 -1
  22. package/lib/launcher/hydranium-glsp-server.d.ts +10 -6
  23. package/lib/launcher/hydranium-glsp-server.d.ts.map +1 -1
  24. package/lib/launcher/hydranium-glsp-server.js +16 -6
  25. package/lib/launcher/hydranium-glsp-server.js.map +1 -1
  26. package/lib/messages/index.d.ts +24 -0
  27. package/lib/messages/index.d.ts.map +1 -0
  28. package/lib/messages/index.js +24 -0
  29. package/lib/messages/index.js.map +1 -0
  30. package/lib/node/start-glsp-server.d.ts +1 -1
  31. package/lib/node/start-glsp-server.d.ts.map +1 -1
  32. package/lib/node/start-glsp-server.js +2 -2
  33. package/lib/node/start-glsp-server.js.map +1 -1
  34. package/lib/state/abstract-hydranium-glsp-state.d.ts +110 -43
  35. package/lib/state/abstract-hydranium-glsp-state.d.ts.map +1 -1
  36. package/lib/state/abstract-hydranium-glsp-state.js +139 -60
  37. package/lib/state/abstract-hydranium-glsp-state.js.map +1 -1
  38. package/lib/state/full-text-hydranium-glsp-state.d.ts +6 -6
  39. package/lib/state/full-text-hydranium-glsp-state.d.ts.map +1 -1
  40. package/lib/state/full-text-hydranium-glsp-state.js +6 -10
  41. package/lib/state/full-text-hydranium-glsp-state.js.map +1 -1
  42. package/lib/state/reconcile-source-model-write.d.ts +9 -8
  43. package/lib/state/reconcile-source-model-write.d.ts.map +1 -1
  44. package/lib/state/reconcile-source-model-write.js +8 -8
  45. package/lib/state/reconcile-source-model-write.js.map +1 -1
  46. package/lib/state/reconciling-multi-document-glsp-state.d.ts +59 -59
  47. package/lib/state/reconciling-multi-document-glsp-state.d.ts.map +1 -1
  48. package/lib/state/reconciling-multi-document-glsp-state.js +84 -82
  49. package/lib/state/reconciling-multi-document-glsp-state.js.map +1 -1
  50. package/lib/state/reconciling-transfer-hydranium-glsp-state.d.ts +19 -18
  51. package/lib/state/reconciling-transfer-hydranium-glsp-state.d.ts.map +1 -1
  52. package/lib/state/reconciling-transfer-hydranium-glsp-state.js +26 -30
  53. package/lib/state/reconciling-transfer-hydranium-glsp-state.js.map +1 -1
  54. package/lib/storage/hydranium-glsp-request-save-model-action-handler.d.ts +30 -0
  55. package/lib/storage/hydranium-glsp-request-save-model-action-handler.d.ts.map +1 -0
  56. package/lib/storage/hydranium-glsp-request-save-model-action-handler.js +47 -0
  57. package/lib/storage/hydranium-glsp-request-save-model-action-handler.js.map +1 -0
  58. package/lib/storage/hydranium-glsp-storage.d.ts +313 -86
  59. package/lib/storage/hydranium-glsp-storage.d.ts.map +1 -1
  60. package/lib/storage/hydranium-glsp-storage.js +456 -104
  61. package/lib/storage/hydranium-glsp-storage.js.map +1 -1
  62. package/lib/storage/index.d.ts +2 -1
  63. package/lib/storage/index.d.ts.map +1 -1
  64. package/lib/storage/index.js +2 -1
  65. package/lib/storage/index.js.map +1 -1
  66. package/lib/storage/save-delivery-policy.d.ts +46 -0
  67. package/lib/storage/save-delivery-policy.d.ts.map +1 -0
  68. package/lib/storage/{save-conflict-policy.js → save-delivery-policy.js} +7 -8
  69. package/lib/storage/save-delivery-policy.js.map +1 -0
  70. package/lib/validation/diagnostic-markers.d.ts +2 -2
  71. package/lib/validation/diagnostic-markers.d.ts.map +1 -1
  72. package/lib/validation/diagnostic-markers.js +7 -0
  73. package/lib/validation/diagnostic-markers.js.map +1 -1
  74. package/lib/validation/hydranium-glsp-model-validator.d.ts.map +1 -1
  75. package/lib/validation/hydranium-glsp-model-validator.js +1 -1
  76. package/lib/validation/hydranium-glsp-model-validator.js.map +1 -1
  77. package/package.json +17 -8
  78. package/src/browser/start-glsp-server-in-worker.ts +41 -44
  79. package/src/command/hydranium-glsp-command-stack.ts +36 -0
  80. package/src/command/hydranium-glsp-recording-command.ts +26 -26
  81. package/src/command/index.ts +1 -0
  82. package/src/launcher/abstract-hydranium-glsp-diagram-module.ts +27 -3
  83. package/src/launcher/hydranium-glsp-server.ts +16 -6
  84. package/src/messages/index.ts +30 -0
  85. package/src/node/start-glsp-server.ts +2 -3
  86. package/src/state/abstract-hydranium-glsp-state.ts +174 -73
  87. package/src/state/full-text-hydranium-glsp-state.ts +7 -11
  88. package/src/state/reconcile-source-model-write.ts +18 -15
  89. package/src/state/reconciling-multi-document-glsp-state.ts +86 -83
  90. package/src/state/reconciling-transfer-hydranium-glsp-state.ts +27 -31
  91. package/src/storage/hydranium-glsp-request-save-model-action-handler.ts +40 -0
  92. package/src/storage/hydranium-glsp-storage.ts +520 -113
  93. package/src/storage/index.ts +2 -1
  94. package/src/storage/save-delivery-policy.ts +44 -0
  95. package/src/validation/diagnostic-markers.ts +10 -3
  96. package/src/validation/hydranium-glsp-model-validator.ts +3 -3
  97. package/lib/storage/save-conflict-policy.d.ts +0 -56
  98. package/lib/storage/save-conflict-policy.d.ts.map +0 -1
  99. package/lib/storage/save-conflict-policy.js.map +0 -1
  100. package/src/storage/save-conflict-policy.ts +0 -52
@@ -7,7 +7,7 @@
7
7
  * SPDX-License-Identifier: MIT
8
8
  ********************************************************************************/
9
9
 
10
- import { MarkersReason, SetMarkersAction } from '@eclipse-glsp/protocol';
10
+ import { MarkersReason, MessageAction, SetMarkersAction, StatusAction } from '@eclipse-glsp/protocol';
11
11
  import {
12
12
  type Action,
13
13
  ActionDispatcher,
@@ -27,20 +27,101 @@ import {
27
27
  type RequestModelAction,
28
28
  SOURCE_URI_ARG,
29
29
  type SaveModelAction,
30
+ SetDirtyStateAction,
30
31
  SetEditModeAction,
31
32
  type SourceModelStorage,
32
33
  TEMPORARY_CLIENT_ID
33
34
  } from '@eclipse-glsp/server';
34
- import { Debouncer, DisposableCollection } from '@hydranium/protocol';
35
+ import {
36
+ Debouncer,
37
+ defineMessage,
38
+ DisposableCollection,
39
+ isDuplicateClientIdError,
40
+ isSessionClosedError,
41
+ SessionClosedError
42
+ } from '@hydranium/protocol';
35
43
  import { inject, injectable, optional, postConstruct } from 'inversify';
36
44
  import { type AstNode } from '@hydranium/langium';
37
45
  import { URI } from '@hydranium/langium';
38
- import { AstDocument, type AstDocumentSavedEvent, type AstDocumentUpdatedEvent, type ServerSharedServices } from '@hydranium/core';
46
+ import {
47
+ AstDocument,
48
+ type AstDocumentSavedEvent,
49
+ type AstDocumentUpdatedEvent,
50
+ type ClientSession as ModelClientSession,
51
+ type DocumentDirtyChangedEvent,
52
+ type ServerSharedServices
53
+ } from '@hydranium/core';
39
54
  import { DiagnosticSeverity } from 'vscode-languageserver-types';
40
55
  import { type AbstractHydraniumGlspState } from '../state/abstract-hydranium-glsp-state.js';
41
56
  import { type HydraniumGlspSubmissionHandler } from '../submission/hydranium-glsp-submission-handler.js';
42
57
  import { HydraniumTypes } from '../state/hydranium-shared-core-services.js';
43
- import { DEFAULT_SAVE_CONFLICT_POLICY, SaveConflictPolicy } from './save-conflict-policy.js';
58
+ import { DEFAULT_SAVE_DELIVERY_POLICY, SaveDeliveryPolicy } from './save-delivery-policy.js';
59
+
60
+ /**
61
+ * A save action arrived with nowhere to write to. A save action carries no
62
+ * request id, so it falls through to the error handler and `message` becomes
63
+ * the toast text.
64
+ *
65
+ * Rendered at the raise site rather than by a carrier method, because GLSP's
66
+ * action protocol has no slot for an identity anywhere — every member of its
67
+ * message, status and reject actions is prose or an enum, and
68
+ * `MessageAction.details` is not a substitute since it is prose populated from
69
+ * `cause?.toString?.()`, and a Theia host shows it to a user on demand. So the
70
+ * throw is the last place that still knows which message this is.
71
+ */
72
+ export const SAVE_TARGET_UNKNOWN = defineMessage(
73
+ 'hydranium/glsp-server/save-target-unknown',
74
+ 'Could not determine where to save this model'
75
+ );
76
+
77
+ /**
78
+ * A model request arrived without the source URI it is required to carry.
79
+ * Rendered at the raise site, like its save-path sibling and for the same
80
+ * reason.
81
+ */
82
+ export const SOURCE_URI_MISSING = defineMessage(
83
+ 'hydranium/glsp-server/source-uri-missing',
84
+ 'Could not open this model: the request did not say which document to load'
85
+ );
86
+
87
+ /**
88
+ * Why the canvas has stopped accepting edits.
89
+ *
90
+ * **A READONLY canvas is otherwise indistinguishable from a broken one.** The
91
+ * client's answer to {@link SetEditModeAction} is to withdraw the tool palette,
92
+ * so the surface a reader was working in silently loses the only control it had,
93
+ * with the cause — a syntax error in a document that may not even be open —
94
+ * nowhere on screen. The mode flip is the mechanism; this is the only part of it
95
+ * a user can see.
96
+ *
97
+ * Rendered at the raise site, like its two siblings above and for the same
98
+ * reason: every member of GLSP's status action is prose or an enum.
99
+ *
100
+ * It names the recovery rather than the fault, because the fault already has a
101
+ * surface — the squiggle and the problems list, both of which say WHICH
102
+ * character — and repeating it here would put a second, less precise account of
103
+ * the same error on screen. What no other surface says is that the diagram is
104
+ * waiting on it.
105
+ */
106
+ export const DIAGRAM_READONLY_PARSE_ERROR = defineMessage(
107
+ 'hydranium/glsp-server/diagram-readonly-parse-error',
108
+ 'Read-only: this document has a syntax error. Fix it to edit the diagram again.'
109
+ );
110
+
111
+ /**
112
+ * The diagram's GLSP client id is still live as another participant's client
113
+ * id once the load has waited for it to free, so the diagram does not load.
114
+ *
115
+ * Rendered at the raise site, as {@link SAVE_TARGET_UNKNOWN} is and for the
116
+ * same reason, and dispatched to the client as a message of its own: a model
117
+ * request fails as a rejection whose detail is the English cause, which no user
118
+ * sees. It names the recovery: a host that numbers its diagram clients gives a
119
+ * reopened diagram a fresh id.
120
+ */
121
+ export const DIAGRAM_SESSION_REFUSED = defineMessage(
122
+ 'hydranium/glsp-server/diagram-session-refused',
123
+ 'Could not open this diagram: its identifier is still in use by another editor. Close the diagram and open it again.'
124
+ );
44
125
 
45
126
  /** Window (ms) over which back-to-back external rebuilds collapse into one resubmit. */
46
127
  const EXTERNAL_SUBMIT_DEBOUNCE_MS = 250;
@@ -90,17 +171,24 @@ function isStructuralDiagnostic(diagnostic: unknown): boolean {
90
171
  *
91
172
  * Adopters with richer needs override the seams ({@link isStructurallyBroken},
92
173
  * {@link onParseErrorChanged}, {@link onSourceModelSettled}) rather than the
93
- * whole flow, and select a {@link SaveConflictPolicy} via the bound option to
94
- * tune how {@link saveSourceModel} reacts to a concurrent edit.
174
+ * whole flow, and select a {@link SaveDeliveryPolicy} via the bound option to
175
+ * tune how {@link saveSourceModel} delivers its result.
95
176
  *
96
- * **Default `saveSourceModel` flow.** Delegates to `ModelService.save`, which
97
- * serialises through the language-specific `Serializer` bound at
98
- * `services.serializer.Serializer` (resolved per-URI via `ServiceRegistry`),
99
- * updates the multi-client text-document store, drives a rebuild, and writes the
100
- * result via the `WritableFileSystemProvider`. The bound
101
- * {@link SaveConflictPolicy} (default {@link DEFAULT_SAVE_CONFLICT_POLICY},
102
- * `overwrite`) decides the based-on-version guard, await-vs-fire-and-forget, and
103
- * failure handling.
177
+ * **One client session per GLSP client session.** `init` registers the GLSP
178
+ * client id as a client session ({@link registerModelSession}) and hands it to
179
+ * the state as `modelSession`. The primary is opened through it on load, and
180
+ * every document that joins the write set as it joins
181
+ * ({@link openSecondary}); a document that leaves the write set stays open
182
+ * until the next save, so leaving never reverts the diagram's unsaved edits to
183
+ * it. Disposing the storage ends the session, which closes everything it has
184
+ * open.
185
+ *
186
+ * **Default `saveSourceModel` flow.** Flushes the stored text of every
187
+ * document the diagram's session has open via `AstDocumentManager.save`, with
188
+ * no serializer in the path — the update path already put the settled text in
189
+ * the store. The bound {@link SaveDeliveryPolicy} (default
190
+ * {@link DEFAULT_SAVE_DELIVERY_POLICY}, `await`) decides
191
+ * await-vs-fire-and-forget and failure handling.
104
192
  *
105
193
  * **tempId filter.** GLSP's `DefaultGlobalActionProvider` spins up a
106
194
  * throwaway per-diagram-type container with upstream's {@link TEMPORARY_CLIENT_ID}
@@ -111,9 +199,9 @@ function isStructuralDiagnostic(diagnostic: unknown): boolean {
111
199
  * **Lifecycle ordering.** `init` runs after DI resolution and parks the
112
200
  * secondary-write-set watch, which outlives any single load.
113
201
  * {@link sessionDisposed} calls {@link dispose}, which drains
114
- * {@link toDispose} idempotently. Every transient subscription created in
115
- * {@link doLoadSourceModel} is parked on {@link toDispose} so the drain catches
116
- * them on client-detach.
202
+ * {@link toDispose} idempotently and then ends the client session. Every
203
+ * transient subscription created in {@link doLoadSourceModel} is parked on
204
+ * {@link toDispose} so the drain catches them on client-detach.
117
205
  */
118
206
  @injectable()
119
207
  export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
@@ -137,14 +225,13 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
137
225
  @inject(ModelValidator) @optional() protected readonly modelValidator?: ModelValidator;
138
226
 
139
227
  /**
140
- * Selected {@link SaveConflictPolicy} for {@link saveSourceModel}. Bind the
141
- * {@link SaveConflictPolicy} token in a `DiagramModule` to choose a policy;
142
- * left unbound it resolves to {@link DEFAULT_SAVE_CONFLICT_POLICY}
143
- * (`overwrite`, last-write-wins). Read via {@link saveConflictPolicy} so the
144
- * fallback is applied even when inversify injects `undefined` for an unbound
145
- * `@optional()` member.
228
+ * Selected {@link SaveDeliveryPolicy} for {@link saveSourceModel}. Bind the
229
+ * {@link SaveDeliveryPolicy} token in a `DiagramModule` to choose a policy;
230
+ * left unbound it resolves to {@link DEFAULT_SAVE_DELIVERY_POLICY}. Read via
231
+ * {@link saveDeliveryPolicy} so the fallback is applied even when inversify
232
+ * injects `undefined` for an unbound `@optional()` member.
146
233
  */
147
- @inject(SaveConflictPolicy) @optional() protected readonly boundSaveConflictPolicy?: SaveConflictPolicy;
234
+ @inject(SaveDeliveryPolicy) @optional() protected readonly boundSaveDeliveryPolicy?: SaveDeliveryPolicy;
148
235
 
149
236
  /** Disposables created during {@link doLoadSourceModel}; drained on session disposal. */
150
237
  protected toDispose = new DisposableCollection();
@@ -159,6 +246,27 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
159
246
  */
160
247
  protected readonly secondarySubscriptions = new Map<string, Disposable>();
161
248
 
249
+ /**
250
+ * Documents that left the write set since the last save. They stay open in
251
+ * the diagram's session until that save has persisted them, and are closed
252
+ * after it: closing a document as it leaves would revert the diagram's
253
+ * unsaved edits to it where the diagram was its last client.
254
+ */
255
+ protected readonly departedSecondaries = new Set<string>();
256
+
257
+ /**
258
+ * How long a load waits for a client id another participant holds to free
259
+ * before refusing. A reloaded client can reconnect under its old id before
260
+ * the server has noticed the old connection close, which ends that
261
+ * connection's sessions a moment later.
262
+ */
263
+ protected readonly sessionWaitMs: number = 2_000;
264
+
265
+ /** Set by {@link dispose}; a disposed storage registers no session again. */
266
+ protected disposed = false;
267
+ /** Saves of the diagram's own that GLSP's save handler awaits; see {@link handleDirtyChanged}. */
268
+ protected ownSavesPending = 0;
269
+
162
270
  /**
163
271
  * Whether an external resubmit has ever been dispatched. Gates the
164
272
  * keep-the-last-valid-GModel branch, which needs to know a canvas is already
@@ -178,7 +286,7 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
178
286
  protected resubmitDebouncer!: Debouncer;
179
287
 
180
288
  /** Latest event document awaiting the debounced resubmit; read by {@link flushResubmit} (last write wins). */
181
- protected pendingResubmitDocument?: AstDocument<AstNode, unknown>;
289
+ protected pendingResubmitDocument?: AstDocument<AstNode>;
182
290
 
183
291
  @postConstruct()
184
292
  protected init(): void {
@@ -188,6 +296,7 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
188
296
  if (this.state.clientId === TEMPORARY_CLIENT_ID) {
189
297
  return;
190
298
  }
299
+ this.state.modelSession = this.registerModelSession();
191
300
  this.sessionManager.addListener(this, this.state.clientId);
192
301
  // Watched for this storage's whole lifetime rather than per load. The load
193
302
  // flow is a documented override point, so a subscription parked there would
@@ -196,6 +305,103 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
196
305
  this.toDispose.push(this.state.onSecondaryUrisChanged(() => this.reconcileSecondarySubscriptions()));
197
306
  }
198
307
 
308
+ /**
309
+ * Register the diagram's client session under the GLSP client id, or
310
+ * `undefined` when another participant holds that id, which is logged.
311
+ *
312
+ * The diagram then refuses to load ({@link requireModelSession}). Taking
313
+ * the id over would end the other participant's session under it, and
314
+ * working without a session would share that participant's opens and
315
+ * echoes, so its close would take the diagram's documents with it.
316
+ *
317
+ * A client id the framework reserves throws `ReservedClientIdError`:
318
+ * whoever chose the GLSP client id has a bug, and no later registration can
319
+ * succeed.
320
+ */
321
+ protected registerModelSession(): ModelClientSession<AstNode> | undefined {
322
+ try {
323
+ return this.sharedServices.model.ModelService.createSession('diagram', this.state.clientId);
324
+ } catch (error: unknown) {
325
+ if (!isDuplicateClientIdError(error)) {
326
+ throw error;
327
+ }
328
+ this.logger.warn(`Client id ${this.state.clientId} is held by another participant: this diagram has no session yet`);
329
+ return undefined;
330
+ }
331
+ }
332
+
333
+ /**
334
+ * The diagram's client session, registering it again when the id was held
335
+ * earlier. Throws `SessionClosedError` once the storage is disposed, and the
336
+ * refusal when the id is still held.
337
+ */
338
+ protected requireModelSession(): ModelClientSession<AstNode> {
339
+ if (this.disposed) {
340
+ throw new SessionClosedError(this.state.clientId, 'The diagram has closed; its client session has ended.');
341
+ }
342
+ if (!this.state.modelSession) {
343
+ this.state.modelSession = this.registerModelSession();
344
+ }
345
+ const session = this.state.modelSession;
346
+ if (!session) {
347
+ throw new GLSPServerError(
348
+ this.sharedServices.MessageRenderer.renderMessage(DIAGRAM_SESSION_REFUSED),
349
+ `clientId=${this.state.clientId} is still held by another participant, so this diagram has no client session`
350
+ );
351
+ }
352
+ return session;
353
+ }
354
+
355
+ /**
356
+ * {@link requireModelSession} for the load: while the id is held, wait up to
357
+ * {@link sessionWaitMs} for its holder to end before refusing, and tell the
358
+ * user when it does not.
359
+ */
360
+ protected async awaitModelSession(): Promise<ModelClientSession<AstNode>> {
361
+ if (!this.state.modelSession && !this.disposed) {
362
+ this.state.modelSession = this.registerModelSession();
363
+ }
364
+ if (!this.state.modelSession && !this.disposed) {
365
+ await this.waitForSessionEnd(this.state.clientId, this.sessionWaitMs);
366
+ }
367
+ try {
368
+ return this.requireModelSession();
369
+ } catch (error: unknown) {
370
+ // A diagram closed while it waited has no client left to tell.
371
+ if (isSessionClosedError(error)) {
372
+ throw error;
373
+ }
374
+ this.logger.error(`Client id ${this.state.clientId} is still held by another participant: this diagram does not load`);
375
+ this.actionDispatcher
376
+ .dispatch(
377
+ MessageAction.create(this.sharedServices.MessageRenderer.renderMessage(DIAGRAM_SESSION_REFUSED), { severity: 'ERROR' })
378
+ )
379
+ .catch((dispatchError: unknown) =>
380
+ this.logger.warn(`Could not tell the client the diagram was refused: ${String(dispatchError)}`)
381
+ );
382
+ throw error;
383
+ }
384
+ }
385
+
386
+ /** Resolve once the client session under `clientId` ends, or after `timeoutMs`. */
387
+ protected waitForSessionEnd(clientId: string, timeoutMs: number): Promise<void> {
388
+ return new Promise<void>(resolve => {
389
+ const subscriptions = new DisposableCollection();
390
+ const done = (): void => {
391
+ subscriptions.dispose();
392
+ resolve();
393
+ };
394
+ subscriptions.push(this.sharedServices.Clock.setTimer(done, timeoutMs));
395
+ subscriptions.push(
396
+ this.sharedServices.workspace.TextDocuments.onDidCloseSession(event => {
397
+ if (event.clientId === clientId) {
398
+ done();
399
+ }
400
+ })
401
+ );
402
+ });
403
+ }
404
+
199
405
  /**
200
406
  * Default load flow — {@link doLoadSourceModel}. Adopters whose load flow
201
407
  * needs more than the seams allow override this method.
@@ -211,8 +417,8 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
211
417
 
212
418
  // Open FIRST: a workspace-scanned-but-never-didOpened document rebuilds here,
213
419
  // so the capture below settles on a built root rather than a transient
214
- // mid-rebuild one.
215
- this.toDispose.push(await modelService.open({ uri: rootUri, clientId: this.state.clientId }));
420
+ // mid-rebuild one. Closed when the session ends.
421
+ await (await this.awaitModelSession()).open(rootUri);
216
422
 
217
423
  // GLSP's sessionDisposed is unreliable on Theia tab-close; dispose on client
218
424
  // detach so reopens don't accumulate stale onModelUpdated listeners.
@@ -226,21 +432,41 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
226
432
  // React to external rebuilds: settle-gated capture + debounced/deduped resubmit.
227
433
  this.toDispose.push(modelService.onModelUpdated(rootUri, event => this.handleModelUpdated(rootUri, event)));
228
434
 
229
- // Coordinate the command stack's dirty state when another client saves.
435
+ // GLSP's own command stack counts commands; tell it of another client's save.
230
436
  this.toDispose.push(modelService.onModelSaved(rootUri, event => this.handleModelSaved(event)));
231
437
 
438
+ // Tell the client of each dirty flip, one the diagram did not cause included.
439
+ this.toDispose.push(this.sharedServices.workspace.TextDocuments.onDidChangeDirty(event => this.handleDirtyChanged(event)));
440
+
232
441
  // Capture the initial settled root.
233
442
  const document = await modelService.settled(rootUri);
234
443
  await this.captureSettledRoot(rootUri, document);
235
444
  }
236
445
 
237
446
  /**
238
- * Capture the initial settled root and apply the initial edit mode. `settled()`
239
- * strips diagnostics by phase contract, so the first edit mode is `EDITABLE`;
240
- * the first {@link handleModelUpdated} (at `Validated`, with diagnostics) flips
241
- * it READONLY if the document has structural parse errors. Edit-mode +
242
- * settle-hook actions are dispatched on a macrotask so the initial
447
+ * Capture the initial settled root and apply the initial edit mode. Edit-mode
448
+ * + settle-hook actions are dispatched on a macrotask so the initial
243
449
  * `requestModel → setModel` handshake isn't perturbed.
450
+ *
451
+ * **Whether the settled document carries diagnostics depends on how far it
452
+ * had already got, so the initial edit mode has two correct outcomes rather
453
+ * than one.** `settled()` strips nothing: it resolves AT OR ABOVE the
454
+ * integrity landmark and hands back the live document's own array. Its
455
+ * `AstDocument<TAst, never>` return type asserts emptiness for a document the
456
+ * wait had to DRIVE to that landmark, which is pre-validation — and for that
457
+ * one the mode here is `EDITABLE` and the flip to READONLY arrives with the
458
+ * first {@link handleModelUpdated}. A document already past `Validated` when
459
+ * the session opened resolves immediately with its diagnostics intact, and is
460
+ * decided correctly here with no later update owed. Any host that validates
461
+ * its workspace before a diagram is opened produces the second case, so
462
+ * neither is exceptional.
463
+ *
464
+ * **A correct decision is not a delivered one.** This dispatch happens inside
465
+ * the initial `requestModel`, before the client has the model — so the UI
466
+ * extensions that answer {@link onParseErrorChanged}'s actions are not yet
467
+ * constructed, and a canvas that opens READONLY over a broken document shows
468
+ * none of that feedback. The same timer carries {@link onSourceModelSettled},
469
+ * so an adopter's own initial actions have it too.
244
470
  */
245
471
  protected async captureSettledRoot(rootUri: string, document: AstDocument<AstNode, never>): Promise<void> {
246
472
  this.state.setSourceRoot(rootUri, document.root as TRoot);
@@ -275,26 +501,52 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
275
501
  if (!current.has(uri)) {
276
502
  subscription.dispose();
277
503
  this.secondarySubscriptions.delete(uri);
504
+ this.departedSecondaries.add(uri);
278
505
  }
279
506
  }
280
507
  for (const uri of current) {
281
508
  if (!this.secondarySubscriptions.has(uri)) {
509
+ this.departedSecondaries.delete(uri);
282
510
  this.secondarySubscriptions.set(
283
511
  uri,
284
512
  this.sharedServices.model.ModelService.onModelUpdated(uri, event => this.handleSecondaryUpdated(uri, event))
285
513
  );
514
+ this.openSecondary(uri);
286
515
  }
287
516
  }
288
517
  }
289
518
 
519
+ /**
520
+ * Open a document that joined the write set through the diagram's session,
521
+ * where it stays open until the diagram's next save after it leaves the set,
522
+ * or the diagram's end. A document that does not exist yet is left to the
523
+ * write that creates it, and a failed open is logged: the write that needs
524
+ * the document opens it again and fails there.
525
+ */
526
+ protected openSecondary(uri: string): void {
527
+ const session = this.state.modelSession;
528
+ if (!session || !this.sharedServices.model.ModelService.getDocument(uri)) {
529
+ return;
530
+ }
531
+ session.open(uri).catch((error: unknown) => {
532
+ const detail = `Could not open ${uri} for ${this.state.clientId}: ${error instanceof Error ? error.message : String(error)}`;
533
+ // A session ending while the open reads is the diagram closing.
534
+ if (isSessionClosedError(error)) {
535
+ this.logger.debug(detail);
536
+ } else {
537
+ this.logger.warn(detail);
538
+ }
539
+ });
540
+ }
541
+
290
542
  /**
291
543
  * React to a rebuild of a SECONDARY document by resubmitting the diagram, so
292
544
  * an external edit to (say) a layout file reaches the canvas.
293
545
  *
294
- * Reuses {@link handleModelUpdated}'s authorship guard, and that is the load-
295
- * bearing half rather than the resubmit: a diagram interaction that writes a
546
+ * Applies {@link handleModelUpdated}'s guard, and that is the load-bearing
547
+ * half rather than the resubmit: a diagram interaction that writes a
296
548
  * secondary — a drag persisting bounds to a layout file — comes back through
297
- * this listener as the client's own `changed` edit, and resubmitting on it
549
+ * this listener caused by the client's own write, and resubmitting on it
298
550
  * fights the optimistic client-side move the user is still holding.
299
551
  *
300
552
  * Schedules the PRIMARY's current document, never the secondary's.
@@ -312,18 +564,20 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
312
564
  * drag this client authors — the guard above gates the resubmit, not the
313
565
  * markers.
314
566
  */
315
- protected handleSecondaryUpdated(uri: string, event: AstDocumentUpdatedEvent<AstNode, unknown>): void {
567
+ protected handleSecondaryUpdated(uri: string, event: AstDocumentUpdatedEvent<AstNode>): void {
316
568
  if (this.disposeIfStale(uri)) {
317
569
  return;
318
570
  }
319
- if (this.state.clientId === event.sourceClientId && event.reason === 'changed') {
571
+ if (event.causedBy === this.state.clientId) {
320
572
  return;
321
573
  }
322
574
  const primary = this.currentPrimaryDocument();
323
575
  if (primary === undefined) {
324
576
  return;
325
577
  }
326
- this.logger.debug(`Secondary ${uri} rebuilt by ${event.sourceClientId} (${event.reason}) — scheduling resubmit`);
578
+ this.logger.debug(
579
+ `Secondary ${uri} rebuilt, caused by ${event.causedBy ?? 'an unknown client'} (${event.reason}) — scheduling resubmit`
580
+ );
327
581
  this.scheduleUpdateAndSubmit(primary);
328
582
  }
329
583
 
@@ -333,11 +587,12 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
333
587
  *
334
588
  * Synchronous by construction — the phase-agnostic lookup door plus the shared
335
589
  * projection — so reacting to a secondary neither waits nor can force a build.
336
- * `settled()` is not an alternative: it strips diagnostics by phase contract,
337
- * and diagnostics are the reason {@link doUpdateAndSubmit} takes a separate
338
- * event document at all.
590
+ * `settled()` is not an alternative: besides being asynchronous, it cannot be
591
+ * relied on to carry diagnostics — a document it has to drive to the landmark
592
+ * arrives pre-validation with an empty array — and diagnostics are the reason
593
+ * {@link doUpdateAndSubmit} takes a separate event document at all.
339
594
  */
340
- protected currentPrimaryDocument(): AstDocument<AstNode, unknown> | undefined {
595
+ protected currentPrimaryDocument(): AstDocument<AstNode> | undefined {
341
596
  const document = this.sharedServices.model.ModelService.getDocument(this.state.sourceUri);
342
597
  return document ? AstDocument.from(document) : undefined;
343
598
  }
@@ -345,16 +600,23 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
345
600
  /**
346
601
  * React to an external rebuild reaching `Validated`. Self-cleans if the
347
602
  * session has vanished but this listener leaked (neither `sessionDisposed`
348
- * nor `onClientClosed` fired on tab close). Resubmits for any change not
349
- * authored by this client's own optimistic `changed` edit, then refreshes
350
- * diagnostic markers for every update — including own edits, which skip
351
- * the resubmit but can still change diagnostics.
603
+ * nor `onClientClosed` fired on tab close). Resubmits for any update this
604
+ * client's own write did not cause alone, then refreshes diagnostic markers
605
+ * for every update — including own writes, which skip the resubmit but can
606
+ * still change diagnostics. An event without `causedBy` is resubmitted.
607
+ *
608
+ * Own writes include a primary that the build of a write to a secondary
609
+ * swept in, which arrives `rebuilt`. Skipping it loses nothing the canvas
610
+ * shows: the operation behind the write submits once the write has
611
+ * answered, and a build steps its whole batch through each phase, so that
612
+ * submit already renders the relinked primary. What the later `Validated`
613
+ * adds are diagnostics, which the marker refresh carries.
352
614
  */
353
- protected async handleModelUpdated(rootUri: string, event: AstDocumentUpdatedEvent<AstNode, unknown>): Promise<void> {
615
+ protected async handleModelUpdated(rootUri: string, event: AstDocumentUpdatedEvent<AstNode>): Promise<void> {
354
616
  if (this.disposeIfStale(rootUri)) {
355
617
  return;
356
618
  }
357
- if (this.state.clientId !== event.sourceClientId || event.reason !== 'changed') {
619
+ if (event.causedBy !== this.state.clientId) {
358
620
  this.scheduleUpdateAndSubmit(event.document);
359
621
  }
360
622
  await this.refreshDiagnosticMarkers();
@@ -382,7 +644,7 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
382
644
  * {@link resubmitDebouncer}, so a burst of rebuilds runs
383
645
  * {@link doUpdateAndSubmit} once with the most recent document.
384
646
  */
385
- protected scheduleUpdateAndSubmit(document: AstDocument<AstNode, unknown>): void {
647
+ protected scheduleUpdateAndSubmit(document: AstDocument<AstNode>): void {
386
648
  this.pendingResubmitDocument = document;
387
649
  this.resubmitDebouncer.schedule();
388
650
  }
@@ -405,23 +667,55 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
405
667
  }
406
668
 
407
669
  /**
408
- * Coordinate the command stack when another client persists the document:
409
- * mark our stack clean so the editor doesn't prompt to re-save identical
410
- * content. Our own saves already settle the stack through the save flow.
670
+ * For a diagram module on GLSP's own command stack, which counts commands:
671
+ * mark it clean when another client persists the document.
672
+ * `HydraniumGlspCommandStack` reads the text store instead, and this does
673
+ * not move it.
411
674
  */
412
- protected handleModelSaved(event: AstDocumentSavedEvent<AstNode, unknown>): void {
675
+ protected handleModelSaved(event: AstDocumentSavedEvent<AstNode>): void {
413
676
  if (this.state.clientId !== event.sourceClientId) {
414
677
  this.commandStack.saveIsDone();
415
678
  }
416
679
  }
417
680
 
681
+ /**
682
+ * Send the diagram's dirty state when a document it has open turns dirty or
683
+ * clean, the answer being the command stack's. GLSP sends it only with a
684
+ * model submission or a save of the diagram's own, so a save by another
685
+ * client, an editor included, or an edit that changes nothing the diagram
686
+ * shows would otherwise leave the client's marker wrong until the next
687
+ * gesture. A flip the diagram's own operation causes is sent twice, once
688
+ * here and once with the submission, and the client keeps the one state.
689
+ *
690
+ * Nothing is sent while a save of the diagram's own is awaited: GLSP's save
691
+ * handler sends the state once the save settles, reason `save`, and GLSP's
692
+ * client keeps a state only when it changes, so the save's own flip sent
693
+ * here first would leave that answer changing nothing. GLSP's saveable
694
+ * waits for exactly that answer, and times out without it.
695
+ */
696
+ protected handleDirtyChanged(event: DocumentDirtyChangedEvent): void {
697
+ if (this.ownSavesPending > 0 || !this.sharedServices.workspace.TextDocuments.isOpenInClient(event.uri, this.state.clientId)) {
698
+ return;
699
+ }
700
+ this.sendDirtyState();
701
+ }
702
+
703
+ /** Send the command stack's dirty state, reason `external`. */
704
+ protected sendDirtyState(): void {
705
+ this.actionDispatcher
706
+ .dispatch(SetDirtyStateAction.create(this.commandStack.isDirty, { reason: 'external' }))
707
+ .catch((error: unknown) => this.logger.warn(`Could not send the dirty state of ${this.state.sourceUri}: ${String(error)}`));
708
+ }
709
+
418
710
  /**
419
711
  * Re-settle to a guaranteed fully-linked + reprojected root, capture it, and
420
712
  * (unless suppressed) resubmit a deduped external GModel. Diagnostics for the
421
- * edit-mode decision come from the event document (`settled()` strips them by
422
- * phase contract).
713
+ * edit-mode decision come from the event document, because the re-settled one
714
+ * cannot be relied on to have any: a document driven to the landmark arrives
715
+ * pre-validation, and this path re-settles precisely to escape a transient
716
+ * mid-rebuild snapshot.
423
717
  */
424
- protected async doUpdateAndSubmit(rootUri: string, eventDocument: AstDocument<AstNode, unknown>): Promise<Action[]> {
718
+ protected async doUpdateAndSubmit(rootUri: string, eventDocument: AstDocument<AstNode>): Promise<Action[]> {
425
719
  // Settle-gate the capture: never setSourceRoot off the event's possibly-transient
426
720
  // snapshot — the event can arrive while a re-entered document is mid-rebuild.
427
721
  const document = await this.sharedServices.model.ModelService.settled(rootUri);
@@ -459,7 +753,7 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
459
753
  * flip {@link AbstractHydraniumGlspState.editMode}, and — only on a transition —
460
754
  * return the {@link onParseErrorChanged} actions. No transition → no actions.
461
755
  */
462
- protected refreshEditMode(document: AstDocument<AstNode, unknown>): Action[] {
756
+ protected refreshEditMode(document: AstDocument<AstNode>): Action[] {
463
757
  const broken = this.isStructurallyBroken(document);
464
758
  const previousEditMode = this.state.editMode;
465
759
  this.state.editMode = broken ? EditMode.READONLY : EditMode.EDITABLE;
@@ -476,20 +770,53 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
476
770
  * carrying a Langium `lexing-error` / `parsing-error` code. Adopters override
477
771
  * to compose additional break reasons.
478
772
  */
479
- protected isStructurallyBroken(document: AstDocument<AstNode, unknown>): boolean {
773
+ protected isStructurallyBroken(document: AstDocument<AstNode>): boolean {
480
774
  return document.diagnostics.some(diagnostic => isStructuralDiagnostic(diagnostic));
481
775
  }
482
776
 
483
777
  /**
484
- * Seam: actions to emit when the structural-broken state transitions. Default:
485
- * a single {@link SetEditModeAction} toggling READONLY ↔ EDITABLE — editing is
486
- * disabled while the syntax is broken but re-enabled once it parses, a sound
487
- * generic for any Langium-backed diagram. Adopters override to add or replace
488
- * the transition feedback. The {@link AbstractHydraniumGlspState.editMode}
489
- * flip itself is owned by {@link refreshEditMode}.
778
+ * Seam: actions to emit when the structural-broken state transitions.
779
+ * Default: a {@link SetEditModeAction} toggling READONLY ↔ EDITABLE — editing
780
+ * is disabled while the syntax is broken but re-enabled once it parses, a
781
+ * sound generic for any Langium-backed diagram — plus the
782
+ * {@link DIAGRAM_READONLY_PARSE_ERROR} band that says why, cleared on the way
783
+ * back. Adopters override to add or replace the transition feedback. The
784
+ * {@link AbstractHydraniumGlspState.editMode} flip itself is owned by
785
+ * {@link refreshEditMode}.
786
+ *
787
+ * **Both actions or neither, which is why they are one seam rather than
788
+ * two.** The mode flip removes the tool palette and the band is the only
789
+ * account of that, so an override that keeps one and drops the other produces
790
+ * either an unexplained loss of the palette or a warning about a restriction
791
+ * that is not in force.
792
+ *
793
+ * **The status is dispatched with no `timeout`, so it persists** — a
794
+ * time-limited band would describe a condition that outlasts it.
795
+ *
796
+ * **The slot is SHARED, and the other writer wins on one path.**
797
+ * `ModelSubmissionHandler`'s live validation writes the same slot, clearing
798
+ * it with `severity: 'NONE'` when it finishes, and an adopter dispatching its
799
+ * own `StatusAction` replaces this one too. Nothing re-asserts it. That is
800
+ * harmless while a document is being edited, because a resubmit is skipped
801
+ * once the AST is broken (see {@link doUpdateAndSubmit}) — but NOT at load:
802
+ * the initial `requestModel` submit is not that skipped path, so a diagram
803
+ * opened on an already-broken document shows this band and then loses it to
804
+ * the validation clear a moment later. Measured on a browser host, polling the
805
+ * overlay: no element, empty, the sentence, empty again.
490
806
  */
491
- protected onParseErrorChanged(_document: AstDocument<AstNode, unknown>, broken: boolean): Action[] {
492
- return [SetEditModeAction.create(broken ? EditMode.READONLY : EditMode.EDITABLE)];
807
+ protected onParseErrorChanged(_document: AstDocument<AstNode>, broken: boolean): Action[] {
808
+ return [
809
+ SetEditModeAction.create(broken ? EditMode.READONLY : EditMode.EDITABLE),
810
+ broken
811
+ ? StatusAction.create(this.sharedServices.MessageRenderer.renderMessage(DIAGRAM_READONLY_PARSE_ERROR), {
812
+ severity: 'WARNING'
813
+ })
814
+ : // `NONE` is the client's own spelling for "clear", not a severity it
815
+ // renders: `StatusOverlay.handle` branches on it before touching the
816
+ // DOM. An empty message at any other severity leaves an empty band
817
+ // with a warning icon standing on the canvas.
818
+ StatusAction.create('', { severity: 'NONE' })
819
+ ];
493
820
  }
494
821
 
495
822
  /**
@@ -500,32 +827,48 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
500
827
  return [];
501
828
  }
502
829
 
503
- /** The effective {@link SaveConflictPolicy}: the bound option, or {@link DEFAULT_SAVE_CONFLICT_POLICY} when unbound. */
504
- protected get saveConflictPolicy(): SaveConflictPolicy {
505
- return this.boundSaveConflictPolicy ?? DEFAULT_SAVE_CONFLICT_POLICY;
830
+ /** The effective {@link SaveDeliveryPolicy}: the bound option, or {@link DEFAULT_SAVE_DELIVERY_POLICY} when unbound. */
831
+ protected get saveDeliveryPolicy(): SaveDeliveryPolicy {
832
+ return this.boundSaveDeliveryPolicy ?? DEFAULT_SAVE_DELIVERY_POLICY;
506
833
  }
507
834
 
508
835
  /**
509
- * Default save flow: route through `ModelService.save`. The framework
510
- * serialise-and-persist machinery handles serialisation (via the
511
- * language-specific `Serializer` bound at `services.serializer.Serializer`),
512
- * the multi-client text-document update, the rebuild, and the eventual
513
- * `WritableFileSystemProvider.writeFile`.
836
+ * Default save flow: persist the store's current text for every document the
837
+ * diagram's session has open.
838
+ *
839
+ * **A save persists, it does not author.** Every diagram gesture already
840
+ * reached the store through the diagram's session, so the store holds the
841
+ * settled text and disk is the only thing behind. Re-serializing from the AST
842
+ * here cannot improve on that text and can only damage it: a serializer
843
+ * normalises formatting, and the comments the write path carries over keep
844
+ * none of the hand-formatting around them, so a save would reflow a document
845
+ * nothing changed — which is what a pure bounds drag does to the semantic
846
+ * file when only its layout moved.
847
+ *
848
+ * **What is saved is the primary, every tracked secondary
849
+ * (`AbstractHydraniumGlspState.trackSecondaryDocument`) and every document
850
+ * that left the write set since the last save**, so a document the diagram
851
+ * wrote is persisted whether or not it is the one the client named. One the
852
+ * diagram's session does not have open is skipped, even when another client
853
+ * has it open: that client's unsaved edits are not the diagram's to persist.
514
854
  *
515
- * The configured {@link SaveConflictPolicy} (see {@link saveConflictPolicy})
516
- * decides how a concurrent edit that advanced the document is handled:
517
- * - `overwrite` (default): no based-on guard, await, propagate failures —
518
- * last-write-wins, correct for a single-editor head.
519
- * - `reject`: guard on the captured `state.version`, await, surface a
520
- * `ConflictError` (and any other failure) to the GLSP save action.
521
- * - `drop-and-log`: guard, fire-and-forget, log + swallow failures — a stale
522
- * diagram save is dropped because a concurrent form/code edit already wrote
523
- * the truth, and GLSP exposes no save-failure back-channel.
855
+ * **A save therefore names documents the gesture did not aim at**, which is
856
+ * why an unchanged one is not rewritten even though the user asked for a
857
+ * save: the mtime would move on a file they never touched.
858
+ * `AstDocumentManager.save` decides that per document and announces the save
859
+ * either way.
860
+ *
861
+ * The configured {@link SaveDeliveryPolicy} (see {@link saveDeliveryPolicy})
862
+ * decides await-vs-fire-and-forget and failure handling. It carries no
863
+ * based-on guard: the guard exists to stop a stale writer overwriting a newer
864
+ * document, and a flush persists the store — which already holds every other
865
+ * client's change, including the one that advanced the version.
524
866
  *
525
867
  * Returns `MaybePromise<void>` to match upstream `SourceModelStorage`:
526
- * resolves with the persist under `overwrite`/`reject`, returns synchronously
527
- * under `drop-and-log`. Adopters that bypass `ModelService` (writing through
528
- * `WritableFileSystemProvider` directly) still override the whole method.
868
+ * resolves with the flush under `await`, returns synchronously under
869
+ * `fire-and-forget`. Adopters that bypass `AstDocumentManager` (writing
870
+ * through `WritableFileSystemProvider` directly) still override the whole
871
+ * method.
529
872
  */
530
873
  saveSourceModel(action: SaveModelAction): MaybePromise<void> {
531
874
  // Normalised for the same reason the load path is: `SaveModelAction.fileUri`
@@ -535,24 +878,61 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
535
878
  // the call site rather than inside `getFileUri` so that method's contract —
536
879
  // return what the action said — is unchanged for adopters overriding it.
537
880
  const uri = this.toSourceModelUri(this.getFileUri(action));
538
- const policy = this.saveConflictPolicy;
539
- const persisted = this.sharedServices.model.ModelService.save({
540
- uri,
541
- model: this.state.sourceRoot,
542
- clientId: this.state.clientId,
543
- baseVersion: policy.kind === 'overwrite' ? undefined : this.state.version
544
- }).then(() => undefined);
545
-
546
- if (policy.kind === 'drop-and-log') {
547
- // Fire-and-forget: the diagram save lost the race to a concurrent edit
548
- // that already persisted the truth, so it must neither block the action
549
- // nor surface. Log rather than leave an unhandled rejection.
550
- persisted.catch(error => this.logger.error(`Save failed for ${uri}: ${error instanceof Error ? error.message : String(error)}`));
881
+ if (this.saveDeliveryPolicy.kind === 'fire-and-forget') {
882
+ // Log rather than leave an unhandled rejection: the promise is not
883
+ // returned, so nothing else will observe a failure.
884
+ this.flushWriteSet(uri).catch(error =>
885
+ this.logger.error(`Save failed for ${uri}: ${error instanceof Error ? error.message : String(error)}`)
886
+ );
551
887
  return undefined;
552
888
  }
553
- // overwrite / reject: await; a ConflictError (reject) or any other failure
554
- // propagates to GLSP's save-action handler.
555
- return persisted;
889
+ // Awaited: any failure propagates to GLSP's save-action handler, which
890
+ // then sends no dirty state, so the one the save held back goes here.
891
+ // An override that throws before it returns a promise throws inside the
892
+ // async function too, so the hold always ends.
893
+ this.ownSavesPending++;
894
+ return (async () => {
895
+ try {
896
+ await this.flushWriteSet(uri);
897
+ } catch (error: unknown) {
898
+ this.sendDirtyState();
899
+ throw error;
900
+ } finally {
901
+ this.ownSavesPending--;
902
+ }
903
+ })();
904
+ }
905
+
906
+ /**
907
+ * Save the stored text of `primaryUri`, every tracked secondary and every
908
+ * document that left the write set since the last save, each only where
909
+ * the diagram's session has it open; then close those that left.
910
+ *
911
+ * Deduplicated, because a state that tracks its own primary as a secondary
912
+ * would otherwise save it twice and fire two save notifications for one
913
+ * save. Every save is called in one synchronous step, so each takes its text
914
+ * before anything can close a document of the set: saved one after another,
915
+ * a GLSP session ending during the first write closes the rest unsaved.
916
+ */
917
+ protected async flushWriteSet(primaryUri: string): Promise<void> {
918
+ // Refused like the load: without a session the client id's opens are
919
+ // another participant's, and this would save them.
920
+ this.requireModelSession();
921
+ const documents = this.sharedServices.workspace.AstDocumentManager;
922
+ const textDocuments = this.sharedServices.workspace.TextDocuments;
923
+ const clientId = this.state.clientId;
924
+ // Taken with the targets: a document leaving the set during the writes
925
+ // was not saved by them, and stays open for the next save.
926
+ const departed = [...this.departedSecondaries];
927
+ const targets = [...new Set([primaryUri, ...this.state.secondaryUris, ...departed])].filter(uri =>
928
+ textDocuments.isOpenInClient(uri, clientId)
929
+ );
930
+ await Promise.all(targets.map(target => documents.save(target, clientId)));
931
+ for (const uri of departed) {
932
+ if (this.departedSecondaries.delete(uri) && uri !== this.state.sourceUri) {
933
+ await this.state.modelSession?.close(uri);
934
+ }
935
+ }
556
936
  }
557
937
 
558
938
  /**
@@ -584,11 +964,25 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
584
964
  *
585
965
  * Returns it VERBATIM, in whatever form the client sent;
586
966
  * {@link toSourceModelUri} is what normalises it.
967
+ *
968
+ * A model request DOES carry a request id, so a throw here takes the
969
+ * client-request path rather than the toast path — and on that path `detail`
970
+ * comes from `cause?.toString?.()`. A single-argument throw therefore reaches
971
+ * neither the server log nor the client console: both print `undefined`. So
972
+ * the two readers are addressed separately, as on the save path: `message`
973
+ * names what failed in the user's terms and is rendered, `cause` names the
974
+ * action and the option key that was missing and stays English. On THIS path
975
+ * the cause reaches only logs — `RejectAction.detail` is read by nothing but
976
+ * the client action-dispatcher's `logger.warn` — which is not what makes it
977
+ * English; its content is.
587
978
  */
588
979
  protected getSourceUri(action: RequestModelAction): string {
589
980
  const sourceUri = action.options?.[SOURCE_URI_ARG];
590
981
  if (typeof sourceUri !== 'string') {
591
- throw new GLSPServerError(`Invalid RequestModelAction! Missing argument with key '${SOURCE_URI_ARG}'`);
982
+ throw new GLSPServerError(
983
+ this.sharedServices.MessageRenderer.renderMessage(SOURCE_URI_MISSING),
984
+ `no '${SOURCE_URI_ARG}' option on the ${action.kind} action (received ${typeof sourceUri})`
985
+ );
592
986
  }
593
987
  return sourceUri;
594
988
  }
@@ -600,18 +994,24 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
600
994
  * {@link AbstractHydraniumGlspState.setSourceRoot}.
601
995
  *
602
996
  * A save action carries no request id, so a throw here reaches the user as a
603
- * toast built from `message`, with `cause` behind its details control. The
604
- * two therefore address different readers and must not be collapsed: the
605
- * message names what failed in the user's terms, and the cause names which
606
- * lookups came back empty for whom. Neither can name the URI — not having
607
- * one IS the failure, and both sources are written together, so the typed
608
- * `sourceUri` is equally empty whenever this fires.
997
+ * toast built from `message`, while `cause` travels in
998
+ * `MessageAction.details` — **which a Theia host DOES put in front of a
999
+ * user**, as a "Show details" button on the toast opening a dialog. So the
1000
+ * two are not split by reachability; they are split by AUDIENCE, which is
1001
+ * the older rule and the one that holds here. The message names what failed
1002
+ * in the user's terms and is rendered; the cause names which lookups came
1003
+ * back empty for whom, and stays English because a wire action kind, an
1004
+ * option key and a client id address whoever composes the system — a
1005
+ * translated developer string is a worse outcome than an untranslated one.
1006
+ * Neither can name the URI — not having one IS the failure, and both sources
1007
+ * are written together, so the typed `sourceUri` is equally empty whenever
1008
+ * this fires.
609
1009
  */
610
1010
  protected getFileUri(action: SaveModelAction): string {
611
1011
  const uri = action.fileUri ?? this.state.get<string>(SOURCE_URI_ARG);
612
1012
  if (!uri) {
613
1013
  throw new GLSPServerError(
614
- 'Could not determine where to save this model',
1014
+ this.sharedServices.MessageRenderer.renderMessage(SAVE_TARGET_UNKNOWN),
615
1015
  `no fileUri on the save action and no ${SOURCE_URI_ARG} in the model state (clientId=${this.state.clientId})`
616
1016
  );
617
1017
  }
@@ -644,10 +1044,14 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
644
1044
  }
645
1045
 
646
1046
  /**
647
- * Cancel any pending resubmit and drain every subscription — those registered
1047
+ * Cancel any pending resubmit, drain every subscription — those registered
648
1048
  * during {@link doLoadSourceModel} and the per-secondary ones, which live
649
- * outside {@link toDispose} because they come and go with the write set.
1049
+ * outside {@link toDispose} because they come and go with the write set —
1050
+ * and end the diagram's client session, closing everything it has open.
650
1051
  * Idempotent.
1052
+ *
1053
+ * The session ends last, once the detach listener is gone: its closes would
1054
+ * otherwise report this storage's own teardown as a client detaching.
651
1055
  */
652
1056
  dispose(): void {
653
1057
  this.resubmitDebouncer?.dispose();
@@ -656,5 +1060,8 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
656
1060
  }
657
1061
  this.secondarySubscriptions.clear();
658
1062
  this.toDispose.dispose();
1063
+ this.disposed = true;
1064
+ this.state.modelSession?.dispose();
1065
+ this.state.modelSession = undefined;
659
1066
  }
660
1067
  }