@hydranium/data-server 1.0.0-next.135 → 1.0.0-next.140
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/data-server.d.ts +107 -146
- package/lib/data-server.d.ts.map +1 -1
- package/lib/data-server.js +138 -185
- package/lib/data-server.js.map +1 -1
- package/package.json +7 -7
- package/src/data-server.ts +173 -194
package/src/data-server.ts
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
type TransferDiagnostic,
|
|
39
39
|
TransferDocument,
|
|
40
40
|
type TransferElement,
|
|
41
|
+
UNKNOWN_CLIENT_ID,
|
|
41
42
|
asSnapshotVersion
|
|
42
43
|
} from '@hydranium/protocol';
|
|
43
44
|
import {
|
|
@@ -63,7 +64,7 @@ import {
|
|
|
63
64
|
type TransferUpdateDocumentArgs,
|
|
64
65
|
type TransferUpdateDocumentsArgs
|
|
65
66
|
} from '@hydranium/protocol/data';
|
|
66
|
-
import { REVERT_ON_CLOSE_CLIENT_ID
|
|
67
|
+
import { REVERT_ON_CLOSE_CLIENT_ID } from '@hydranium/core';
|
|
67
68
|
import { defaultDataServerDiagnostics } from './default-diagnostics.js';
|
|
68
69
|
|
|
69
70
|
/**
|
|
@@ -231,12 +232,13 @@ export interface DataServerOptions extends LogNameOptions {
|
|
|
231
232
|
* (`DataClientProtocol.onDocumentUpdated`).
|
|
232
233
|
*
|
|
233
234
|
* **Subscription dispatch only** — distinct from the synchronous RPC
|
|
234
|
-
* response phase.
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
* controls only the async notification
|
|
235
|
+
* response phase. A read (`getModelDocument`) settles at the
|
|
236
|
+
* integrity-settled landmark (`IntegrityService.SettledState`), or at
|
|
237
|
+
* `Validated` when {@link GetModelDocumentArgs.includeDiagnostics} is set.
|
|
238
|
+
* A write (`updateModelDocument` / `updateModelDocuments` /
|
|
239
|
+
* `saveModelDocument`) answers once its document is validated, as a
|
|
240
|
+
* session's write does. This option controls only the async notification
|
|
241
|
+
* phase.
|
|
240
242
|
*
|
|
241
243
|
* Default: `DocumentState.Validated` — validation is the last builder
|
|
242
244
|
* phase and produces the full diagnostic set, so subscription events fired
|
|
@@ -374,6 +376,33 @@ export interface ResolvedDataServerOptions {
|
|
|
374
376
|
readonly referenceSettleTimeoutMs: number;
|
|
375
377
|
}
|
|
376
378
|
|
|
379
|
+
/**
|
|
380
|
+
* What a {@link DataServer} keeps per canonical URI, in the `protected`
|
|
381
|
+
* {@link DataServer.uriWatchRecords}. A record lives while it has a watcher
|
|
382
|
+
* or a revert mark; {@link DataServer.pruneUriWatchRecord} enforces that on
|
|
383
|
+
* every path that removes either, so a record with no watcher has a revert
|
|
384
|
+
* pending. Test the size of {@link DataServerUriWatchRecord.watchers}, not the
|
|
385
|
+
* record's presence, for "watched".
|
|
386
|
+
*/
|
|
387
|
+
export interface DataServerUriWatchRecord {
|
|
388
|
+
/** The client ids watching the URI; save, dirty and phase events go out only while it is non-empty, a pending revert aside. */
|
|
389
|
+
readonly watchers: Set<string>;
|
|
390
|
+
/** Digest of the last emitted state, or of the state a first watch found; see {@link DataServer.dispatchPhaseEvent} for why it de-duplicates, {@link DataServer.computeDocumentFingerprint} for its inputs. */
|
|
391
|
+
fingerprint?: string;
|
|
392
|
+
/** Set by {@link DataServer.subscribeToTextDocumentCloses}; the next phase event broadcasts even without a watcher. */
|
|
393
|
+
revertPending?: boolean;
|
|
394
|
+
/**
|
|
395
|
+
* The version of an open document the last event sent was built at. The
|
|
396
|
+
* version sent again goes out as `'rebuilt'` from no client: the manager
|
|
397
|
+
* counts a version delivered only once its `Validated` listeners ran, so a
|
|
398
|
+
* build cancelled after the subscription phase would send it as a change
|
|
399
|
+
* twice. Only while the store has the document open: without it, a
|
|
400
|
+
* document can keep one version through changes of its file, so its events
|
|
401
|
+
* keep the manager's attribution.
|
|
402
|
+
*/
|
|
403
|
+
sentVersion?: number;
|
|
404
|
+
}
|
|
405
|
+
|
|
377
406
|
/**
|
|
378
407
|
* Typed-RPC protocol head for the hydranium framework. The data-server
|
|
379
408
|
* is a PEER of `@hydranium/core/lsp` and `@hydranium/glsp-server` —
|
|
@@ -437,8 +466,8 @@ export class DataServer<
|
|
|
437
466
|
};
|
|
438
467
|
|
|
439
468
|
protected readonly options: ResolvedDataServerOptions;
|
|
440
|
-
/**
|
|
441
|
-
protected readonly
|
|
469
|
+
/** This connection's {@link DataServerUriWatchRecord} per canonical URI; see there for when a record lives. */
|
|
470
|
+
protected readonly uriWatchRecords = new Map<string, DataServerUriWatchRecord>();
|
|
442
471
|
/**
|
|
443
472
|
* The client sessions this connection registered, by client id. A document
|
|
444
473
|
* request carrying one of these ids acts as that session; one carrying any
|
|
@@ -452,67 +481,6 @@ export class DataServer<
|
|
|
452
481
|
/** Typed client proxy — sends `data-server/on*` notifications back over the same wire. */
|
|
453
482
|
protected readonly clientProxy: DataClientProtocol<TTransfer, TDiagnostic, TProject>;
|
|
454
483
|
protected readonly disposables = new DisposableCollection();
|
|
455
|
-
/**
|
|
456
|
-
* Snapshot of the most recent `DocumentBuilder.onUpdate` event. Drives
|
|
457
|
-
* `reason` discrimination on outbound `onDocumentUpdated` notifications:
|
|
458
|
-
* a URI in the `changed` list emits `'changed'`, otherwise `'rebuilt'`
|
|
459
|
-
* (cascade rebuild from a dependent URI's change). The same mechanism
|
|
460
|
-
* `AstDocumentManager.onUpdate` uses on the LSP side, so reason fidelity
|
|
461
|
-
* stays consistent between heads. The `deleted` half is read by
|
|
462
|
-
* {@link dispatchDeleteEvents} rather than by the reason discrimination,
|
|
463
|
-
* a deletion travelling on its own channel.
|
|
464
|
-
*/
|
|
465
|
-
protected lastBuildUpdate?: { changed: readonly URI[]; deleted: readonly URI[] };
|
|
466
|
-
/**
|
|
467
|
-
* Per-URI hash of the last emitted (text + diagnostics) state,
|
|
468
|
-
* used by {@link dispatchPhaseEvent} to suppress duplicate
|
|
469
|
-
* `onDocumentUpdated` notifications for rebuilds that produce no
|
|
470
|
-
* observable change since the last emit.
|
|
471
|
-
*
|
|
472
|
-
* Two scenarios routinely produce such rebuilds:
|
|
473
|
-
* 1. **Refresh-triggered rebuilds.** When a second client attaches to a
|
|
474
|
-
* document already open in another client,
|
|
475
|
-
* `HydraniumTextDocuments.refreshContent` fires `onDidChangeContent`
|
|
476
|
-
* purely to re-trigger the build pipeline — the text and diagnostics
|
|
477
|
-
* are identical to the prior emit.
|
|
478
|
-
* 2. **Cascade rebuilds with no diagnostic delta.** Langium rebuilds a
|
|
479
|
-
* document when a dependency changes; if the rebuild produces the
|
|
480
|
-
* same diagnostics, there is nothing new to communicate to subscribers.
|
|
481
|
-
*
|
|
482
|
-
* Without this filter, both scenarios reach subscribers as wire-side
|
|
483
|
-
* `'changed'` events. A subscriber that interprets `'changed'` as
|
|
484
|
-
* "another client wrote new content" and resets its in-memory root to
|
|
485
|
-
* the server view then misreads the spurious event as a concurrent
|
|
486
|
-
* third-party write and loses the user's edits.
|
|
487
|
-
*
|
|
488
|
-
* Initialised on {@link watchModelDocument} to the current
|
|
489
|
-
* document fingerprint so the FIRST phase event after a fresh
|
|
490
|
-
* subscription is also de-duplicated against the state subscribers
|
|
491
|
-
* obtained via `getModelDocument` — they do not need a redundant phase
|
|
492
|
-
* notification for the state they just fetched.
|
|
493
|
-
*
|
|
494
|
-
* Cleared in {@link unwatchModelDocument} when the last
|
|
495
|
-
* subscriber for a URI leaves so memory does not accumulate.
|
|
496
|
-
*
|
|
497
|
-
* Stored as a cyrb53 hex digest (16 chars per URI) rather than the raw
|
|
498
|
-
* text + diagnostics serialisation so the memory cost is constant in
|
|
499
|
-
* document size. See {@link computeDocumentFingerprint} for the inputs.
|
|
500
|
-
*/
|
|
501
|
-
protected readonly lastEmittedFingerprint = new Map<string, string>();
|
|
502
|
-
/**
|
|
503
|
-
* URIs the text store just released after their last close. The store
|
|
504
|
-
* rebuilds such a document from its disk content (discarding unsaved
|
|
505
|
-
* in-session edits), but {@link dispatchPhaseEvent} gates on the
|
|
506
|
-
* subscription map — and the last close typically also removed the last
|
|
507
|
-
* watcher, so consumers that only ever fetch via `getModelDocument` would
|
|
508
|
-
* keep showing the discarded state forever. Marked on the release (see
|
|
509
|
-
* {@link subscribeToTextDocumentCloses}) and consumed by
|
|
510
|
-
* {@link dispatchPhaseEvent}, which broadcasts the following rebuild's
|
|
511
|
-
* phase event even without a subscription — de-duplicated against
|
|
512
|
-
* {@link lastEmittedFingerprint} where an entry survives, so a close
|
|
513
|
-
* whose disk state equals the last emitted state stays silent.
|
|
514
|
-
*/
|
|
515
|
-
protected readonly pendingRevertBroadcasts = new Set<string>();
|
|
516
484
|
protected readonly tracer: Tracer;
|
|
517
485
|
/** The in-flight interactive profile capture, held between {@link startProfiling} and {@link stopProfiling}. */
|
|
518
486
|
protected activeProfile?: DataServerProfileCapture;
|
|
@@ -568,12 +536,7 @@ export class DataServer<
|
|
|
568
536
|
// framework's own rejections.
|
|
569
537
|
renderErrorMessage: error => this.services.MessageRenderer.renderError(error)
|
|
570
538
|
});
|
|
571
|
-
this.disposables.push(
|
|
572
|
-
this.services.workspace.DocumentBuilder.onUpdate((changed, deleted) => {
|
|
573
|
-
this.lastBuildUpdate = { changed, deleted };
|
|
574
|
-
this.dispatchDeleteEvents(deleted);
|
|
575
|
-
})
|
|
576
|
-
);
|
|
539
|
+
this.disposables.push(this.services.workspace.DocumentBuilder.onUpdate((_changed, deleted) => this.dispatchDeleteEvents(deleted)));
|
|
577
540
|
this.subscribeToDocumentBuilder();
|
|
578
541
|
this.subscribeToTextDocumentSaves();
|
|
579
542
|
this.subscribeToDirtyChanges();
|
|
@@ -589,10 +552,9 @@ export class DataServer<
|
|
|
589
552
|
|
|
590
553
|
/**
|
|
591
554
|
* Release everything the constructor wired up — the bound protocol
|
|
592
|
-
* handlers and every listener — and clear
|
|
593
|
-
*
|
|
594
|
-
*
|
|
595
|
-
* closes. Also ends every session this connection registered, which closes
|
|
555
|
+
* handlers and every listener — and clear {@link uriWatchRecords}, so a
|
|
556
|
+
* long-lived shared services bundle does not retain per-connection memory
|
|
557
|
+
* after the connection closes. Also ends every session this connection registered, which closes
|
|
596
558
|
* every document it has open: the SHARED store's state rather than this
|
|
597
559
|
* server's, which outlives the connection unless released here.
|
|
598
560
|
*
|
|
@@ -621,9 +583,7 @@ export class DataServer<
|
|
|
621
583
|
for (const [clientId, session] of this.clientSessions) {
|
|
622
584
|
this.endSession(clientId, session, cause);
|
|
623
585
|
}
|
|
624
|
-
this.
|
|
625
|
-
this.lastEmittedFingerprint.clear();
|
|
626
|
-
this.pendingRevertBroadcasts.clear();
|
|
586
|
+
this.uriWatchRecords.clear();
|
|
627
587
|
}
|
|
628
588
|
|
|
629
589
|
// ============================================================
|
|
@@ -677,10 +637,9 @@ export class DataServer<
|
|
|
677
637
|
session: ClientSession<AstNode, AstDiagnostic, TTransfer>,
|
|
678
638
|
cause: SessionEndCause = 'closed'
|
|
679
639
|
): void {
|
|
680
|
-
for (const [uri,
|
|
681
|
-
if (watchers.delete(clientId)
|
|
682
|
-
this.
|
|
683
|
-
this.lastEmittedFingerprint.delete(uri);
|
|
640
|
+
for (const [uri, record] of this.uriWatchRecords) {
|
|
641
|
+
if (record.watchers.delete(clientId)) {
|
|
642
|
+
this.pruneUriWatchRecord(uri);
|
|
684
643
|
}
|
|
685
644
|
}
|
|
686
645
|
session.dispose(cause);
|
|
@@ -821,28 +780,24 @@ export class DataServer<
|
|
|
821
780
|
* fan out to the wired `clientProxy.onDocumentUpdated`. The
|
|
822
781
|
* bidirectional pattern means the event channel is the client
|
|
823
782
|
* notification surface, not a returned handle — this method only
|
|
824
|
-
* registers the
|
|
783
|
+
* registers the watcher in {@link uriWatchRecords}.
|
|
825
784
|
*
|
|
826
|
-
* Also baselines the
|
|
827
|
-
* {@link
|
|
828
|
-
*
|
|
829
|
-
*
|
|
830
|
-
*
|
|
831
|
-
*
|
|
832
|
-
* redundant phase notification for that same state.
|
|
785
|
+
* Also baselines the URI's emission fingerprint (see
|
|
786
|
+
* {@link dispatchPhaseEvent}) to the document's current state when the
|
|
787
|
+
* first watcher for the URI registers. This guarantees that any phase event firing immediately after
|
|
788
|
+
* the watch with no observable change is suppressed — watchers obtain
|
|
789
|
+
* initial state via `getModelDocument` (or {@link openModelDocument}) and
|
|
790
|
+
* do not need a redundant phase notification for that same state.
|
|
833
791
|
*/
|
|
834
792
|
async watchModelDocument(args: WatchModelDocumentArgs): Promise<void> {
|
|
835
793
|
const uri = this.canonicalKey(args.uri);
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
}
|
|
841
|
-
subscribers.add(args.clientId);
|
|
842
|
-
if (!this.lastEmittedFingerprint.has(uri)) {
|
|
794
|
+
const record = this.uriWatchRecords.get(uri) ?? { watchers: new Set<string>() };
|
|
795
|
+
this.uriWatchRecords.set(uri, record);
|
|
796
|
+
record.watchers.add(args.clientId);
|
|
797
|
+
if (record.fingerprint === undefined) {
|
|
843
798
|
const document = this.services.workspace.LangiumDocuments.getDocument(UriUtils.toUri(uri));
|
|
844
799
|
if (document) {
|
|
845
|
-
|
|
800
|
+
record.fingerprint = this.computeDocumentFingerprint(document);
|
|
846
801
|
}
|
|
847
802
|
}
|
|
848
803
|
}
|
|
@@ -850,29 +805,40 @@ export class DataServer<
|
|
|
850
805
|
/**
|
|
851
806
|
* Remove a watch for `(uri, clientId)` previously created by
|
|
852
807
|
* {@link watchModelDocument}. Idempotent — unwatching twice is a no-op.
|
|
853
|
-
* Dispatch for `uri` stops once no watchers remain,
|
|
854
|
-
*
|
|
855
|
-
* re-baselines against the then-current document state rather than a
|
|
856
|
-
* stale snapshot from the previous watch.
|
|
808
|
+
* Dispatch for `uri` stops once no watchers remain, and the emission
|
|
809
|
+
* fingerprint goes with the last watcher (see {@link pruneUriWatchRecord}).
|
|
857
810
|
*/
|
|
858
811
|
async unwatchModelDocument(args: WatchModelDocumentArgs): Promise<void> {
|
|
859
812
|
const uri = this.canonicalKey(args.uri);
|
|
860
|
-
|
|
861
|
-
|
|
813
|
+
if (this.uriWatchRecords.get(uri)?.watchers.delete(args.clientId)) {
|
|
814
|
+
this.pruneUriWatchRecord(uri);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* Drop what `uri` no longer needs: the
|
|
820
|
+
* {@link DataServerUriWatchRecord.fingerprint} once no client watches it,
|
|
821
|
+
* and the whole record once it holds no
|
|
822
|
+
* {@link DataServerUriWatchRecord.revertPending} mark either. Every path
|
|
823
|
+
* that removes a watcher or consumes a mark ends here.
|
|
824
|
+
*/
|
|
825
|
+
protected pruneUriWatchRecord(uri: string): void {
|
|
826
|
+
const record = this.uriWatchRecords.get(uri);
|
|
827
|
+
if (!record || record.watchers.size > 0) {
|
|
862
828
|
return;
|
|
863
829
|
}
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
this.
|
|
830
|
+
if (record.revertPending) {
|
|
831
|
+
record.fingerprint = undefined;
|
|
832
|
+
} else {
|
|
833
|
+
this.uriWatchRecords.delete(uri);
|
|
868
834
|
}
|
|
869
835
|
}
|
|
870
836
|
|
|
871
837
|
/**
|
|
872
|
-
* Canonicalise a URI string for use as a
|
|
873
|
-
*
|
|
874
|
-
*
|
|
875
|
-
*
|
|
838
|
+
* Canonicalise a URI string for use as a {@link uriWatchRecords} key, via the
|
|
839
|
+
* shared `DocumentUriPolicy`. Callers may send non-canonical URIs
|
|
840
|
+
* (drive-letter casing, percent-encoding differences, or a symlink path)
|
|
841
|
+
* over the wire; the dispatch side keys by
|
|
876
842
|
* `document.uri.toString()` from `LangiumDocuments`, so writer keys must
|
|
877
843
|
* canonicalise to the same form or events silently fail to deliver.
|
|
878
844
|
* Routing through the seam (rather than a bare `UriUtils.normalize`)
|
|
@@ -1247,7 +1213,7 @@ export class DataServer<
|
|
|
1247
1213
|
*
|
|
1248
1214
|
* Payload-free on purpose: URIs only, so a client re-reads what it displays
|
|
1249
1215
|
* rather than being handed transfer documents it did not ask for. That is the
|
|
1250
|
-
* property the
|
|
1216
|
+
* property the watcher gate protects, and it is preserved here by
|
|
1251
1217
|
* carrying no document rather than by gating the message.
|
|
1252
1218
|
*
|
|
1253
1219
|
* Quiet in the common case. Editing a document that an editor has open leaves
|
|
@@ -1256,14 +1222,16 @@ export class DataServer<
|
|
|
1256
1222
|
* nothing because it does not build to this phase. The ceiling is a
|
|
1257
1223
|
* whole-workspace rebuild at the subscription phase: one message, URIs only.
|
|
1258
1224
|
*
|
|
1259
|
-
* The URI is canonicalised for the same reason
|
|
1225
|
+
* The URI is canonicalised for the same reason {@link uriWatchRecords} is keyed
|
|
1260
1226
|
* that way, and is untested for the same reason as its twin in
|
|
1261
1227
|
* {@link dispatchDeleteEvents}: the builder reports URIs out of its own
|
|
1262
1228
|
* store, so a non-canonical one cannot be produced without a fixture
|
|
1263
1229
|
* asserting a shape the real system never emits.
|
|
1264
1230
|
*/
|
|
1265
1231
|
protected dispatchBuiltEvent(built: readonly LangiumDocument[]): void {
|
|
1266
|
-
const uris = built
|
|
1232
|
+
const uris = built
|
|
1233
|
+
.map(document => this.canonicalKey(document.uri.toString()))
|
|
1234
|
+
.filter(uri => !this.uriWatchRecords.get(uri)?.watchers.size);
|
|
1267
1235
|
if (uris.length === 0) {
|
|
1268
1236
|
return;
|
|
1269
1237
|
}
|
|
@@ -1295,29 +1263,41 @@ export class DataServer<
|
|
|
1295
1263
|
}
|
|
1296
1264
|
|
|
1297
1265
|
/**
|
|
1298
|
-
* Send a dirty flip to the connection, gated
|
|
1266
|
+
* Send a dirty flip to the connection, gated on the URI's watchers as
|
|
1299
1267
|
* {@link dispatchSaveEvent} is: the answer at any one moment travels on
|
|
1300
1268
|
* every document sent, so a client that watches nothing reads it there.
|
|
1301
1269
|
*/
|
|
1302
1270
|
protected dispatchDirtyEvent(event: DocumentDirtyChangedEvent): void {
|
|
1303
1271
|
const uri = this.canonicalKey(event.uri);
|
|
1304
|
-
if (this.
|
|
1272
|
+
if (this.uriWatchRecords.get(uri)?.watchers.size) {
|
|
1305
1273
|
this.clientProxy.onDocumentDirtyChanged({ uri, dirty: event.dirty });
|
|
1306
1274
|
}
|
|
1307
1275
|
}
|
|
1308
1276
|
|
|
1309
1277
|
/**
|
|
1310
|
-
* Mark each document the store releases after its last close
|
|
1311
|
-
* {@link
|
|
1312
|
-
*
|
|
1313
|
-
*
|
|
1314
|
-
*
|
|
1315
|
-
*
|
|
1278
|
+
* Mark each document the store releases after its last close, so
|
|
1279
|
+
* {@link dispatchPhaseEvent} broadcasts the following rebuild even without
|
|
1280
|
+
* a watcher. The store rebuilds such a document from its disk content,
|
|
1281
|
+
* discarding unsaved in-session edits, and the last close typically also
|
|
1282
|
+
* removed the last watcher: without the broadcast, a consumer that only
|
|
1283
|
+
* ever fetches via `getModelDocument` keeps showing the discarded state.
|
|
1284
|
+
* The broadcast is de-duplicated against the fingerprint while a watcher
|
|
1285
|
+
* holds one, so a close whose disk state equals the last emitted state
|
|
1286
|
+
* stays silent; with no watcher there is no fingerprint, and every revert
|
|
1287
|
+
* is sent.
|
|
1288
|
+
*
|
|
1289
|
+
* A release, not the close itself: a document whose last client lost its
|
|
1290
|
+
* connection is released only once the revert grace runs out, or when
|
|
1291
|
+
* another client opens it meanwhile, and not at all when a client lost
|
|
1292
|
+
* from it opens it again within its own grace.
|
|
1316
1293
|
*/
|
|
1317
1294
|
protected subscribeToTextDocumentCloses(): void {
|
|
1318
1295
|
this.disposables.push(
|
|
1319
1296
|
this.services.workspace.TextDocuments.onDidCloseLastOpen(event => {
|
|
1320
|
-
this.
|
|
1297
|
+
const uri = this.canonicalKey(event.uri);
|
|
1298
|
+
const record = this.uriWatchRecords.get(uri) ?? { watchers: new Set<string>() };
|
|
1299
|
+
record.revertPending = true;
|
|
1300
|
+
this.uriWatchRecords.set(uri, record);
|
|
1321
1301
|
})
|
|
1322
1302
|
);
|
|
1323
1303
|
}
|
|
@@ -1328,7 +1308,7 @@ export class DataServer<
|
|
|
1328
1308
|
*
|
|
1329
1309
|
* **Deliberately ungated, where {@link dispatchPhaseEvent} and
|
|
1330
1310
|
* {@link dispatchSaveEvent} are gated.** Those two carry a built document and
|
|
1331
|
-
* fire on every build, so the
|
|
1311
|
+
* fire on every build, so the watcher gate is what keeps bandwidth
|
|
1332
1312
|
* proportional to what a client asked for. A deletion is neither: it is one
|
|
1333
1313
|
* URI, it is rare, and it reports the workspace's STRUCTURE rather than a
|
|
1334
1314
|
* document's content. Gating it forces any consumer that displays the
|
|
@@ -1338,7 +1318,7 @@ export class DataServer<
|
|
|
1338
1318
|
* care filters on the URI, which costs it a comparison.
|
|
1339
1319
|
*
|
|
1340
1320
|
* The precedent is the last-close revert broadcast (see
|
|
1341
|
-
* {@link
|
|
1321
|
+
* {@link subscribeToTextDocumentCloses}), where the same judgement was already made
|
|
1342
1322
|
* in the other direction: a transition that matters enough is delivered
|
|
1343
1323
|
* without a subscription.
|
|
1344
1324
|
*
|
|
@@ -1352,35 +1332,38 @@ export class DataServer<
|
|
|
1352
1332
|
* `deleteDocuments` resolves a directory URI to the documents beneath it — so
|
|
1353
1333
|
* no caller has to handle a directory here.
|
|
1354
1334
|
*
|
|
1355
|
-
*
|
|
1356
|
-
*
|
|
1357
|
-
*
|
|
1358
|
-
*
|
|
1359
|
-
* baseline by the next {@link watchModelDocument} and suppresses the first
|
|
1360
|
-
* emit after the file returns with its previous content.
|
|
1335
|
+
* The URI's fingerprint and revert mark are dropped: they describe a
|
|
1336
|
+
* document that no longer exists. A kept fingerprint is adopted as the
|
|
1337
|
+
* baseline and suppresses the first emit after the file returns with its
|
|
1338
|
+
* previous content.
|
|
1361
1339
|
*
|
|
1362
|
-
* The
|
|
1363
|
-
*
|
|
1364
|
-
*
|
|
1340
|
+
* The watchers SURVIVE: a recreated file resumes delivering to them with no
|
|
1341
|
+
* re-subscription, and a client that answers the deletion by closing
|
|
1342
|
+
* releases the watch through `closeModelDocument` anyway.
|
|
1365
1343
|
*/
|
|
1366
1344
|
protected dispatchDeleteEvents(deleted: readonly URI[]): void {
|
|
1367
1345
|
for (const removed of deleted) {
|
|
1368
1346
|
const uri = this.canonicalKey(removed.toString());
|
|
1369
|
-
this.
|
|
1370
|
-
|
|
1347
|
+
const record = this.uriWatchRecords.get(uri);
|
|
1348
|
+
if (record) {
|
|
1349
|
+
record.fingerprint = undefined;
|
|
1350
|
+
record.revertPending = undefined;
|
|
1351
|
+
record.sentVersion = undefined;
|
|
1352
|
+
this.pruneUriWatchRecord(uri);
|
|
1353
|
+
}
|
|
1371
1354
|
this.clientProxy.onDocumentDeleted({ uri });
|
|
1372
1355
|
}
|
|
1373
1356
|
}
|
|
1374
1357
|
|
|
1375
|
-
/** Fan out a save event for the document's URI, gated
|
|
1358
|
+
/** Fan out a save event for the document's URI, gated on the URI's watchers. */
|
|
1376
1359
|
protected dispatchSaveEvent(event: ClientTextDocumentChangeEvent<TextDocument>): void {
|
|
1377
1360
|
// The save event arrives under the CLIENT URI the text store keys by (e.g. a
|
|
1378
|
-
// symlink path S);
|
|
1361
|
+
// symlink path S); `uriWatchRecords` and `dispatchPhaseEvent` key by the
|
|
1379
1362
|
// CANONICAL identity R. Canonicalize before both the gate and the envelope so
|
|
1380
1363
|
// a save of a symlinked file isn't silently dropped (and the envelope resolves
|
|
1381
1364
|
// the R-keyed document rather than missing into an empty one).
|
|
1382
1365
|
const uri = this.canonicalKey(event.document.uri);
|
|
1383
|
-
if (!this.
|
|
1366
|
+
if (!this.uriWatchRecords.get(uri)?.watchers.size) {
|
|
1384
1367
|
return;
|
|
1385
1368
|
}
|
|
1386
1369
|
const response = this.envelope(UriUtils.toUri(uri));
|
|
@@ -1400,13 +1383,25 @@ export class DataServer<
|
|
|
1400
1383
|
* out to multiple local subscribers with an `Emitter<T>` — the
|
|
1401
1384
|
* framework deliberately does NOT promise multi-listener semantics.
|
|
1402
1385
|
*
|
|
1403
|
-
*
|
|
1404
|
-
*
|
|
1405
|
-
*
|
|
1406
|
-
*
|
|
1407
|
-
*
|
|
1408
|
-
*
|
|
1409
|
-
*
|
|
1386
|
+
* An event for a URI no client watches is NOT sent over the wire, so
|
|
1387
|
+
* bandwidth scales with watched URIs rather than with phase events; a
|
|
1388
|
+
* pending revert mark is the exception (see
|
|
1389
|
+
* {@link subscribeToTextDocumentCloses}).
|
|
1390
|
+
*
|
|
1391
|
+
* An event for a rebuild with no observable change since the last emit is
|
|
1392
|
+
* suppressed against the URI's fingerprint. Such rebuilds are routine: a
|
|
1393
|
+
* second client attaching to an open document makes
|
|
1394
|
+
* `HydraniumTextDocuments.refreshContent` fire `onDidChangeContent` purely
|
|
1395
|
+
* to re-trigger the build, and Langium rebuilds a document when a
|
|
1396
|
+
* dependency changes even if its diagnostics come out the same. Sent at a
|
|
1397
|
+
* version this head has not sent, they reach watchers as `'changed'`, and a
|
|
1398
|
+
* watcher that reads `'changed'` as another client's write and resets its
|
|
1399
|
+
* root to the server view loses the user's edits. The first {@link watchModelDocument} baselines the
|
|
1400
|
+
* fingerprint, so the first event after a watch is de-duplicated against
|
|
1401
|
+
* the state the watcher just fetched; it goes with the last watcher, so the
|
|
1402
|
+
* next first watch re-baselines rather than adopting a stale digest. A
|
|
1403
|
+
* cyrb53 digest keeps its memory constant in document size; see
|
|
1404
|
+
* {@link computeDocumentFingerprint} for the inputs.
|
|
1410
1405
|
*/
|
|
1411
1406
|
protected dispatchPhaseEvent(document: LangiumDocument, cancelToken: CancellationToken): void {
|
|
1412
1407
|
if (cancelToken.isCancellationRequested) {
|
|
@@ -1418,16 +1413,23 @@ export class DataServer<
|
|
|
1418
1413
|
return;
|
|
1419
1414
|
}
|
|
1420
1415
|
const uri = document.uri.toString();
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
// the author lookup would yield.
|
|
1425
|
-
const revertedOnClose = this.pendingRevertBroadcasts.delete(uri);
|
|
1426
|
-
if (!this.subscriptions.has(uri) && !revertedOnClose) {
|
|
1416
|
+
const record = this.uriWatchRecords.get(uri);
|
|
1417
|
+
const revertedOnClose = record?.revertPending === true;
|
|
1418
|
+
if (!record || (record.watchers.size === 0 && !revertedOnClose)) {
|
|
1427
1419
|
return;
|
|
1428
1420
|
}
|
|
1421
|
+
if (revertedOnClose) {
|
|
1422
|
+
// Consumed even when watchers exist — the regular dispatch below
|
|
1423
|
+
// serves them, and the mark's attribution is more precise than the
|
|
1424
|
+
// post-close unknown client the manager's attribution would yield.
|
|
1425
|
+
// With no watcher the record goes now, so the fingerprint written
|
|
1426
|
+
// below lands on a detached record and the next first watch
|
|
1427
|
+
// re-baselines instead of adopting it.
|
|
1428
|
+
record.revertPending = undefined;
|
|
1429
|
+
this.pruneUriWatchRecord(uri);
|
|
1430
|
+
}
|
|
1429
1431
|
const fingerprint = this.computeDocumentFingerprint(document);
|
|
1430
|
-
if (
|
|
1432
|
+
if (record.fingerprint === fingerprint) {
|
|
1431
1433
|
// A rebuild that produced no observable change since the last emit —
|
|
1432
1434
|
// suppressed so RPC subscribers don't see a no-op broadcast. Logged at
|
|
1433
1435
|
// debug so a *needed* re-broadcast wrongly suppressed by this dedup
|
|
@@ -1435,11 +1437,20 @@ export class DataServer<
|
|
|
1435
1437
|
this.tracer.withUri(uri).debug(`Suppress onDocumentUpdated v${document.textDocument.version}: fingerprint unchanged`);
|
|
1436
1438
|
return;
|
|
1437
1439
|
}
|
|
1438
|
-
|
|
1440
|
+
record.fingerprint = fingerprint;
|
|
1441
|
+
// The manager's attribution, so this head names the same client as the
|
|
1442
|
+
// in-process heads do for one build, except for a version this head
|
|
1443
|
+
// already sent; see `DataServerUriWatchRecord.sentVersion`.
|
|
1444
|
+
const version = this.services.workspace.AstDocumentManager.isOpen(uri) ? document.textDocument.version : undefined;
|
|
1445
|
+
const { reason, sourceClientId } =
|
|
1446
|
+
version !== undefined && record.sentVersion === version
|
|
1447
|
+
? { reason: 'rebuilt' as const, sourceClientId: UNKNOWN_CLIENT_ID }
|
|
1448
|
+
: this.services.workspace.AstDocumentManager.attributeUpdate(document);
|
|
1449
|
+
record.sentVersion = version;
|
|
1439
1450
|
const event: TransferDocumentUpdatedEvent<TTransfer, TDiagnostic> = {
|
|
1440
1451
|
document: this.envelope(document.uri),
|
|
1441
|
-
sourceClientId: revertedOnClose ? REVERT_ON_CLOSE_CLIENT_ID :
|
|
1442
|
-
reason
|
|
1452
|
+
sourceClientId: revertedOnClose ? REVERT_ON_CLOSE_CLIENT_ID : sourceClientId,
|
|
1453
|
+
reason
|
|
1443
1454
|
};
|
|
1444
1455
|
this.tracer
|
|
1445
1456
|
.withUri(uri)
|
|
@@ -1487,24 +1498,6 @@ export class DataServer<
|
|
|
1487
1498
|
return [];
|
|
1488
1499
|
}
|
|
1489
1500
|
|
|
1490
|
-
/**
|
|
1491
|
-
* Discriminate the reason for a phase-event-driven update notification.
|
|
1492
|
-
* Uses the most recent `DocumentBuilder.onUpdate` snapshot:
|
|
1493
|
-
* - URI in the `changed` list → `'changed'` (the URI was passed to
|
|
1494
|
-
* `documentBuilder.update(changed, deleted)`, which spans `didChange`
|
|
1495
|
-
* text-document events and programmatic `update([uri], [])` calls).
|
|
1496
|
-
* - Otherwise → `'rebuilt'` (cascade re-derivation: this URI was rebuilt
|
|
1497
|
-
* because something it depends on changed; its own text wasn't flagged).
|
|
1498
|
-
*
|
|
1499
|
-
* `'saved'` is NOT emitted here — saves take the dedicated
|
|
1500
|
-
* `DataClientProtocol.onDocumentSaved` channel, and adopters wanting a
|
|
1501
|
-
* unified stream synthesise it in their bridge layer. A deletion is not a
|
|
1502
|
-
* reason at all; see {@link dispatchDeleteEvents}.
|
|
1503
|
-
*/
|
|
1504
|
-
protected resolveUpdateReason(uri: URI): TransferDocumentUpdatedEvent<TTransfer, TDiagnostic>['reason'] {
|
|
1505
|
-
return this.lastBuildUpdate?.changed.some(changed => UriUtils.equals(changed, uri)) ? 'changed' : 'rebuilt';
|
|
1506
|
-
}
|
|
1507
|
-
|
|
1508
1501
|
/**
|
|
1509
1502
|
* Subscribe to the project tier's change channel and re-fan registry
|
|
1510
1503
|
* diffs into per-project wire notifications. The internal
|
|
@@ -1548,20 +1541,6 @@ export class DataServer<
|
|
|
1548
1541
|
}
|
|
1549
1542
|
}
|
|
1550
1543
|
|
|
1551
|
-
/**
|
|
1552
|
-
* Resolve the wire-level `sourceClientId` for `document`'s events — the client
|
|
1553
|
-
* that authored its current version, from the version-author history on
|
|
1554
|
-
* `HydraniumTextDocuments`. The protocol-level counterpart of the internal
|
|
1555
|
-
* `AstDocumentManager.getAuthor`: a framework-internal rebuild has no author,
|
|
1556
|
-
* so this surfaces the {@link UNKNOWN_CLIENT_ID} presentation default. Adopters
|
|
1557
|
-
* that rebuild through non-text-document channels override to derive a
|
|
1558
|
-
* source id of their own.
|
|
1559
|
-
*/
|
|
1560
|
-
protected resolveSourceClientId(document: LangiumDocument): string {
|
|
1561
|
-
const author = this.services.workspace.TextDocuments.getAuthor(document.textDocument.uri, document.textDocument.version);
|
|
1562
|
-
return author ?? UNKNOWN_CLIENT_ID;
|
|
1563
|
-
}
|
|
1564
|
-
|
|
1565
1544
|
// --- Diagnostics (DataServerDiagnosticsProtocol) ---------------------------
|
|
1566
1545
|
// Computed in THIS process: the data-server child holds the model store, so
|
|
1567
1546
|
// these snapshots reflect the heap that actually carries the workspace
|