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