@hydranium/glsp-server 1.0.0-next.31 → 1.0.0-next.315

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 (217) hide show
  1. package/README.md +80 -88
  2. package/lib/browser/start-glsp-server-in-worker.d.ts +43 -42
  3. package/lib/browser/start-glsp-server-in-worker.d.ts.map +1 -1
  4. package/lib/browser/start-glsp-server-in-worker.js +62 -13
  5. package/lib/browser/start-glsp-server-in-worker.js.map +1 -1
  6. package/lib/command/diagram-edit-dropped-error.d.ts +31 -0
  7. package/lib/command/diagram-edit-dropped-error.d.ts.map +1 -0
  8. package/lib/command/diagram-edit-dropped-error.js +48 -0
  9. package/lib/command/diagram-edit-dropped-error.js.map +1 -0
  10. package/lib/command/hydranium-glsp-command-stack.d.ts +33 -0
  11. package/lib/command/hydranium-glsp-command-stack.d.ts.map +1 -0
  12. package/lib/command/hydranium-glsp-command-stack.js +63 -0
  13. package/lib/command/hydranium-glsp-command-stack.js.map +1 -0
  14. package/lib/command/hydranium-glsp-operation-action-handler.d.ts +64 -0
  15. package/lib/command/hydranium-glsp-operation-action-handler.d.ts.map +1 -0
  16. package/lib/command/hydranium-glsp-operation-action-handler.js +142 -0
  17. package/lib/command/hydranium-glsp-operation-action-handler.js.map +1 -0
  18. package/lib/command/hydranium-glsp-operation-command.d.ts +250 -0
  19. package/lib/command/hydranium-glsp-operation-command.d.ts.map +1 -0
  20. package/lib/command/hydranium-glsp-operation-command.js +491 -0
  21. package/lib/command/hydranium-glsp-operation-command.js.map +1 -0
  22. package/lib/command/hydranium-glsp-recording-command.d.ts +26 -87
  23. package/lib/command/hydranium-glsp-recording-command.d.ts.map +1 -1
  24. package/lib/command/hydranium-glsp-recording-command.js +40 -117
  25. package/lib/command/hydranium-glsp-recording-command.js.map +1 -1
  26. package/lib/command/hydranium-glsp-undo-redo-action-handler.d.ts +23 -0
  27. package/lib/command/hydranium-glsp-undo-redo-action-handler.d.ts.map +1 -0
  28. package/lib/command/hydranium-glsp-undo-redo-action-handler.js +41 -0
  29. package/lib/command/hydranium-glsp-undo-redo-action-handler.js.map +1 -0
  30. package/lib/command/index.d.ts +5 -0
  31. package/lib/command/index.d.ts.map +1 -1
  32. package/lib/command/index.js +5 -0
  33. package/lib/command/index.js.map +1 -1
  34. package/lib/computed-bounds/hydranium-glsp-computed-bounds-action-handler.d.ts +1 -1
  35. package/lib/computed-bounds/hydranium-glsp-computed-bounds-action-handler.d.ts.map +1 -1
  36. package/lib/computed-bounds/hydranium-glsp-computed-bounds-action-handler.js +2 -2
  37. package/lib/computed-bounds/hydranium-glsp-computed-bounds-action-handler.js.map +1 -1
  38. package/lib/dispatcher/server-action-dispatcher.d.ts +25 -24
  39. package/lib/dispatcher/server-action-dispatcher.d.ts.map +1 -1
  40. package/lib/dispatcher/server-action-dispatcher.js +38 -26
  41. package/lib/dispatcher/server-action-dispatcher.js.map +1 -1
  42. package/lib/index.d.ts +1 -0
  43. package/lib/index.d.ts.map +1 -1
  44. package/lib/index.js +1 -0
  45. package/lib/index.js.map +1 -1
  46. package/lib/launcher/abstract-hydranium-glsp-diagram-module.d.ts +35 -59
  47. package/lib/launcher/abstract-hydranium-glsp-diagram-module.d.ts.map +1 -1
  48. package/lib/launcher/abstract-hydranium-glsp-diagram-module.js +60 -71
  49. package/lib/launcher/abstract-hydranium-glsp-diagram-module.js.map +1 -1
  50. package/lib/launcher/drop-notifications-to-gone-client.d.ts +23 -0
  51. package/lib/launcher/drop-notifications-to-gone-client.d.ts.map +1 -0
  52. package/lib/launcher/drop-notifications-to-gone-client.js +43 -0
  53. package/lib/launcher/drop-notifications-to-gone-client.js.map +1 -0
  54. package/lib/launcher/glsp-app-module.d.ts +1 -2
  55. package/lib/launcher/glsp-app-module.d.ts.map +1 -1
  56. package/lib/launcher/glsp-app-module.js.map +1 -1
  57. package/lib/launcher/glsp-connection-logger.d.ts +19 -0
  58. package/lib/launcher/glsp-connection-logger.d.ts.map +1 -0
  59. package/lib/launcher/glsp-connection-logger.js +24 -0
  60. package/lib/launcher/glsp-connection-logger.js.map +1 -0
  61. package/lib/launcher/glsp-server-overrides.d.ts +10 -8
  62. package/lib/launcher/glsp-server-overrides.d.ts.map +1 -1
  63. package/lib/launcher/glsp-server-overrides.js +25 -11
  64. package/lib/launcher/glsp-server-overrides.js.map +1 -1
  65. package/lib/launcher/hydranium-glsp-server.d.ts +60 -15
  66. package/lib/launcher/hydranium-glsp-server.d.ts.map +1 -1
  67. package/lib/launcher/hydranium-glsp-server.js +145 -22
  68. package/lib/launcher/hydranium-glsp-server.js.map +1 -1
  69. package/lib/launcher/index.d.ts +2 -0
  70. package/lib/launcher/index.d.ts.map +1 -1
  71. package/lib/launcher/index.js +5 -4
  72. package/lib/launcher/index.js.map +1 -1
  73. package/lib/logging/glsp-client-logger.d.ts +10 -6
  74. package/lib/logging/glsp-client-logger.d.ts.map +1 -1
  75. package/lib/logging/glsp-client-logger.js +14 -7
  76. package/lib/logging/glsp-client-logger.js.map +1 -1
  77. package/lib/messages/index.d.ts +11 -6
  78. package/lib/messages/index.d.ts.map +1 -1
  79. package/lib/messages/index.js +11 -6
  80. package/lib/messages/index.js.map +1 -1
  81. package/lib/node/start-glsp-server.d.ts +50 -15
  82. package/lib/node/start-glsp-server.d.ts.map +1 -1
  83. package/lib/node/start-glsp-server.js +97 -30
  84. package/lib/node/start-glsp-server.js.map +1 -1
  85. package/lib/state/abstract-hydranium-glsp-state.d.ts +326 -102
  86. package/lib/state/abstract-hydranium-glsp-state.d.ts.map +1 -1
  87. package/lib/state/abstract-hydranium-glsp-state.js +529 -139
  88. package/lib/state/abstract-hydranium-glsp-state.js.map +1 -1
  89. package/lib/state/diagram-status.d.ts +38 -0
  90. package/lib/state/diagram-status.d.ts.map +1 -0
  91. package/lib/state/diagram-status.js +27 -0
  92. package/lib/state/diagram-status.js.map +1 -0
  93. package/lib/state/full-text-hydranium-glsp-state.d.ts +22 -9
  94. package/lib/state/full-text-hydranium-glsp-state.d.ts.map +1 -1
  95. package/lib/state/full-text-hydranium-glsp-state.js +45 -18
  96. package/lib/state/full-text-hydranium-glsp-state.js.map +1 -1
  97. package/lib/state/hydranium-glsp-index.d.ts +40 -29
  98. package/lib/state/hydranium-glsp-index.d.ts.map +1 -1
  99. package/lib/state/hydranium-glsp-index.js +82 -45
  100. package/lib/state/hydranium-glsp-index.js.map +1 -1
  101. package/lib/state/hydranium-shared-core-services.d.ts +16 -29
  102. package/lib/state/hydranium-shared-core-services.d.ts.map +1 -1
  103. package/lib/state/hydranium-shared-core-services.js +15 -28
  104. package/lib/state/hydranium-shared-core-services.js.map +1 -1
  105. package/lib/state/index.d.ts +1 -0
  106. package/lib/state/index.d.ts.map +1 -1
  107. package/lib/state/index.js +1 -0
  108. package/lib/state/index.js.map +1 -1
  109. package/lib/state/model-ready-timeout-error.d.ts.map +1 -1
  110. package/lib/state/model-ready-timeout-error.js.map +1 -1
  111. package/lib/state/reconcile-source-model-write.d.ts +13 -27
  112. package/lib/state/reconcile-source-model-write.d.ts.map +1 -1
  113. package/lib/state/reconcile-source-model-write.js +44 -32
  114. package/lib/state/reconcile-source-model-write.js.map +1 -1
  115. package/lib/state/reconciling-multi-document-glsp-state.d.ts +125 -69
  116. package/lib/state/reconciling-multi-document-glsp-state.d.ts.map +1 -1
  117. package/lib/state/reconciling-multi-document-glsp-state.js +242 -100
  118. package/lib/state/reconciling-multi-document-glsp-state.js.map +1 -1
  119. package/lib/state/reconciling-transfer-hydranium-glsp-state.d.ts +36 -28
  120. package/lib/state/reconciling-transfer-hydranium-glsp-state.d.ts.map +1 -1
  121. package/lib/state/reconciling-transfer-hydranium-glsp-state.js +59 -42
  122. package/lib/state/reconciling-transfer-hydranium-glsp-state.js.map +1 -1
  123. package/lib/status/diagram-status-reporter.d.ts +44 -0
  124. package/lib/status/diagram-status-reporter.d.ts.map +1 -0
  125. package/lib/status/diagram-status-reporter.js +88 -0
  126. package/lib/status/diagram-status-reporter.js.map +1 -0
  127. package/lib/status/hydranium-glsp-request-model-action-handler.d.ts +27 -0
  128. package/lib/status/hydranium-glsp-request-model-action-handler.d.ts.map +1 -0
  129. package/lib/status/hydranium-glsp-request-model-action-handler.js +52 -0
  130. package/lib/status/hydranium-glsp-request-model-action-handler.js.map +1 -0
  131. package/lib/status/hydranium-glsp-set-edit-mode-action-handler.d.ts +27 -0
  132. package/lib/status/hydranium-glsp-set-edit-mode-action-handler.d.ts.map +1 -0
  133. package/lib/status/hydranium-glsp-set-edit-mode-action-handler.js +53 -0
  134. package/lib/status/hydranium-glsp-set-edit-mode-action-handler.js.map +1 -0
  135. package/lib/status/index.d.ts +12 -0
  136. package/lib/status/index.d.ts.map +1 -0
  137. package/lib/status/index.js +12 -0
  138. package/lib/status/index.js.map +1 -0
  139. package/lib/storage/hydranium-glsp-request-save-model-action-handler.d.ts +30 -0
  140. package/lib/storage/hydranium-glsp-request-save-model-action-handler.d.ts.map +1 -0
  141. package/lib/storage/hydranium-glsp-request-save-model-action-handler.js +47 -0
  142. package/lib/storage/hydranium-glsp-request-save-model-action-handler.js.map +1 -0
  143. package/lib/storage/hydranium-glsp-storage.d.ts +324 -143
  144. package/lib/storage/hydranium-glsp-storage.d.ts.map +1 -1
  145. package/lib/storage/hydranium-glsp-storage.js +569 -213
  146. package/lib/storage/hydranium-glsp-storage.js.map +1 -1
  147. package/lib/storage/index.d.ts +2 -1
  148. package/lib/storage/index.d.ts.map +1 -1
  149. package/lib/storage/index.js +2 -1
  150. package/lib/storage/index.js.map +1 -1
  151. package/lib/storage/save-delivery-policy.d.ts +51 -0
  152. package/lib/storage/save-delivery-policy.d.ts.map +1 -0
  153. package/lib/storage/{save-conflict-policy.js → save-delivery-policy.js} +7 -8
  154. package/lib/storage/save-delivery-policy.js.map +1 -0
  155. package/lib/submission/hydranium-glsp-submission-handler.d.ts +44 -4
  156. package/lib/submission/hydranium-glsp-submission-handler.d.ts.map +1 -1
  157. package/lib/submission/hydranium-glsp-submission-handler.js +82 -6
  158. package/lib/submission/hydranium-glsp-submission-handler.js.map +1 -1
  159. package/lib/testing/glsp-harness.d.ts +34 -7
  160. package/lib/testing/glsp-harness.d.ts.map +1 -1
  161. package/lib/testing/glsp-harness.js +47 -19
  162. package/lib/testing/glsp-harness.js.map +1 -1
  163. package/lib/validation/diagnostic-markers.d.ts +3 -3
  164. package/lib/validation/diagnostic-markers.d.ts.map +1 -1
  165. package/lib/validation/diagnostic-markers.js +1 -1
  166. package/lib/validation/diagnostic-markers.js.map +1 -1
  167. package/lib/validation/hydranium-glsp-model-validator.d.ts +1 -2
  168. package/lib/validation/hydranium-glsp-model-validator.d.ts.map +1 -1
  169. package/lib/validation/hydranium-glsp-model-validator.js +1 -1
  170. package/lib/validation/hydranium-glsp-model-validator.js.map +1 -1
  171. package/package.json +31 -52
  172. package/src/browser/start-glsp-server-in-worker.ts +79 -51
  173. package/src/command/diagram-edit-dropped-error.ts +51 -0
  174. package/src/command/hydranium-glsp-command-stack.ts +47 -0
  175. package/src/command/hydranium-glsp-operation-action-handler.ts +141 -0
  176. package/src/command/hydranium-glsp-operation-command.ts +602 -0
  177. package/src/command/hydranium-glsp-recording-command.ts +40 -121
  178. package/src/command/hydranium-glsp-undo-redo-action-handler.ts +29 -0
  179. package/src/command/index.ts +10 -0
  180. package/src/computed-bounds/hydranium-glsp-computed-bounds-action-handler.ts +2 -2
  181. package/src/dispatcher/server-action-dispatcher.ts +39 -31
  182. package/src/index.ts +1 -0
  183. package/src/launcher/abstract-hydranium-glsp-diagram-module.ts +79 -91
  184. package/src/launcher/drop-notifications-to-gone-client.ts +47 -0
  185. package/src/launcher/glsp-app-module.ts +1 -2
  186. package/src/launcher/glsp-connection-logger.ts +27 -0
  187. package/src/launcher/glsp-server-overrides.ts +27 -11
  188. package/src/launcher/hydranium-glsp-server.ts +153 -23
  189. package/src/launcher/index.ts +5 -4
  190. package/src/logging/glsp-client-logger.ts +16 -9
  191. package/src/messages/index.ts +16 -6
  192. package/src/node/start-glsp-server.ts +133 -40
  193. package/src/state/abstract-hydranium-glsp-state.ts +609 -154
  194. package/src/state/diagram-status.ts +41 -0
  195. package/src/state/full-text-hydranium-glsp-state.ts +46 -18
  196. package/src/state/hydranium-glsp-index.ts +85 -41
  197. package/src/state/hydranium-shared-core-services.ts +16 -29
  198. package/src/state/index.ts +1 -0
  199. package/src/state/reconcile-source-model-write.ts +51 -53
  200. package/src/state/reconciling-multi-document-glsp-state.ts +276 -102
  201. package/src/state/reconciling-transfer-hydranium-glsp-state.ts +67 -45
  202. package/src/status/diagram-status-reporter.ts +106 -0
  203. package/src/status/hydranium-glsp-request-model-action-handler.ts +43 -0
  204. package/src/status/hydranium-glsp-set-edit-mode-action-handler.ts +38 -0
  205. package/src/status/index.ts +12 -0
  206. package/src/storage/hydranium-glsp-request-save-model-action-handler.ts +40 -0
  207. package/src/storage/hydranium-glsp-storage.ts +647 -223
  208. package/src/storage/index.ts +2 -1
  209. package/src/storage/save-delivery-policy.ts +49 -0
  210. package/src/submission/hydranium-glsp-submission-handler.ts +91 -5
  211. package/src/testing/glsp-harness.ts +67 -25
  212. package/src/validation/diagnostic-markers.ts +3 -3
  213. package/src/validation/hydranium-glsp-model-validator.ts +4 -5
  214. package/lib/storage/save-conflict-policy.d.ts +0 -56
  215. package/lib/storage/save-conflict-policy.d.ts.map +0 -1
  216. package/lib/storage/save-conflict-policy.js.map +0 -1
  217. package/src/storage/save-conflict-policy.ts +0 -52
@@ -7,7 +7,6 @@
7
7
  * SPDX-License-Identifier: MIT
8
8
  ********************************************************************************/
9
9
 
10
- import { MarkersReason, SetMarkersAction } from '@eclipse-glsp/protocol';
11
10
  import {
12
11
  type Action,
13
12
  ActionDispatcher,
@@ -17,41 +16,64 @@ import {
17
16
  CommandStack,
18
17
  type DefaultCommandStack,
19
18
  type Disposable,
20
- EditMode,
21
19
  GLSPServerError,
22
20
  Logger as GlspLogger,
21
+ type Marker,
22
+ MarkersReason,
23
23
  type MaybePromise,
24
+ MessageAction,
24
25
  ModelState,
25
26
  ModelSubmissionHandler,
26
27
  ModelValidator,
27
28
  type RequestModelAction,
28
29
  SOURCE_URI_ARG,
29
30
  type SaveModelAction,
30
- SetEditModeAction,
31
+ SetDirtyStateAction,
32
+ SetMarkersAction,
31
33
  type SourceModelStorage,
32
34
  TEMPORARY_CLIENT_ID
33
35
  } from '@eclipse-glsp/server';
34
- import { Debouncer, defineMessage, DisposableCollection } from '@hydranium/protocol';
36
+ import {
37
+ Debouncer,
38
+ defineMessage,
39
+ DisposableCollection,
40
+ isDuplicateClientIdError,
41
+ isSessionClosedError,
42
+ RESUME_TOKEN_ARG,
43
+ SessionClosedError,
44
+ describeError,
45
+ describeErrorWithStack
46
+ } from '@hydranium/protocol';
35
47
  import { inject, injectable, optional, postConstruct } from 'inversify';
36
- import { type AstNode } from '@hydranium/langium';
37
- import { URI } from '@hydranium/langium';
38
- import { AstDocument, type AstDocumentSavedEvent, type AstDocumentUpdatedEvent, type ServerSharedServices } from '@hydranium/core';
48
+ import { type AstNode, type ParseResult } from '@hydranium/langium';
49
+ import { URI, UriUtils } from '@hydranium/langium';
50
+ import {
51
+ type AstDocument,
52
+ type AstDocumentSavedEvent,
53
+ type AstDocumentUpdatedEvent,
54
+ type ClientSession as ModelClientSession,
55
+ type ModelDirtyChangedEvent,
56
+ type ServerSharedServices,
57
+ type SessionEndCause
58
+ } from '@hydranium/core';
39
59
  import { DiagnosticSeverity } from 'vscode-languageserver-types';
40
60
  import { type AbstractHydraniumGlspState } from '../state/abstract-hydranium-glsp-state.js';
61
+ import { DiagramStatus, type DiagramStatusEntry } from '../state/diagram-status.js';
41
62
  import { type HydraniumGlspSubmissionHandler } from '../submission/hydranium-glsp-submission-handler.js';
42
63
  import { HydraniumTypes } from '../state/hydranium-shared-core-services.js';
43
- import { DEFAULT_SAVE_CONFLICT_POLICY, SaveConflictPolicy } from './save-conflict-policy.js';
64
+ import { DEFAULT_SAVE_DELIVERY_POLICY, SaveDeliveryPolicy } from './save-delivery-policy.js';
44
65
 
45
66
  /**
46
- * A save action arrived with nowhere to write to.
67
+ * A save action arrived with nowhere to write to. A save action carries no
68
+ * request id, so it falls through to the error handler and `message` becomes
69
+ * the toast text.
47
70
  *
48
- * The one framework-authored GLSP string that reaches a user verbatim: a save
49
- * action carries no request id, so it falls through to the error handler and
50
- * `message` becomes the toast text. Declared for enumeration only — GLSP's
51
- * action protocol has no slot for an identity on any of its notification
52
- * actions, so this code cannot travel and the English is what a client receives.
53
- * `MessageAction.details` is not a substitute: it is prose populated from
54
- * `cause?.toString?.()`, so a code there would sit inside human text.
71
+ * Rendered at the raise site rather than by a carrier method, because GLSP's
72
+ * action protocol has no slot for an identity anywhere — every member of its
73
+ * message, status and reject actions is prose or an enum, and
74
+ * `MessageAction.details` is not a substitute since it is prose populated from
75
+ * `cause?.toString?.()`, and a Theia host shows it to a user on demand. So the
76
+ * throw is the last place that still knows which message this is.
55
77
  */
56
78
  export const SAVE_TARGET_UNKNOWN = defineMessage(
57
79
  'hydranium/glsp-server/save-target-unknown',
@@ -60,16 +82,54 @@ export const SAVE_TARGET_UNKNOWN = defineMessage(
60
82
 
61
83
  /**
62
84
  * A model request arrived without the source URI it is required to carry.
63
- *
64
- * Declared for enumeration only, like its save-path sibling: GLSP's action
65
- * protocol has no slot for an identity, so the English is what a client
66
- * receives.
85
+ * Rendered at the raise site, like its save-path sibling and for the same
86
+ * reason.
67
87
  */
68
88
  export const SOURCE_URI_MISSING = defineMessage(
69
89
  'hydranium/glsp-server/source-uri-missing',
70
90
  'Could not open this model: the request did not say which document to load'
71
91
  );
72
92
 
93
+ /**
94
+ * Why the canvas has stopped accepting edits.
95
+ *
96
+ * **A READONLY canvas is otherwise indistinguishable from a broken one.** The
97
+ * client's answer to a read-only edit mode is to withdraw the tool palette,
98
+ * so the surface a reader was working in silently loses the only control it had,
99
+ * with the cause — a syntax error in a document that may not even be open —
100
+ * nowhere on screen. The mode flip is the mechanism; this is the only part of it
101
+ * a user can see.
102
+ *
103
+ * Rendered at the raise site, like its two siblings above and for the same
104
+ * reason: every member of GLSP's status action is prose or an enum.
105
+ *
106
+ * It names the document and the recovery rather than the fault, because the
107
+ * fault already has a surface — the squiggle and the problems list, both of which
108
+ * say WHICH character — and repeating it here would put a second, less precise
109
+ * account of the same error on screen. What no other surface says is that the
110
+ * diagram is waiting on it, and the document it waits on need not be the one
111
+ * the diagram was opened on.
112
+ */
113
+ export const DIAGRAM_READONLY_PARSE_ERROR = defineMessage(
114
+ 'hydranium/glsp-server/diagram-readonly-parse-error',
115
+ 'Read-only: {document} has a syntax error. Fix it to edit the diagram again.'
116
+ );
117
+
118
+ /**
119
+ * The diagram's GLSP client id is still live as another participant's client
120
+ * id once the load has waited for it to free, so the diagram does not load.
121
+ *
122
+ * Rendered at the raise site, as {@link SAVE_TARGET_UNKNOWN} is and for the
123
+ * same reason, and dispatched to the client as a message of its own: a model
124
+ * request fails as a rejection whose detail is the English cause, which no user
125
+ * sees. It names the recovery: a host that numbers its diagram clients gives a
126
+ * reopened diagram a fresh id.
127
+ */
128
+ export const DIAGRAM_SESSION_REFUSED = defineMessage(
129
+ 'hydranium/glsp-server/diagram-session-refused',
130
+ 'Could not open this diagram: its identifier is still in use by another editor. Close the diagram and open it again.'
131
+ );
132
+
73
133
  /** Window (ms) over which back-to-back external rebuilds collapse into one resubmit. */
74
134
  const EXTERNAL_SUBMIT_DEBOUNCE_MS = 250;
75
135
 
@@ -98,6 +158,16 @@ function isStructuralDiagnostic(diagnostic: unknown): boolean {
98
158
  return candidate.severity === DiagnosticSeverity.Error && (code === 'lexing-error' || code === 'parsing-error');
99
159
  }
100
160
 
161
+ /** Whether `parseResult` holds what Langium's validator reports as an error-severity `lexing-error` or `parsing-error`. */
162
+ function hasStructuralErrors(parseResult: ParseResult): boolean {
163
+ const lexing = parseResult.lexerReport?.diagnostics ?? [];
164
+ return (
165
+ parseResult.parserErrors.length > 0 ||
166
+ parseResult.lexerErrors.length > 0 ||
167
+ lexing.some(diagnostic => (diagnostic.severity ?? 'error') === 'error')
168
+ );
169
+ }
170
+
101
171
  /**
102
172
  * GLSP source-model storage base shared by all hydranium adopters.
103
173
  * Provides the DI signature, the {@link ClientSessionListener} +
@@ -108,27 +178,37 @@ function isStructuralDiagnostic(diagnostic: unknown): boolean {
108
178
  * invariant.
109
179
  *
110
180
  * **The settled-root invariant.** Every {@link AbstractHydraniumGlspState.setSourceRoot}
111
- * the framework performs captures a root that has reached
181
+ * the storage performs captures a root that has reached
112
182
  * `IntegrityService.SettledState` (post-`Linked`, references indexed, on-build
113
- * integrity rules applied). The GModel factory therefore always walks a fully
114
- * linked + reprojected AST. The flow never captures off a transient mid-rebuild
115
- * snapshot — notably it re-`settled()`s in the resubmit path rather than trusting
116
- * the `onModelUpdated` event's document, which can arrive while a re-entered
117
- * document is still being rebuilt.
183
+ * integrity rules applied). The end of an operation captures the root its
184
+ * write produced, settled or not, and the operation's own submit waits for the
185
+ * settled one through `ready()`. The GModel factory therefore always walks a
186
+ * fully linked + reprojected AST. The resubmit path re-`settled()`s rather
187
+ * than keeping the `onModelUpdated` event's document: the event's document
188
+ * holds only while its listener runs, and the resubmit runs after a debounce,
189
+ * by which time another build can have reset it.
118
190
  *
119
191
  * Adopters with richer needs override the seams ({@link isStructurallyBroken},
120
- * {@link onParseErrorChanged}, {@link onSourceModelSettled}) rather than the
121
- * whole flow, and select a {@link SaveConflictPolicy} via the bound option to
122
- * tune how {@link saveSourceModel} reacts to a concurrent edit.
192
+ * {@link parseErrorStatus}, {@link onSourceModelSettled}) rather than the
193
+ * whole flow, and select a {@link SaveDeliveryPolicy} via the bound option to
194
+ * tune how {@link saveSourceModel} delivers its result.
195
+ *
196
+ * **One client session per GLSP client session.** The load registers the GLSP
197
+ * client id as a client session ({@link registerModelSession}), with the
198
+ * request's {@link RESUME_TOKEN_ARG}, and hands it to the state as
199
+ * `modelSession`. The primary is opened through it, and
200
+ * every document that joins the write set as it joins
201
+ * ({@link openSecondary}); a document that leaves the write set stays open
202
+ * until the next save, so leaving never reverts the diagram's unsaved edits to
203
+ * it. Disposing the storage ends the session, which closes everything it has
204
+ * open.
123
205
  *
124
- * **Default `saveSourceModel` flow.** Delegates to `ModelService.save`, which
125
- * serialises through the language-specific `Serializer` bound at
126
- * `services.serializer.Serializer` (resolved per-URI via `ServiceRegistry`),
127
- * updates the multi-client text-document store, drives a rebuild, and writes the
128
- * result via the `WritableFileSystemProvider`. The bound
129
- * {@link SaveConflictPolicy} (default {@link DEFAULT_SAVE_CONFLICT_POLICY},
130
- * `overwrite`) decides the based-on-version guard, await-vs-fire-and-forget, and
131
- * failure handling.
206
+ * **Default `saveSourceModel` flow.** Flushes the stored text of every
207
+ * document the diagram's session has open through the session's `persist`, with
208
+ * no serializer in the path — the update path already put the settled text in
209
+ * the store. The bound {@link SaveDeliveryPolicy} (default
210
+ * {@link DEFAULT_SAVE_DELIVERY_POLICY}, `await`) decides
211
+ * await-vs-fire-and-forget and failure handling.
132
212
  *
133
213
  * **tempId filter.** GLSP's `DefaultGlobalActionProvider` spins up a
134
214
  * throwaway per-diagram-type container with upstream's {@link TEMPORARY_CLIENT_ID}
@@ -139,9 +219,12 @@ function isStructuralDiagnostic(diagnostic: unknown): boolean {
139
219
  * **Lifecycle ordering.** `init` runs after DI resolution and parks the
140
220
  * secondary-write-set watch, which outlives any single load.
141
221
  * {@link sessionDisposed} calls {@link dispose}, which drains
142
- * {@link toDispose} idempotently. Every transient subscription created in
143
- * {@link doLoadSourceModel} is parked on {@link toDispose} so the drain catches
144
- * them on client-detach.
222
+ * {@link toDispose} idempotently and then ends the client session. The
223
+ * framework's GLSP server disposes the storage as lost first when it shuts
224
+ * down, which is how the client's connection ending reaches it, so the
225
+ * diagram's unsaved text waits out the release grace. Every
226
+ * transient subscription created in {@link doLoadSourceModel} is parked on
227
+ * {@link toDispose} so the drain catches them on client-detach.
145
228
  */
146
229
  @injectable()
147
230
  export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
@@ -165,14 +248,13 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
165
248
  @inject(ModelValidator) @optional() protected readonly modelValidator?: ModelValidator;
166
249
 
167
250
  /**
168
- * Selected {@link SaveConflictPolicy} for {@link saveSourceModel}. Bind the
169
- * {@link SaveConflictPolicy} token in a `DiagramModule` to choose a policy;
170
- * left unbound it resolves to {@link DEFAULT_SAVE_CONFLICT_POLICY}
171
- * (`overwrite`, last-write-wins). Read via {@link saveConflictPolicy} so the
172
- * fallback is applied even when inversify injects `undefined` for an unbound
173
- * `@optional()` member.
251
+ * Selected {@link SaveDeliveryPolicy} for {@link saveSourceModel}. Bind the
252
+ * {@link SaveDeliveryPolicy} token in a `DiagramModule` to choose a policy;
253
+ * left unbound it resolves to {@link DEFAULT_SAVE_DELIVERY_POLICY}. Read via
254
+ * {@link saveDeliveryPolicy} so the fallback is applied even when inversify
255
+ * injects `undefined` for an unbound `@optional()` member.
174
256
  */
175
- @inject(SaveConflictPolicy) @optional() protected readonly boundSaveConflictPolicy?: SaveConflictPolicy;
257
+ @inject(SaveDeliveryPolicy) @optional() protected readonly boundSaveDeliveryPolicy?: SaveDeliveryPolicy;
176
258
 
177
259
  /** Disposables created during {@link doLoadSourceModel}; drained on session disposal. */
178
260
  protected toDispose = new DisposableCollection();
@@ -188,13 +270,20 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
188
270
  protected readonly secondarySubscriptions = new Map<string, Disposable>();
189
271
 
190
272
  /**
191
- * Whether an external resubmit has ever been dispatched. Gates the
192
- * keep-the-last-valid-GModel branch, which needs to know a canvas is already
193
- * standing — not what is on it. The content comparison that decides whether a
194
- * resubmit is worth dispatching lives on the submission handler, which is the
195
- * only place that sees operation submits too.
273
+ * Documents that left the write set since the last save. They stay open in
274
+ * the diagram's session until that save has persisted them, and are closed
275
+ * after it: closing a document as it leaves would revert the diagram's
276
+ * unsaved edits to it where the diagram was its last client.
196
277
  */
197
- protected hasSubmittedExternally = false;
278
+ protected readonly departedSecondaries = new Set<string>();
279
+
280
+ /** How long a load without a resume token waits for a held client id to free; see {@link loadModelSession}. */
281
+ protected readonly sessionWaitMs: number = 2_000;
282
+
283
+ /** Set by {@link dispose}; a disposed storage registers no session again. */
284
+ protected disposed = false;
285
+ /** Saves of the diagram's own that GLSP's save handler awaits; see {@link handleDirtyChanged}. */
286
+ protected ownSavesPending = 0;
198
287
 
199
288
  /**
200
289
  * Trailing-edge debounce for the external resubmit. Rescheduled on each
@@ -205,9 +294,6 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
205
294
  */
206
295
  protected resubmitDebouncer!: Debouncer;
207
296
 
208
- /** Latest event document awaiting the debounced resubmit; read by {@link flushResubmit} (last write wins). */
209
- protected pendingResubmitDocument?: AstDocument<AstNode, unknown>;
210
-
211
297
  @postConstruct()
212
298
  protected init(): void {
213
299
  this.resubmitDebouncer = new Debouncer(this.sharedServices.Clock, () => this.flushResubmit(), {
@@ -224,6 +310,110 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
224
310
  this.toDispose.push(this.state.onSecondaryUrisChanged(() => this.reconcileSecondarySubscriptions()));
225
311
  }
226
312
 
313
+ /**
314
+ * Register the diagram's client session under the GLSP client id, or
315
+ * `undefined` when another participant holds that id, which is logged, and
316
+ * the diagram then refuses to load ({@link requireModelSession}). Taking
317
+ * the id over would end that participant's session under it, and working
318
+ * without a session would share its opens and echoes, so its close would
319
+ * take the diagram's documents with it.
320
+ *
321
+ * The exception is a `resumeToken` the holder registered with, which takes
322
+ * its session over: a diagram that reconnected or reloaded, whose old
323
+ * connection the server has not seen close yet.
324
+ *
325
+ * A client id the framework reserves throws `ReservedClientIdError`:
326
+ * whoever chose the GLSP client id has a bug, and no later registration can
327
+ * succeed.
328
+ */
329
+ protected registerModelSession(resumeToken?: string): ModelClientSession<AstNode> | undefined {
330
+ try {
331
+ return this.sharedServices.model.ModelService.createSession('diagram', this.state.clientId, { resumeToken });
332
+ } catch (error: unknown) {
333
+ if (!isDuplicateClientIdError(error)) {
334
+ throw error;
335
+ }
336
+ // Routine while a reconnecting diagram's old session has not closed yet.
337
+ this.logger.debug(`Client id ${this.state.clientId} is held by another participant: this diagram has no session yet`);
338
+ return undefined;
339
+ }
340
+ }
341
+
342
+ /**
343
+ * The diagram's client session. Throws `SessionClosedError` once the
344
+ * storage is disposed, and the refusal before a load has registered one.
345
+ * Registering here instead would start the session without the load's
346
+ * resume token, which a later resume then fails to match.
347
+ */
348
+ protected requireModelSession(): ModelClientSession<AstNode> {
349
+ if (this.disposed) {
350
+ throw new SessionClosedError(this.state.clientId, 'The diagram has closed; its client session has ended.');
351
+ }
352
+ const session = this.state.modelSession;
353
+ if (!session) {
354
+ throw new GLSPServerError(
355
+ this.sharedServices.MessageRenderer.renderMessage(DIAGRAM_SESSION_REFUSED),
356
+ `clientId=${this.state.clientId} has no client session: no load has registered it, or another participant holds the id`
357
+ );
358
+ }
359
+ return session;
360
+ }
361
+
362
+ /**
363
+ * {@link requireModelSession} for the load, registering with the request's
364
+ * resume token, and telling the user when the id is refused. A load with
365
+ * no token waits up to {@link sessionWaitMs} for a held id to free: a
366
+ * client that resumes no session reconnects under its old id before the
367
+ * server has seen the old connection close.
368
+ */
369
+ protected async loadModelSession(action: RequestModelAction): Promise<ModelClientSession<AstNode>> {
370
+ const option = action.options?.[RESUME_TOKEN_ARG];
371
+ const token = typeof option === 'string' ? option : undefined;
372
+ if (!this.state.modelSession && !this.disposed) {
373
+ this.state.modelSession = this.registerModelSession(token);
374
+ }
375
+ if (!this.state.modelSession && !this.disposed && token === undefined) {
376
+ await this.waitForSessionEnd(this.state.clientId, this.sessionWaitMs);
377
+ if (!this.state.modelSession && !this.disposed) {
378
+ this.state.modelSession = this.registerModelSession();
379
+ }
380
+ }
381
+ try {
382
+ return this.requireModelSession();
383
+ } catch (error: unknown) {
384
+ // A diagram closed meanwhile has no client left to tell.
385
+ if (isSessionClosedError(error)) {
386
+ throw error;
387
+ }
388
+ // The error names the held id, and whoever ends the request logs it.
389
+ this.actionDispatcher
390
+ .dispatch(
391
+ MessageAction.create(this.sharedServices.MessageRenderer.renderMessage(DIAGRAM_SESSION_REFUSED), { severity: 'ERROR' })
392
+ )
393
+ .catch((dispatchError: unknown) =>
394
+ this.logger.warn(`Could not tell the client the diagram was refused: ${describeErrorWithStack(dispatchError)}`)
395
+ );
396
+ throw error;
397
+ }
398
+ }
399
+
400
+ /** Resolve once the client session under `clientId` ends, or after `timeoutMs`. */
401
+ protected waitForSessionEnd(clientId: string, timeoutMs: number): Promise<void> {
402
+ return new Promise<void>(resolve => {
403
+ const subscriptions = new DisposableCollection();
404
+ const done = (): void => {
405
+ subscriptions.dispose();
406
+ resolve();
407
+ };
408
+ subscriptions.push(this.sharedServices.Clock.setTimer(done, timeoutMs));
409
+ // A holder this service did not start is waited out by the timer.
410
+ const holder = this.sharedServices.model.ModelService.getSession(clientId);
411
+ if (holder) {
412
+ subscriptions.push(holder.onDidDispose(done));
413
+ }
414
+ });
415
+ }
416
+
227
417
  /**
228
418
  * Default load flow — {@link doLoadSourceModel}. Adopters whose load flow
229
419
  * needs more than the seams allow override this method.
@@ -239,23 +429,29 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
239
429
 
240
430
  // Open FIRST: a workspace-scanned-but-never-didOpened document rebuilds here,
241
431
  // so the capture below settles on a built root rather than a transient
242
- // mid-rebuild one.
243
- this.toDispose.push(await modelService.open({ uri: rootUri, clientId: this.state.clientId }));
432
+ // mid-rebuild one. Closed when the session ends.
433
+ await (await this.loadModelSession(action)).open(rootUri);
244
434
 
245
435
  // GLSP's sessionDisposed is unreliable on Theia tab-close; dispose on client
246
436
  // detach so reopens don't accumulate stale onModelUpdated listeners.
247
437
  this.toDispose.push(
248
- modelService.onClientClosed(rootUri, this.state.clientId, () => {
249
- this.logger.info(`Client detached (${this.state.clientId}) — disposing storage subscriptions for ${rootUri}`);
250
- this.dispose();
251
- })
438
+ modelService.onClientClosed(
439
+ () => {
440
+ this.logger.debug(`Client detached (${this.state.clientId}) — disposing storage subscriptions for ${rootUri}`);
441
+ this.dispose();
442
+ },
443
+ { uri: rootUri, clientId: this.state.clientId }
444
+ )
252
445
  );
253
446
 
254
447
  // React to external rebuilds: settle-gated capture + debounced/deduped resubmit.
255
- this.toDispose.push(modelService.onModelUpdated(rootUri, event => this.handleModelUpdated(rootUri, event)));
448
+ this.toDispose.push(modelService.onModelUpdated(event => this.handleModelUpdated(rootUri, event), { uri: rootUri }));
449
+
450
+ // GLSP's own command stack counts commands; tell it of another client's save.
451
+ this.toDispose.push(modelService.onModelSaved(event => this.handleModelSaved(event), { uri: rootUri }));
256
452
 
257
- // Coordinate the command stack's dirty state when another client saves.
258
- this.toDispose.push(modelService.onModelSaved(rootUri, event => this.handleModelSaved(event)));
453
+ // Tell the client of each dirty flip, one the diagram did not cause included.
454
+ this.toDispose.push(modelService.onDirtyChanged(event => this.handleDirtyChanged(event)));
259
455
 
260
456
  // Capture the initial settled root.
261
457
  const document = await modelService.settled(rootUri);
@@ -263,20 +459,33 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
263
459
  }
264
460
 
265
461
  /**
266
- * Capture the initial settled root and apply the initial edit mode. `settled()`
267
- * strips diagnostics by phase contract, so the first edit mode is `EDITABLE`;
268
- * the first {@link handleModelUpdated} (at `Validated`, with diagnostics) flips
269
- * it READONLY if the document has structural parse errors. Edit-mode +
270
- * settle-hook actions are dispatched on a macrotask so the initial
462
+ * Capture the initial settled root and its parse-error status. Settle-hook
463
+ * actions are dispatched on a macrotask so the initial
271
464
  * `requestModel → setModel` handshake isn't perturbed.
465
+ *
466
+ * The initial status is decided from the root's own parse (see
467
+ * {@link isStructurallyBroken}), not from diagnostics: `settled()` can hand
468
+ * back a document it drove to the integrity landmark, before validation,
469
+ * with no diagnostics yet.
272
470
  */
273
471
  protected async captureSettledRoot(rootUri: string, document: AstDocument<AstNode, never>): Promise<void> {
274
- this.state.setSourceRoot(rootUri, document.root as TRoot);
275
- const actions = [...this.refreshEditMode(document), ...this.onSourceModelSettled(document)];
472
+ await this.state.runExclusive(() => this.state.setSourceRoot(rootUri, document.root as TRoot));
473
+ this.refreshParseErrorStatus(document);
474
+ const actions = this.onSourceModelSettled(document);
276
475
  if (actions.length > 0) {
277
476
  // Defer through the injectable Clock (not raw setTimeout) so the dispatch is
278
477
  // testable and cancels on session disposal; parked on toDispose for that.
279
- this.toDispose.push(this.sharedServices.Clock.setTimer(() => this.actionDispatcher.dispatchAll(actions), 0));
478
+ this.toDispose.push(
479
+ this.sharedServices.Clock.setTimer(
480
+ () =>
481
+ this.actionDispatcher
482
+ .dispatchAll(actions)
483
+ .catch((error: unknown) =>
484
+ this.logger.warn(`Could not send the settled model of ${rootUri}: ${describeErrorWithStack(error)}`)
485
+ ),
486
+ 0
487
+ )
488
+ );
280
489
  }
281
490
  }
282
491
 
@@ -303,87 +512,106 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
303
512
  if (!current.has(uri)) {
304
513
  subscription.dispose();
305
514
  this.secondarySubscriptions.delete(uri);
515
+ this.departedSecondaries.add(uri);
306
516
  }
307
517
  }
308
518
  for (const uri of current) {
309
519
  if (!this.secondarySubscriptions.has(uri)) {
520
+ this.departedSecondaries.delete(uri);
310
521
  this.secondarySubscriptions.set(
311
522
  uri,
312
- this.sharedServices.model.ModelService.onModelUpdated(uri, event => this.handleSecondaryUpdated(uri, event))
523
+ this.sharedServices.model.ModelService.onModelUpdated(event => this.handleSecondaryUpdated(uri, event), { uri })
313
524
  );
525
+ this.openSecondary(uri);
314
526
  }
315
527
  }
316
528
  }
317
529
 
530
+ /**
531
+ * Open a document that joined the write set through the diagram's session,
532
+ * where it stays open until the diagram's next save after it leaves the set,
533
+ * or the diagram's end. A document that does not exist yet is left to the
534
+ * write that creates it, and a failed open is logged: the write that needs
535
+ * the document opens it again and fails there.
536
+ */
537
+ protected openSecondary(uri: string): void {
538
+ const session = this.state.modelSession;
539
+ if (!session || !this.sharedServices.model.ModelService.getDocument(uri)) {
540
+ return;
541
+ }
542
+ session.open(uri).catch((error: unknown) => {
543
+ const detail = `Could not open ${uri} for ${this.state.clientId}: ${describeError(error)}`;
544
+ // A session ending while the open reads is the diagram closing.
545
+ if (isSessionClosedError(error)) {
546
+ this.logger.debug(detail);
547
+ } else {
548
+ this.logger.warn(detail);
549
+ }
550
+ });
551
+ }
552
+
318
553
  /**
319
554
  * React to a rebuild of a SECONDARY document by resubmitting the diagram, so
320
555
  * an external edit to (say) a layout file reaches the canvas.
321
556
  *
322
- * Reuses {@link handleModelUpdated}'s authorship guard, and that is the load-
323
- * bearing half rather than the resubmit: a diagram interaction that writes a
557
+ * Applies {@link handleModelUpdated}'s guard, and that is the load-bearing
558
+ * half rather than the resubmit: a diagram interaction that writes a
324
559
  * secondary — a drag persisting bounds to a layout file — comes back through
325
- * this listener as the client's own `changed` edit, and resubmitting on it
560
+ * this listener caused by the client's own write, and resubmitting on it
326
561
  * fights the optimistic client-side move the user is still holding.
327
562
  *
328
- * Schedules the PRIMARY's current document, never the secondary's.
329
- * {@link doUpdateAndSubmit} reads its event document only for the edit-mode
330
- * and structurally-broken decisions, both of which are statements about the
331
- * document the canvas edits: passing the secondary's would let a typo in a
332
- * layout file flip the canvas READONLY and take the keep-the-last-valid-GModel
333
- * branch off an unrelated document's parse state. The pending-document field
334
- * is shared with the primary path, so it would poison a concurrently
335
- * scheduled primary resubmit too.
336
- *
337
563
  * No marker refresh, unlike the primary path: a secondary's diagnostics reach
338
564
  * the client only if an adopter's index registers elements as rendering that
339
565
  * document, and refreshing here unconditionally would add a dispatch to every
340
566
  * drag this client authors — the guard above gates the resubmit, not the
341
567
  * markers.
342
568
  */
343
- protected handleSecondaryUpdated(uri: string, event: AstDocumentUpdatedEvent<AstNode, unknown>): void {
569
+ protected handleSecondaryUpdated(uri: string, event: AstDocumentUpdatedEvent<AstNode>): void {
344
570
  if (this.disposeIfStale(uri)) {
345
571
  return;
346
572
  }
347
- if (this.state.clientId === event.sourceClientId && event.reason === 'changed') {
573
+ if (event.causedBy === this.state.clientId || this.currentPrimaryDocument() === undefined) {
348
574
  return;
349
575
  }
350
- const primary = this.currentPrimaryDocument();
351
- if (primary === undefined) {
352
- return;
353
- }
354
- this.logger.debug(`Secondary ${uri} rebuilt by ${event.sourceClientId} (${event.reason}) — scheduling resubmit`);
355
- this.scheduleUpdateAndSubmit(primary);
576
+ this.logger.debug(
577
+ `Secondary ${uri} rebuilt, caused by ${event.causedBy ?? 'an unknown client'} (${event.reason}) — scheduling resubmit`
578
+ );
579
+ this.scheduleUpdateAndSubmit();
356
580
  }
357
581
 
358
582
  /**
359
583
  * The primary document's current state, or `undefined` when it is not
360
584
  * registered.
361
585
  *
362
- * Synchronous by construction — the phase-agnostic lookup door plus the shared
363
- * projection — so reacting to a secondary neither waits nor can force a build.
364
- * `settled()` is not an alternative: it strips diagnostics by phase contract,
365
- * and diagnostics are the reason {@link doUpdateAndSubmit} takes a separate
366
- * event document at all.
586
+ * Synchronous by construction, so the parse-error status reads the text as
587
+ * it stands rather than waiting for a build.
367
588
  */
368
- protected currentPrimaryDocument(): AstDocument<AstNode, unknown> | undefined {
589
+ protected currentPrimaryDocument(): AstDocument<AstNode> | undefined {
369
590
  const document = this.sharedServices.model.ModelService.getDocument(this.state.sourceUri);
370
- return document ? AstDocument.from(document) : undefined;
591
+ return document ? this.sharedServices.workspace.AstDocumentManager.toAstDocument(document) : undefined;
371
592
  }
372
593
 
373
594
  /**
374
595
  * React to an external rebuild reaching `Validated`. Self-cleans if the
375
596
  * session has vanished but this listener leaked (neither `sessionDisposed`
376
- * nor `onClientClosed` fired on tab close). Resubmits for any change not
377
- * authored by this client's own optimistic `changed` edit, then refreshes
378
- * diagnostic markers for every update — including own edits, which skip
379
- * the resubmit but can still change diagnostics.
597
+ * nor `onClientClosed` fired on tab close). Resubmits for any update this
598
+ * client's own write did not cause alone, then refreshes diagnostic markers
599
+ * for every update — including own writes, which skip the resubmit but can
600
+ * still change diagnostics. An event without `causedBy` is resubmitted.
601
+ *
602
+ * Own writes include a primary that the build of a write to a secondary
603
+ * swept in, which arrives `rebuilt`. Skipping it loses nothing the canvas
604
+ * shows: the operation behind the write submits once the write has
605
+ * answered, and a build steps its whole batch through each phase, so that
606
+ * submit already renders the relinked primary. What the later `Validated`
607
+ * adds are diagnostics, which the marker refresh carries.
380
608
  */
381
- protected async handleModelUpdated(rootUri: string, event: AstDocumentUpdatedEvent<AstNode, unknown>): Promise<void> {
609
+ protected async handleModelUpdated(rootUri: string, event: AstDocumentUpdatedEvent<AstNode>): Promise<void> {
382
610
  if (this.disposeIfStale(rootUri)) {
383
611
  return;
384
612
  }
385
- if (this.state.clientId !== event.sourceClientId || event.reason !== 'changed') {
386
- this.scheduleUpdateAndSubmit(event.document);
613
+ if (event.causedBy !== this.state.clientId) {
614
+ this.scheduleUpdateAndSubmit();
387
615
  }
388
616
  await this.refreshDiagnosticMarkers();
389
617
  }
@@ -400,72 +628,127 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
400
628
  if (this.sessionManager.getSession(this.state.clientId)) {
401
629
  return false;
402
630
  }
403
- this.logger.warn(`Stale storage (clientId=${this.state.clientId} not in ClientSessionManager) — self-disposing ${context}`);
631
+ this.logger.debug(`Stale storage (clientId=${this.state.clientId} not in ClientSessionManager) — self-disposing ${context}`);
404
632
  this.dispose();
405
633
  return true;
406
634
  }
407
635
 
408
636
  /**
409
- * Record the latest document and (re)arm the trailing-edge
410
- * {@link resubmitDebouncer}, so a burst of rebuilds runs
411
- * {@link doUpdateAndSubmit} once with the most recent document.
637
+ * (Re)arm the trailing-edge {@link resubmitDebouncer}, so a burst of
638
+ * rebuilds runs {@link doUpdateAndSubmit} once.
412
639
  */
413
- protected scheduleUpdateAndSubmit(document: AstDocument<AstNode, unknown>): void {
414
- this.pendingResubmitDocument = document;
640
+ protected scheduleUpdateAndSubmit(): void {
415
641
  this.resubmitDebouncer.schedule();
416
642
  }
417
643
 
418
644
  /**
419
- * Debouncer callback: run the resubmit for the latest pending document
420
- * against this storage's (invariant) source URI and dispatch the result.
421
- * Fire-and-forget — failures are logged, not surfaced to the timer caller.
645
+ * Debouncer callback: run the resubmit against this storage's (invariant)
646
+ * source URI and dispatch the result. Fire-and-forget — failures are
647
+ * logged, not surfaced to the timer caller.
422
648
  */
423
649
  protected flushResubmit(): void {
424
- const document = this.pendingResubmitDocument;
425
- if (document === undefined) {
426
- return;
427
- }
428
650
  const rootUri = this.state.sourceUri;
429
- this.doUpdateAndSubmit(rootUri, document).then(
430
- actions => this.actionDispatcher.dispatchAll(actions),
431
- error => this.logger.error(`Update-and-submit failed for ${rootUri}: ${error instanceof Error ? error.message : String(error)}`)
651
+ this.doUpdateAndSubmit(rootUri).then(
652
+ actions =>
653
+ this.actionDispatcher
654
+ .dispatchAll(actions)
655
+ .catch((error: unknown) =>
656
+ this.logger.warn(`Could not send the resubmitted model of ${rootUri}: ${describeErrorWithStack(error)}`)
657
+ ),
658
+ error => this.logger.error(`Update-and-submit failed for ${rootUri}: ${describeErrorWithStack(error)}`)
432
659
  );
433
660
  }
434
661
 
435
662
  /**
436
- * Coordinate the command stack when another client persists the document:
437
- * mark our stack clean so the editor doesn't prompt to re-save identical
438
- * content. Our own saves already settle the stack through the save flow.
663
+ * For a diagram module on GLSP's own command stack, which counts commands:
664
+ * mark it clean when another client persists the document.
665
+ * `HydraniumGlspCommandStack` reads the text store instead, and this does
666
+ * not move it.
439
667
  */
440
- protected handleModelSaved(event: AstDocumentSavedEvent<AstNode, unknown>): void {
668
+ protected handleModelSaved(event: AstDocumentSavedEvent<AstNode>): void {
441
669
  if (this.state.clientId !== event.sourceClientId) {
442
670
  this.commandStack.saveIsDone();
443
671
  }
444
672
  }
445
673
 
674
+ /**
675
+ * Send the diagram's dirty state when a document it has open turns dirty or
676
+ * clean, the answer being the command stack's. GLSP sends it only with a
677
+ * model submission or a save of the diagram's own, so a save by another
678
+ * client, an editor included, or an edit that changes nothing the diagram
679
+ * shows would otherwise leave the client's marker wrong until the next
680
+ * gesture. A flip the diagram's own operation causes is sent twice, once
681
+ * here and once with the submission, and the client keeps the one state.
682
+ *
683
+ * Nothing is sent while a save of the diagram's own is awaited: GLSP's save
684
+ * handler sends the state once the save settles, reason `save`, and GLSP's
685
+ * client keeps a state only when it changes, so the save's own flip sent
686
+ * here first would leave that answer changing nothing. GLSP's saveable
687
+ * waits for exactly that answer, and times out without it.
688
+ */
689
+ protected handleDirtyChanged(event: ModelDirtyChangedEvent): void {
690
+ if (this.ownSavesPending > 0 || !this.sharedServices.workspace.TextDocuments.isOpenInClient(event.uri, this.state.clientId)) {
691
+ return;
692
+ }
693
+ this.sendDirtyState();
694
+ }
695
+
696
+ /** Send the command stack's dirty state, reason `external`. */
697
+ protected sendDirtyState(): void {
698
+ this.actionDispatcher
699
+ .dispatch(SetDirtyStateAction.create(this.commandStack.isDirty, { reason: 'external' }))
700
+ .catch((error: unknown) =>
701
+ this.logger.warn(`Could not send the dirty state of ${this.state.sourceUri}: ${describeErrorWithStack(error)}`)
702
+ );
703
+ }
704
+
446
705
  /**
447
706
  * Re-settle to a guaranteed fully-linked + reprojected root, capture it, and
448
- * (unless suppressed) resubmit a deduped external GModel. Diagnostics for the
449
- * edit-mode decision come from the event document (`settled()` strips them by
450
- * phase contract).
707
+ * (unless suppressed) resubmit a deduped external GModel.
708
+ *
709
+ * The settle is awaited outside {@link AbstractHydraniumGlspState.runExclusive},
710
+ * so an operation does not wait on a build; the capture and the render run
711
+ * inside it, the render's own wait for the document included, since an
712
+ * operation opened between that wait and the GModel build would be rendered.
451
713
  */
452
- protected async doUpdateAndSubmit(rootUri: string, eventDocument: AstDocument<AstNode, unknown>): Promise<Action[]> {
453
- // Settle-gate the capture: never setSourceRoot off the event's possibly-transient
454
- // snapshot — the event can arrive while a re-entered document is mid-rebuild.
714
+ protected async doUpdateAndSubmit(rootUri: string): Promise<Action[]> {
455
715
  const document = await this.sharedServices.model.ModelService.settled(rootUri);
456
- this.state.setSourceRoot(rootUri, document.root as TRoot);
457
- const editModeActions = this.refreshEditMode(eventDocument);
716
+ return this.state.runExclusive(() => this.captureAndSubmit(rootUri, document.root as TRoot));
717
+ }
718
+
719
+ /**
720
+ * The part of {@link doUpdateAndSubmit} that runs inside the boundary.
721
+ * `root` is not captured when the state already holds a later one: an
722
+ * operation that ended while the settle was awaited captured its own
723
+ * write, which `root` predates.
724
+ *
725
+ * The parse-error status is the primary's as it stands now, which can be
726
+ * past `root`: a status taken from an older document would let an operation
727
+ * write into text that no longer parses.
728
+ */
729
+ protected async captureAndSubmit(rootUri: string, root: TRoot): Promise<Action[]> {
730
+ if (this.sharedServices.workspace.ModelLedger.versionOf(root) >= this.state.version) {
731
+ this.state.setSourceRoot(rootUri, root);
732
+ }
733
+ const primary = this.currentPrimaryDocument();
734
+ if (!primary) {
735
+ // A primary deleted since the settle leaves nothing to be broken.
736
+ this.state.setStatus(DiagramStatus.PARSE_ERROR, undefined);
737
+ }
738
+ const broken = primary ? this.refreshParseErrorStatus(primary) : [];
458
739
 
459
740
  // Skip the external submit until the initial requestModel completes; submitting too
460
741
  // early bumps root.revision and the client's stale first computedBounds is dropped,
461
742
  // leaving the canvas empty.
462
743
  if (this.submissionHandler.hasPendingInitialRequest()) {
463
- return editModeActions;
744
+ return [];
464
745
  }
465
- // While the AST is structurally broken, keep the last valid GModel so the canvas
466
- // doesn't blank mid-typing. READONLY is still flipped for user feedback.
467
- if (this.isStructurallyBroken(eventDocument) && this.hasSubmittedExternally) {
468
- return editModeActions;
746
+ // While a document of the write set is structurally broken, keep the GModel the
747
+ // client has, which the initial request has delivered by now, so the canvas
748
+ // doesn't blank or lose its layout mid-typing. The parse-error status still
749
+ // makes it read-only.
750
+ if (broken.length > 0) {
751
+ return [];
469
752
  }
470
753
  // Read the handler's signature BEFORE submitting and again after: it records
471
754
  // every submission whatever the reason, so an unmoved signature means this
@@ -474,50 +757,76 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
474
757
  // to be answered by an echo of itself — the operation had delivered the
475
758
  // model, and the external comparison had never heard of it.
476
759
  const previousSignature = this.submissionHandler.lastSubmittedSignature;
760
+ // The number this submission takes as it starts.
761
+ const submission = this.submissionHandler.startedSubmissions + 1;
477
762
  const submitActions = await this.submissionHandler.submitModel('external');
478
763
  if (this.submissionHandler.lastSubmittedSignature === previousSignature) {
479
- return editModeActions;
764
+ this.submissionHandler.withdrawLastSubmission(submission);
765
+ return [];
480
766
  }
481
- this.hasSubmittedExternally = true;
482
- return [...submitActions, ...editModeActions];
767
+ return submitActions;
483
768
  }
484
769
 
485
770
  /**
486
- * Recompute the desired edit mode from the document's structural-error state,
487
- * flip {@link AbstractHydraniumGlspState.editMode}, and — only on a transition —
488
- * return the {@link onParseErrorChanged} actions. No transition → no actions.
771
+ * Set {@link DiagramStatus.PARSE_ERROR} to {@link parseErrorStatus} while any
772
+ * document the diagram writes is structurally broken, and withdraw it once
773
+ * they all parse. Answers the documents that are broken.
774
+ *
775
+ * The secondaries count as much as the primary: an operation writes them from
776
+ * the AST their parse recovered, which drops what the parser skipped, so a
777
+ * write into a broken secondary overwrites the user's text.
489
778
  */
490
- protected refreshEditMode(document: AstDocument<AstNode, unknown>): Action[] {
491
- const broken = this.isStructurallyBroken(document);
492
- const previousEditMode = this.state.editMode;
493
- this.state.editMode = broken ? EditMode.READONLY : EditMode.EDITABLE;
494
- if (previousEditMode === this.state.editMode) {
495
- return [];
496
- }
497
- return this.onParseErrorChanged(document, broken);
779
+ protected refreshParseErrorStatus(primary: AstDocument<AstNode>): AstDocument<AstNode>[] {
780
+ const broken = [primary, ...this.secondaryDocuments(primary.uri)].filter(document => this.isStructurallyBroken(document));
781
+ this.state.setStatus(DiagramStatus.PARSE_ERROR, broken.length > 0 ? this.parseErrorStatus(broken) : undefined);
782
+ return broken;
783
+ }
784
+
785
+ /** The write-set secondaries that exist, other than `primaryUri`. */
786
+ protected secondaryDocuments(primaryUri: string): AstDocument<AstNode>[] {
787
+ const { ModelService } = this.sharedServices.model;
788
+ return this.state.secondaryUris
789
+ .filter(uri => uri !== primaryUri)
790
+ .map(uri => ModelService.getDocument(uri))
791
+ .filter(document => document !== undefined)
792
+ .map(document => this.sharedServices.workspace.AstDocumentManager.toAstDocument(document));
498
793
  }
499
794
 
500
795
  /**
501
796
  * Seam: is the AST structurally broken (lexing/parsing errors)? Drives both
502
- * READONLY mode and the GModel resubmit skip — keeping the last valid canvas
503
- * instead of blanking it mid-typing. Default: any error-severity diagnostic
504
- * carrying a Langium `lexing-error` / `parsing-error` code. Adopters override
505
- * to compose additional break reasons.
797
+ * the {@link DiagramStatus.PARSE_ERROR} status and the GModel resubmit
798
+ * skip — keeping the last valid canvas instead of blanking it mid-typing.
799
+ * Default: the lexer and parser errors of
800
+ * the root's own parse, which exist from the parse on, so the answer does
801
+ * not wait for validation; for a root its document has since replaced, any
802
+ * error-severity diagnostic carrying a Langium `lexing-error` /
803
+ * `parsing-error` code. Adopters override to compose additional break
804
+ * reasons.
506
805
  */
507
- protected isStructurallyBroken(document: AstDocument<AstNode, unknown>): boolean {
508
- return document.diagnostics.some(diagnostic => isStructuralDiagnostic(diagnostic));
806
+ protected isStructurallyBroken(document: AstDocument<AstNode>): boolean {
807
+ const parsed = document.root.$document?.parseResult;
808
+ if (parsed?.value === document.root) {
809
+ return hasStructuralErrors(parsed);
810
+ }
811
+ return document.diagnostics?.some(diagnostic => isStructuralDiagnostic(diagnostic)) ?? false;
509
812
  }
510
813
 
511
814
  /**
512
- * Seam: actions to emit when the structural-broken state transitions. Default:
513
- * a single {@link SetEditModeAction} toggling READONLY ↔ EDITABLE — editing is
514
- * disabled while the syntax is broken but re-enabled once it parses, a sound
515
- * generic for any Langium-backed diagram. Adopters override to add or replace
516
- * the transition feedback. The {@link AbstractHydraniumGlspState.editMode}
517
- * flip itself is owned by {@link refreshEditMode}.
815
+ * Seam: the {@link DiagramStatus.PARSE_ERROR} status while `brokenDocuments`
816
+ * do not parse, the primary first when it is among them. Default: a read-only
817
+ * error whose {@link DIAGRAM_READONLY_PARSE_ERROR} message names the first.
818
+ *
819
+ * A read-only status needs a message. Read-only withdraws the tool palette,
820
+ * and without a message nothing on the canvas says why.
518
821
  */
519
- protected onParseErrorChanged(_document: AstDocument<AstNode, unknown>, broken: boolean): Action[] {
520
- return [SetEditModeAction.create(broken ? EditMode.READONLY : EditMode.EDITABLE)];
822
+ protected parseErrorStatus(brokenDocuments: AstDocument<AstNode>[]): DiagramStatusEntry {
823
+ return {
824
+ message: this.sharedServices.MessageRenderer.renderMessage(DIAGRAM_READONLY_PARSE_ERROR, {
825
+ document: UriUtils.basename(URI.parse(brokenDocuments[0].uri))
826
+ }),
827
+ severity: 'ERROR',
828
+ readonly: true
829
+ };
521
830
  }
522
831
 
523
832
  /**
@@ -528,32 +837,49 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
528
837
  return [];
529
838
  }
530
839
 
531
- /** The effective {@link SaveConflictPolicy}: the bound option, or {@link DEFAULT_SAVE_CONFLICT_POLICY} when unbound. */
532
- protected get saveConflictPolicy(): SaveConflictPolicy {
533
- return this.boundSaveConflictPolicy ?? DEFAULT_SAVE_CONFLICT_POLICY;
840
+ /** The effective {@link SaveDeliveryPolicy}: the bound option, or {@link DEFAULT_SAVE_DELIVERY_POLICY} when unbound. */
841
+ protected get saveDeliveryPolicy(): SaveDeliveryPolicy {
842
+ return this.boundSaveDeliveryPolicy ?? DEFAULT_SAVE_DELIVERY_POLICY;
534
843
  }
535
844
 
536
845
  /**
537
- * Default save flow: route through `ModelService.save`. The framework
538
- * serialise-and-persist machinery handles serialisation (via the
539
- * language-specific `Serializer` bound at `services.serializer.Serializer`),
540
- * the multi-client text-document update, the rebuild, and the eventual
541
- * `WritableFileSystemProvider.writeFile`.
846
+ * Default save flow: persist the store's current text for every document the
847
+ * diagram's session has open.
848
+ *
849
+ * **A save persists, it does not author.** Every diagram gesture already
850
+ * reached the store through the diagram's session, so the store holds the
851
+ * settled text and disk is the only thing behind. Re-serializing from the AST
852
+ * here cannot improve on that text and can only damage it: a serializer
853
+ * normalises formatting, and the comments the write path carries over keep
854
+ * none of the hand-formatting around them, so a save would reflow a document
855
+ * nothing changed — which is what a pure bounds drag does to the semantic
856
+ * file when only its layout moved.
857
+ *
858
+ * **What is saved is the primary, every tracked secondary
859
+ * (`AbstractHydraniumGlspState.trackSecondaryDocument`) and every document
860
+ * that left the write set since the last save**, so a document the diagram
861
+ * wrote is persisted whether or not it is the one the client named. One the
862
+ * diagram's session does not have open is skipped, even when another client
863
+ * has it open: that client's unsaved edits are not the diagram's to persist.
864
+ *
865
+ * **A save therefore names documents the gesture did not aim at**, which is
866
+ * why an unchanged one is not rewritten even though the user asked for a
867
+ * save: the mtime would move on a file they never touched. With the default
868
+ * session, `AstDocumentManager.save` decides that per document and
869
+ * announces the save either way.
542
870
  *
543
- * The configured {@link SaveConflictPolicy} (see {@link saveConflictPolicy})
544
- * decides how a concurrent edit that advanced the document is handled:
545
- * - `overwrite` (default): no based-on guard, await, propagate failures —
546
- * last-write-wins, correct for a single-editor head.
547
- * - `reject`: guard on the captured `state.version`, await, surface a
548
- * `ConflictError` (and any other failure) to the GLSP save action.
549
- * - `drop-and-log`: guard, fire-and-forget, log + swallow failures — a stale
550
- * diagram save is dropped because a concurrent form/code edit already wrote
551
- * the truth, and GLSP exposes no save-failure back-channel.
871
+ * The configured {@link SaveDeliveryPolicy} (see {@link saveDeliveryPolicy})
872
+ * decides await-vs-fire-and-forget and failure handling. The save persists
873
+ * at `'any'`: the base-version guard exists to stop a stale writer
874
+ * overwriting a newer document, and a flush persists the store — which
875
+ * already holds every other client's change, including the one that
876
+ * advanced the version.
552
877
  *
553
878
  * Returns `MaybePromise<void>` to match upstream `SourceModelStorage`:
554
- * resolves with the persist under `overwrite`/`reject`, returns synchronously
555
- * under `drop-and-log`. Adopters that bypass `ModelService` (writing through
556
- * `WritableFileSystemProvider` directly) still override the whole method.
879
+ * resolves with the flush under `await`, returns synchronously under
880
+ * `fire-and-forget`. To change how each document is written, override the
881
+ * session class's `persistDocument`; an override of this method that writes
882
+ * past the session skips that override.
557
883
  */
558
884
  saveSourceModel(action: SaveModelAction): MaybePromise<void> {
559
885
  // Normalised for the same reason the load path is: `SaveModelAction.fileUri`
@@ -563,24 +889,92 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
563
889
  // the call site rather than inside `getFileUri` so that method's contract —
564
890
  // return what the action said — is unchanged for adopters overriding it.
565
891
  const uri = this.toSourceModelUri(this.getFileUri(action));
566
- const policy = this.saveConflictPolicy;
567
- const persisted = this.sharedServices.model.ModelService.save({
568
- uri,
569
- model: this.state.sourceRoot,
570
- clientId: this.state.clientId,
571
- baseVersion: policy.kind === 'overwrite' ? undefined : this.state.version
572
- }).then(() => undefined);
573
-
574
- if (policy.kind === 'drop-and-log') {
575
- // Fire-and-forget: the diagram save lost the race to a concurrent edit
576
- // that already persisted the truth, so it must neither block the action
577
- // nor surface. Log rather than leave an unhandled rejection.
578
- persisted.catch(error => this.logger.error(`Save failed for ${uri}: ${error instanceof Error ? error.message : String(error)}`));
892
+ if (this.saveDeliveryPolicy.kind === 'fire-and-forget') {
893
+ // Log rather than leave an unhandled rejection: the promise is not
894
+ // returned, so nothing else will observe a failure. Named for the
895
+ // diagram, since the document that failed may be a secondary.
896
+ this.flushWriteSet(uri).catch(error => this.logger.error(`Diagram save failed for ${uri}: ${describeErrorWithStack(error)}`));
579
897
  return undefined;
580
898
  }
581
- // overwrite / reject: await; a ConflictError (reject) or any other failure
582
- // propagates to GLSP's save-action handler.
583
- return persisted;
899
+ // Awaited: any failure propagates to GLSP's save-action handler, which
900
+ // then sends no dirty state, so the one the save held back goes here.
901
+ // An override that throws before it returns a promise throws inside the
902
+ // async function too, so the hold always ends.
903
+ this.ownSavesPending++;
904
+ return (async () => {
905
+ try {
906
+ await this.flushWriteSet(uri);
907
+ } catch (error: unknown) {
908
+ this.sendDirtyState();
909
+ throw error;
910
+ } finally {
911
+ this.ownSavesPending--;
912
+ }
913
+ })();
914
+ }
915
+
916
+ /**
917
+ * Save the stored text of `primaryUri`, every tracked secondary and every
918
+ * document that left the write set since the last save, each only where
919
+ * the diagram's session has it open; then close those that left. Each goes
920
+ * through the session's `persist`, so an override of the session class's
921
+ * `persistDocument` applies to diagram saves too. Each saved document is
922
+ * then settled in the language client, so the save does not answer before
923
+ * that client has answered the push of what it wrote.
924
+ *
925
+ * Deduplicated, because a state that tracks its own primary as a secondary
926
+ * would otherwise save it twice and fire two save notifications for one
927
+ * save. Every save is called in one synchronous step, so each takes its text
928
+ * before anything can close a document of the set: saved one after another,
929
+ * a GLSP session ending during the first write closes the rest unsaved. A
930
+ * `persistDocument` override that awaits before calling the base gives that
931
+ * up, and a session ending during its await fails the rest with
932
+ * `DocumentNotOpenError`.
933
+ */
934
+ protected async flushWriteSet(primaryUri: string): Promise<void> {
935
+ // Refused like the load: without a session the client id's opens are
936
+ // another participant's, and this would save them.
937
+ const session = this.requireModelSession();
938
+ const textDocuments = this.sharedServices.workspace.TextDocuments;
939
+ const clientId = this.state.clientId;
940
+ // Taken with the targets: a document leaving the set during the writes
941
+ // was not saved by them, and stays open for the next save.
942
+ const departed = [...this.departedSecondaries];
943
+ const targets = [...new Set([primaryUri, ...this.state.secondaryUris, ...departed])].filter(uri =>
944
+ textDocuments.isOpenInClient(uri, clientId)
945
+ );
946
+ // Async, so a persist that throws instead of rejecting fails its own
947
+ // document rather than leaving the rest of the set uncalled. Settled in
948
+ // full before failing, or the save's dirty-state hold ends while the
949
+ // rest are still writing.
950
+ const modelService = this.sharedServices.model.ModelService;
951
+ const results = await Promise.allSettled(
952
+ targets.map(async target => {
953
+ await session.persist({ uri: target, baseVersion: 'any' });
954
+ await modelService.settleLanguageClient(target);
955
+ })
956
+ );
957
+ const failures = results.flatMap((result, i) => (result.status === 'rejected' ? [{ uri: targets[i], reason: result.reason }] : []));
958
+ if (failures.length > 0) {
959
+ // A persist's error need not name its document, so the save's names each one.
960
+ const named = failures.map(({ uri, reason }) => `${uri} (${describeError(reason)})`).join(', ');
961
+ throw new Error(`Save failed for ${named}`, { cause: failures[0].reason });
962
+ }
963
+ for (const uri of departed) {
964
+ if (this.departedSecondaries.delete(uri) && uri !== this.state.sourceUri) {
965
+ try {
966
+ await this.state.modelSession?.close(uri);
967
+ } catch (error: unknown) {
968
+ // A session that ended during the writes closed everything it
969
+ // had open; refusing the close must not fail a save that wrote.
970
+ // Caught around the call, since an ended session throws
971
+ // before it returns a promise.
972
+ if (!isSessionClosedError(error)) {
973
+ throw error;
974
+ }
975
+ }
976
+ }
977
+ }
584
978
  }
585
979
 
586
980
  /**
@@ -616,16 +1010,19 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
616
1010
  * A model request DOES carry a request id, so a throw here takes the
617
1011
  * client-request path rather than the toast path — and on that path `detail`
618
1012
  * comes from `cause?.toString?.()`. A single-argument throw therefore reaches
619
- * neither the client nor the log: both print `undefined`. So the two readers
620
- * are addressed separately, as on the save path: `message` names what failed
621
- * in the user's terms, `cause` names the action and the option key that was
622
- * missing.
1013
+ * neither the server log nor the client console: both print `undefined`. So
1014
+ * the two readers are addressed separately, as on the save path: `message`
1015
+ * names what failed in the user's terms and is rendered, `cause` names the
1016
+ * action and the option key that was missing and stays English. On THIS path
1017
+ * the cause reaches only logs — `RejectAction.detail` is read by nothing but
1018
+ * the client action-dispatcher's `logger.warn` — which is not what makes it
1019
+ * English; its content is.
623
1020
  */
624
1021
  protected getSourceUri(action: RequestModelAction): string {
625
1022
  const sourceUri = action.options?.[SOURCE_URI_ARG];
626
1023
  if (typeof sourceUri !== 'string') {
627
1024
  throw new GLSPServerError(
628
- SOURCE_URI_MISSING.format(),
1025
+ this.sharedServices.MessageRenderer.renderMessage(SOURCE_URI_MISSING),
629
1026
  `no '${SOURCE_URI_ARG}' option on the ${action.kind} action (received ${typeof sourceUri})`
630
1027
  );
631
1028
  }
@@ -639,18 +1036,24 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
639
1036
  * {@link AbstractHydraniumGlspState.setSourceRoot}.
640
1037
  *
641
1038
  * A save action carries no request id, so a throw here reaches the user as a
642
- * toast built from `message`, with `cause` behind its details control. The
643
- * two therefore address different readers and must not be collapsed: the
644
- * message names what failed in the user's terms, and the cause names which
645
- * lookups came back empty for whom. Neither can name the URI — not having
646
- * one IS the failure, and both sources are written together, so the typed
647
- * `sourceUri` is equally empty whenever this fires.
1039
+ * toast built from `message`, while `cause` travels in
1040
+ * `MessageAction.details` — **which a Theia host DOES put in front of a
1041
+ * user**, as a "Show details" button on the toast opening a dialog. So the
1042
+ * two are not split by reachability; they are split by AUDIENCE, which is
1043
+ * the older rule and the one that holds here. The message names what failed
1044
+ * in the user's terms and is rendered; the cause names which lookups came
1045
+ * back empty for whom, and stays English because a wire action kind, an
1046
+ * option key and a client id address whoever composes the system — a
1047
+ * translated developer string is a worse outcome than an untranslated one.
1048
+ * Neither can name the URI — not having one IS the failure, and both sources
1049
+ * are written together, so the typed `sourceUri` is equally empty whenever
1050
+ * this fires.
648
1051
  */
649
1052
  protected getFileUri(action: SaveModelAction): string {
650
1053
  const uri = action.fileUri ?? this.state.get<string>(SOURCE_URI_ARG);
651
1054
  if (!uri) {
652
1055
  throw new GLSPServerError(
653
- SAVE_TARGET_UNKNOWN.format(),
1056
+ this.sharedServices.MessageRenderer.renderMessage(SAVE_TARGET_UNKNOWN),
654
1057
  `no fileUri on the save action and no ${SOURCE_URI_ARG} in the model state (clientId=${this.state.clientId})`
655
1058
  );
656
1059
  }
@@ -669,13 +1072,27 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
669
1072
  * set, so re-pushing the full set clears stale markers (an empty set clears
670
1073
  * all once the model is valid), and sharing the tool-palette validate
671
1074
  * command's `BATCH` reason means the two paths replace rather than duplicate.
1075
+ *
1076
+ * Never rejects: the update listener that calls it is not awaited. A
1077
+ * validator that throws is logged as an error, markers that cannot be sent
1078
+ * as a warning.
672
1079
  */
673
1080
  async refreshDiagnosticMarkers(): Promise<void> {
674
1081
  if (!this.modelValidator) {
675
1082
  return;
676
1083
  }
677
- const markers = await this.modelValidator.validate([this.state.root], MarkersReason.BATCH);
678
- this.actionDispatcher.dispatch(SetMarkersAction.create(markers, { reason: MarkersReason.BATCH }));
1084
+ let markers: Marker[];
1085
+ try {
1086
+ markers = await this.modelValidator.validate([this.state.root], MarkersReason.BATCH);
1087
+ } catch (error: unknown) {
1088
+ this.logger.error(`Validation of ${this.state.sourceUri} failed: ${describeErrorWithStack(error)}`);
1089
+ return;
1090
+ }
1091
+ this.actionDispatcher
1092
+ .dispatch(SetMarkersAction.create(markers, { reason: MarkersReason.BATCH }))
1093
+ .catch((error: unknown) =>
1094
+ this.logger.warn(`Could not send the markers of ${this.state.sourceUri}: ${describeErrorWithStack(error)}`)
1095
+ );
679
1096
  }
680
1097
 
681
1098
  sessionDisposed(_clientSession: ClientSession): void {
@@ -683,17 +1100,24 @@ export class HydraniumGlspStorage<TRoot extends AstNode, TSourceModel = string>
683
1100
  }
684
1101
 
685
1102
  /**
686
- * Cancel any pending resubmit and drain every subscription — those registered
1103
+ * Cancel any pending resubmit, drain every subscription — those registered
687
1104
  * during {@link doLoadSourceModel} and the per-secondary ones, which live
688
- * outside {@link toDispose} because they come and go with the write set.
689
- * Idempotent.
1105
+ * outside {@link toDispose} because they come and go with the write set —
1106
+ * and end the diagram's client session with `cause`, closing everything it
1107
+ * has open. Idempotent.
1108
+ *
1109
+ * The session ends last, once the detach listener is gone: its closes would
1110
+ * otherwise report this storage's own teardown as a client detaching.
690
1111
  */
691
- dispose(): void {
1112
+ dispose(cause: SessionEndCause = 'closed'): void {
692
1113
  this.resubmitDebouncer?.dispose();
693
1114
  for (const subscription of this.secondarySubscriptions.values()) {
694
1115
  subscription.dispose();
695
1116
  }
696
1117
  this.secondarySubscriptions.clear();
697
1118
  this.toDispose.dispose();
1119
+ this.disposed = true;
1120
+ this.state.modelSession?.dispose(cause);
1121
+ this.state.modelSession = undefined;
698
1122
  }
699
1123
  }