@hydranium/protocol 1.0.0-next.380 → 1.0.0-next.382

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.
@@ -0,0 +1,1100 @@
1
+ /********************************************************************************
2
+ * Copyright (c) 2026 EclipseSource and others.
3
+ *
4
+ * This program and the accompanying materials are made available under the
5
+ * terms of the MIT License which is available in the project root.
6
+ *
7
+ * SPDX-License-Identifier: MIT
8
+ ********************************************************************************/
9
+
10
+ import { compare } from 'fast-json-patch';
11
+ import { type Disposable, Emitter, type Event } from 'vscode-jsonrpc';
12
+ import { type Clock, SystemClock, TIMED_OUT } from '../clock';
13
+ import { ConflictError, DocumentNotOpenError, SaveFailedError, STALE_BASE_VERSION_SAVE, STALE_BASE_VERSION_UPDATE } from '../errors';
14
+ import { isModelVersion, type ModelVersion, UNRECORDED_VERSION } from '../model-service/base-version';
15
+ import { type ConflictResolver, type ReconcileOutcome, ReconcilingConflictResolver } from '../patch-merge';
16
+ import { PendingWorkTracker } from '../pending-work-tracker';
17
+ import { type ReconcileWriteOutcome, reconcileWrite, type VersionedModel } from '../reconcile-write';
18
+ import type { TransferDiagnostic } from '../transfer-diagnostic';
19
+ import { type LoadedTransferDocument, type TextState, TransferDocument, type TransferSavedDocument } from '../transfer-document';
20
+ import type { TransferElement } from '../transfer-element';
21
+ import { Deferred } from '../util';
22
+ import type { DataEvents } from './data-events';
23
+ import type { DataSession, DataSessionRestoreEvent } from './data-session';
24
+
25
+ /** What the write that carried an {@link WorkingCopy.edit} did. */
26
+ export type WorkingCopyEditOutcome =
27
+ /** The write landed against an unchanged document. */
28
+ | { readonly status: 'applied' }
29
+ /** The write raced a foreign edit to other paths, and both landed. */
30
+ | { readonly status: 'merged' }
31
+ /**
32
+ * The edit raced a foreign edit of the same path, or kept racing foreign
33
+ * edits, and was dropped, or wrote a path an edit before it lost that way.
34
+ * A server that normalizes the copy's own write, as a name it trims, also
35
+ * yields it for an edit of that path made while the write was out. The
36
+ * copy shows the server's value, unless the read after the last race
37
+ * failed.
38
+ */
39
+ | { readonly status: 'conflict' }
40
+ /** Nothing was written: the server already held the edit, or it changed nothing. */
41
+ | { readonly status: 'unchanged' }
42
+ /**
43
+ * Nothing was written, because the copy was closed, its file deleted, a
44
+ * restore could not open it again or its open's second read failed, before
45
+ * a write carried the edit.
46
+ */
47
+ | { readonly status: 'unavailable' };
48
+
49
+ /**
50
+ * One open of a {@link WorkingCopy}, from the answer to its first read until a
51
+ * close, a deletion or a failure ends it. A request records its answer only
52
+ * while the open it was sent under is still the copy's, so nothing of an
53
+ * ended open reaches the next.
54
+ */
55
+ export interface WorkingCopyOpen<TTransfer extends TransferElement> {
56
+ /** The last document the server sent. */
57
+ snapshot: TransferDocument<TTransfer>;
58
+ /** The copy's restores heard when {@link snapshot} was taken. */
59
+ snapshotRestores: number;
60
+ /**
61
+ * Whether {@link snapshot} was taken while a write was out, so it may hold
62
+ * the edits {@link sending} applies again. A base made on it then is
63
+ * unconfirmed, so its first write is reconciled, not sent.
64
+ */
65
+ snapshotUnderWrite?: boolean;
66
+ /** Edits not yet written, in edit order, each until the write that carries it answers. */
67
+ queued: { readonly change: (root: TTransfer) => TTransfer; readonly outcome: Deferred<WorkingCopyEditOutcome> }[];
68
+ /**
69
+ * The root the first queued edit was made on, the write in flight
70
+ * included, and the snapshot version their write is gated on. A foreign
71
+ * update that lands while they wait then conflicts and is reconciled,
72
+ * instead of being written over, and the copy's own write does not.
73
+ * `restores` is the copy's restores heard when that root was taken. A base
74
+ * from before a restore conflicts on its first write. `unconfirmed` marks
75
+ * a base whose write before a conflict dropped edits from. The root is then
76
+ * the one that write was made on with only the edits it kept, so an edit
77
+ * restating a dropped value conflicts, and its first write conflicts too.
78
+ * It also marks a base made on a {@link snapshotUnderWrite}. `dropped`
79
+ * makes any edit of a path a dropped edit wrote conflict, a revert to the
80
+ * value before it included.
81
+ */
82
+ base?: VersionedModel<TTransfer> & {
83
+ readonly restores: number;
84
+ readonly unconfirmed?: boolean;
85
+ /** The root the user saw before a conflict dropped edits, and the paths those edits wrote. */
86
+ readonly dropped?: { readonly seen: TTransfer; readonly paths: readonly string[] };
87
+ };
88
+ /** Edits of the write in flight, in edit order. */
89
+ sending: WorkingCopyOpen<TTransfer>['queued'];
90
+ /** Whether a write loop runs. */
91
+ writing: boolean;
92
+ /** The server's dirty flag, as last sent. */
93
+ serverDirty: boolean;
94
+ /** Requests out whose answer replaces the snapshot. */
95
+ snapshotRequests: number;
96
+ /**
97
+ * The newest document the server sent while a request is out, held until a
98
+ * request answers with no write out, or for
99
+ * {@link WorkingCopy.settleWritesMs} at most.
100
+ */
101
+ held?: TransferDocument<TTransfer>;
102
+ /** Takes {@link held} once it has waited {@link WorkingCopy.settleWritesMs}. */
103
+ holdTimer?: Disposable;
104
+ /** The text version of the dirty flag last taken, so an answer built earlier does not undo a later save. */
105
+ dirtyVersion?: number;
106
+ /** Whether that flag came as a dirty event, which a document at its version does not undo. */
107
+ dirtyFromEvent: boolean;
108
+ }
109
+
110
+ /** `root()`, or `undefined` when an edit's change throws on the way. */
111
+ function rootOrUndefined<TTransfer>(root: () => TTransfer | undefined): TTransfer | undefined {
112
+ try {
113
+ return root();
114
+ } catch {
115
+ return undefined;
116
+ }
117
+ }
118
+
119
+ /**
120
+ * For each edit of `batch`, whether it writes a path an edit dropped before
121
+ * it wrote, replayed on the root the user saw, so a revert to the value
122
+ * before the dropped edit counts. A restated dropped value conflicts on the
123
+ * unconfirmed base already.
124
+ */
125
+ function writesDropped<TTransfer extends TransferElement>(
126
+ batch: readonly { readonly change: (root: TTransfer) => TTransfer }[],
127
+ dropped: { readonly seen: TTransfer; readonly paths: readonly string[] } | undefined
128
+ ): boolean[] {
129
+ if (!dropped) {
130
+ return batch.map(() => false);
131
+ }
132
+ const overlaps = (path: string): boolean =>
133
+ dropped.paths.some(droppedPath => path === droppedPath || path.startsWith(`${droppedPath}/`) || droppedPath.startsWith(`${path}/`));
134
+ let root: TTransfer | undefined = dropped.seen;
135
+ return batch.map(edit => {
136
+ const before = root;
137
+ root = before === undefined ? undefined : rootOrUndefined(() => edit.change(before));
138
+ return before !== undefined && root !== undefined && compare(before, root).some(operation => overlaps(operation.path));
139
+ });
140
+ }
141
+
142
+ /**
143
+ * `base` with the edits of `batch` its `outcomes` did not drop, or `base`
144
+ * alone when one of those throws on it.
145
+ */
146
+ function keptOf<TTransfer>(
147
+ batch: readonly { readonly change: (root: TTransfer) => TTransfer }[],
148
+ outcomes: readonly (WorkingCopyEditOutcome | Promise<never>)[],
149
+ base: TTransfer
150
+ ): TTransfer {
151
+ return rootOrUndefined(() => batch.reduce((root, edit, index) => (isDropped(outcomes[index]) ? root : edit.change(root)), base)) ?? base;
152
+ }
153
+
154
+ /** Whether a batch's edit did not reach the server: a conflict dropped it, or its write failed. */
155
+ function isDropped(outcome: WorkingCopyEditOutcome | Promise<never>): boolean {
156
+ return !('status' in outcome) || outcome.status === 'conflict';
157
+ }
158
+
159
+ /**
160
+ * The client's working copy of one data-server document: the server's latest
161
+ * snapshot of it, plus the edits the server has not confirmed yet.
162
+ *
163
+ * - **Follows the server.** Another client's update replaces the snapshot and
164
+ * fires {@link onDidChange}. This session's own echoes are skipped while a
165
+ * write is out, since the write's answer carries them. A document the
166
+ * server sends while a write, a save, a reread or the open's second read is
167
+ * out waits. The newest is taken when one of them answers and no write is
168
+ * out, or once the first of them has waited {@link settleWritesMs}. A write
169
+ * still out then has its edits applied to a document that may hold them
170
+ * already, so an edit that does not give the same root twice may show twice
171
+ * until its write answers. A batch made on that document has its first
172
+ * write reconciled, so the edit is not written twice.
173
+ * - **Batches edits behind a write.** An edit goes out at once when no write
174
+ * is out. The edits made while one is out go out after it, as one write of
175
+ * the whole root, gated on the version of the snapshot the first of them
176
+ * was made on, or of the answer to the copy's write before when that landed
177
+ * unchanged. Each edit resolves to a {@link WorkingCopyEditOutcome}, its
178
+ * batch's unless the batch splits on a conflict.
179
+ * - **Reconciles.** A write that meets a newer version goes through
180
+ * `reconcileWrite`: the edits are replayed onto the refetched root, and a
181
+ * same-path collision drops the edit that made it in favour of the
182
+ * server's value. The other edits of its batch are still written.
183
+ * - **Counts its own edits as dirty.** {@link dirty} is the server's flag, or
184
+ * true while an edit is unanswered, so a host never shows clean text with
185
+ * an edit still on its way.
186
+ *
187
+ * Its session's own writes of the document are taken as echoes while a
188
+ * write is out, so the copy is the only writer of the document on its
189
+ * session. One heard with none out is taken, as a late write of a copy
190
+ * before it may be.
191
+ *
192
+ * One URI per copy, for its whole life. Opening another document is a new
193
+ * copy, so no queued edit can land on a document it was not made for. Open a
194
+ * new copy of the same document on the same session only once the last one's
195
+ * {@link close} has settled, since the copy does not check it. The session
196
+ * holds one open per document, so a close sent later would end the new copy's
197
+ * open too.
198
+ *
199
+ * An edit is a function of the root and runs again on every newer snapshot
200
+ * until its write answers, so it must be pure. Running it once and keeping the
201
+ * result would hide a foreign update behind the edit's stale copy of the root.
202
+ * One that throws on a newer root makes {@link root} throw too, until its
203
+ * write answers.
204
+ */
205
+ export class WorkingCopy<TTransfer extends TransferElement> {
206
+ protected readonly changeEmitter = new Emitter<void>();
207
+ /** Fires whenever {@link root} or {@link diagnostics} may have changed. */
208
+ readonly onDidChange: Event<void> = this.changeEmitter.event;
209
+ protected readonly dirtyEmitter = new Emitter<void>();
210
+ /** Fires whenever {@link dirty} changes. Apart from {@link onDidChange}, since a save changes no content. */
211
+ readonly onDidChangeDirty: Event<void> = this.dirtyEmitter.event;
212
+
213
+ /** Asked for each conflicting write, see {@link reconcile}. */
214
+ protected readonly conflictResolver: ConflictResolver = new ReconcilingConflictResolver();
215
+ /**
216
+ * Writes of one reconcile, the first included, before a still-conflicting
217
+ * one is dropped. A first write the copy refuses unsent, on a base from
218
+ * before a restore or one a conflict dropped edits from, counts too. A
219
+ * split batch reconciles twice, so one batch makes at most twice the cap
220
+ * less one writes. Absent, `reconcileWrite`'s default applies.
221
+ */
222
+ protected readonly maxWrites?: number;
223
+ /**
224
+ * How long {@link save}, {@link close} and {@link whenWritesSettled} wait
225
+ * for the copy's edits before going on anyway, and how long a document the
226
+ * server sends is held for a request at most.
227
+ */
228
+ protected readonly settleWritesMs: number = 30_000;
229
+ /** The clock of {@link settleWritesMs}. */
230
+ protected readonly clock: Clock = new SystemClock();
231
+
232
+ /**
233
+ * The open the copy follows. A close, a deletion or a failure ends it, and a
234
+ * request sent under an earlier one neither counts nor answers.
235
+ */
236
+ protected current?: WorkingCopyOpen<TTransfer>;
237
+ /** Restores heard that may have renumbered the versions, see {@link handleDocumentRestored}. */
238
+ protected restores = 0;
239
+ /** Every edit until its write answers, so a settle waits on rereads after a conflict too. */
240
+ protected readonly editsInFlight = new PendingWorkTracker();
241
+ /** {@link dirty} as last announced. */
242
+ protected announcedDirty = false;
243
+ /** Requests being settled, during which {@link onDidChangeDirty} waits, since their answer is not taken yet. */
244
+ protected settling = 0;
245
+ /** The open, under way or done, which a later call shares until a deletion or a failure clears it. */
246
+ protected opening?: Promise<void>;
247
+ /** The close, once called. The copy does not open again after it. */
248
+ protected closing?: Promise<void>;
249
+ /** The listeners on the session and its events, ended once {@link dispose} ran and the edits have answered. */
250
+ protected readonly subscriptions: Disposable[];
251
+ /** Set by {@link dispose}, after which nothing is announced. */
252
+ protected disposed = false;
253
+ /** Set once {@link close} stopped waiting for the open, which then ends when it answers. */
254
+ protected openAbandoned = false;
255
+
256
+ constructor(
257
+ protected readonly session: DataSession<TTransfer>,
258
+ protected readonly events: DataEvents<TTransfer>,
259
+ /** The document followed, as the caller spells it, which the session keys its open by. */
260
+ readonly uri: string
261
+ ) {
262
+ // A deletion carries no document, so it has a channel of its own.
263
+ this.subscriptions = [
264
+ events.onDidUpdateDocument(event => this.handleDocumentUpdated(event)),
265
+ events.onDidDeleteDocument(event => this.handleDocumentDeleted(event)),
266
+ events.onDidChangeDocumentDirty(event => {
267
+ const open = this.current;
268
+ if (open && event.uri === open.snapshot.uri) {
269
+ this.takeDirty(open, event.text ?? { dirty: false }, true);
270
+ }
271
+ }),
272
+ session.onDidRestoreDocument(event => this.handleDocumentRestored(event))
273
+ ];
274
+ }
275
+
276
+ /** The last document the server sent, without the edits it has not confirmed. `undefined` before {@link open}. */
277
+ get snapshot(): TransferDocument<TTransfer> | undefined {
278
+ return this.current?.snapshot;
279
+ }
280
+
281
+ /** The version of {@link snapshot}. A write is gated on the version its batch was made on, see the class doc. */
282
+ get version(): ModelVersion | undefined {
283
+ return this.current?.snapshot.model?.version;
284
+ }
285
+
286
+ /** The server's root with every unconfirmed edit applied. `undefined` when no document is loaded. */
287
+ get root(): TTransfer | undefined {
288
+ const open = this.current;
289
+ const root = open?.snapshot.model?.root;
290
+ return root && [...open.sending, ...open.queued].reduce((edited, edit) => edit.change(edited), root);
291
+ }
292
+
293
+ /**
294
+ * Diagnostics as of the last snapshot. A write answers once its document is
295
+ * validated, so they hold for the copy's own edits without an update push.
296
+ * After a reconnect the ones from before stay until the copy's reread
297
+ * answers, or the next update when it fails.
298
+ */
299
+ get diagnostics(): readonly TransferDiagnostic[] {
300
+ return this.current?.snapshot.model?.diagnostics ?? [];
301
+ }
302
+
303
+ /** Whether the server's text differs from the file, or an edit has not been confirmed. */
304
+ get dirty(): boolean {
305
+ const open = this.current;
306
+ return !!open && (open.serverDirty || open.queued.length > 0 || open.sending.length > 0);
307
+ }
308
+
309
+ /** Whether an edit has not answered yet. Synchronous, for an unload guard. */
310
+ get hasWritesInFlight(): boolean {
311
+ return !this.editsInFlight.isIdle;
312
+ }
313
+
314
+ /**
315
+ * Open and watch the document, then read it again with diagnostics. The
316
+ * open settles before validation, so without the second read a document
317
+ * that was never edited reports no problems. Fires a change once both reads
318
+ * have answered and the open stands, and before that only for a document a
319
+ * restore hands over meanwhile. Repeated calls share the first one, and a
320
+ * failed open may be retried. Rejects once {@link close} was called, and
321
+ * with a `DocumentNotOpenError` when the file is gone by the second read. A
322
+ * failed second read rejects once the session is connected, so after a
323
+ * dropped connection it waits for the reconnect, and resolves when the
324
+ * restore took the document over.
325
+ */
326
+ async open(): Promise<void> {
327
+ this.assertLive();
328
+ if (this.closing) {
329
+ throw new Error('WorkingCopy is closed');
330
+ }
331
+ const opening = (this.opening ??= this.load().catch((error: unknown) => {
332
+ // A deletion may have ended this open and another one begun.
333
+ if (this.opening === opening) {
334
+ this.opening = undefined;
335
+ }
336
+ throw error;
337
+ }));
338
+ return opening;
339
+ }
340
+
341
+ /**
342
+ * Queue `change` for the next write. The edit shows in {@link root} at once.
343
+ * Resolves to the outcome of the write that carried it, and rejects when
344
+ * that write fails for another reason than a conflict, as with a
345
+ * `DocumentSyntaxError` while the document does not parse. Rejects with a
346
+ * `DocumentNotOpenError` when the copy follows no document, and with what
347
+ * `change` throws on {@link root}, queueing nothing. A change that throws
348
+ * only on a later root rejects every edit of the write that carries it.
349
+ */
350
+ async edit(change: (root: TTransfer) => TTransfer): Promise<WorkingCopyEditOutcome> {
351
+ this.assertLive();
352
+ const open = this.requireOpen();
353
+ const loaded = TransferDocument.assertLoaded(open.snapshot);
354
+ // Run once here, so a change that throws fails only its own edit. One
355
+ // before it that throws on this root fails its own write instead.
356
+ const shown = rootOrUndefined(() => this.root);
357
+ if (shown) {
358
+ change(shown);
359
+ }
360
+ const edit: WorkingCopyOpen<TTransfer>['queued'][number] = { change, outcome: new Deferred<WorkingCopyEditOutcome>() };
361
+ open.base ??= {
362
+ // A sent edit that throws on this root is measured as none.
363
+ model: rootOrUndefined(() => open.sending.reduce((root, sent) => sent.change(root), loaded.model.root)) ?? loaded.model.root,
364
+ baseVersion: loaded.model.version,
365
+ restores: open.snapshotRestores,
366
+ unconfirmed: (open.snapshotUnderWrite && open.sending.length > 0) || undefined
367
+ };
368
+ open.queued.push(edit);
369
+ this.editsInFlight.track(edit.outcome.promise).catch(() => undefined);
370
+ this.announceDirty();
371
+ this.changeEmitter.fire(undefined);
372
+ if (!open.writing) {
373
+ void this.writeQueued(open);
374
+ }
375
+ return edit.outcome.promise;
376
+ }
377
+
378
+ /**
379
+ * Wait for every edit to answer, those made during the wait included, and
380
+ * persist the server's text when it differs from the file, whoever wrote
381
+ * it. Resolves to what the persist answered, or `undefined` when nothing
382
+ * needed saving. Rejects with a `DocumentNotOpenError` when the copy
383
+ * follows no document, at the call or once the edits have answered, or
384
+ * follows one opened again meanwhile. Rejects with a `ConflictError` when
385
+ * the text moved past the copy's version or the copy's document is from
386
+ * before the latest restore, and with a `SaveFailedError` when an edit has
387
+ * not answered within {@link settleWritesMs}.
388
+ */
389
+ async save(): Promise<TransferSavedDocument<TTransfer> | undefined> {
390
+ this.assertLive();
391
+ const before = this.requireOpen();
392
+ // An edit made during the wait is written first, or the persist would be
393
+ // gated on the version before it.
394
+ if (!(await this.whenEditsIdle())) {
395
+ throw new SaveFailedError([{ uri: this.uri, reason: 'an edit has not been answered' }]);
396
+ }
397
+ // Gone during the wait, as after a deletion, the save is not reported as
398
+ // done, nor made on a document opened again meanwhile.
399
+ const open = this.requireOpen();
400
+ if (open !== before) {
401
+ throw new DocumentNotOpenError(this.uri, this.session.clientId);
402
+ }
403
+ const model = open.snapshot.model;
404
+ if (!model || !open.serverDirty) {
405
+ return undefined;
406
+ }
407
+ // Gated on the copy's version: text the copy has not shown is not saved
408
+ // on its behalf, and the save rejects with the `ConflictError` instead.
409
+ // A version from before the latest restore names nothing on this server.
410
+ if (open.snapshotRestores !== this.restores) {
411
+ throw new ConflictError(this.uri, model.version, UNRECORDED_VERSION, STALE_BASE_VERSION_SAVE);
412
+ }
413
+ let saved: TransferSavedDocument<TTransfer> | undefined;
414
+ open.snapshotRequests++;
415
+ try {
416
+ saved = await this.session.persistDocument({ uri: this.uri, baseVersion: model.version });
417
+ return saved;
418
+ } finally {
419
+ this.settleRequest(open, saved, true);
420
+ }
421
+ }
422
+
423
+ /**
424
+ * Resolve to `true` once no edit is pending, those made during the wait
425
+ * included, or to `false` once {@link settleWritesMs} has passed first.
426
+ * Never rejects.
427
+ */
428
+ whenWritesSettled(): Promise<boolean> {
429
+ return this.whenEditsIdle();
430
+ }
431
+
432
+ /**
433
+ * Resolve to `true` once `opening` has settled and no edit is pending, those
434
+ * made during the wait included, or to `false` once {@link settleWritesMs}
435
+ * has passed first.
436
+ */
437
+ protected async whenEditsIdle(opening?: Promise<void>): Promise<boolean> {
438
+ const idle = async (): Promise<void> => {
439
+ await opening?.catch(() => undefined);
440
+ while (!this.editsInFlight.isIdle) {
441
+ await this.editsInFlight.whenSettled();
442
+ }
443
+ };
444
+ return (!opening && this.editsInFlight.isIdle) || (await this.clock.raceTimer(idle(), this.settleWritesMs)) !== TIMED_OUT;
445
+ }
446
+
447
+ /**
448
+ * Wait for an open under way and the edits, those made during the wait
449
+ * included, up to {@link settleWritesMs}, and close the document. Final:
450
+ * the copy does not open again, and a later call shares this one.
451
+ */
452
+ close(): Promise<void> {
453
+ return (this.closing ??= this.closeAfter(this.opening));
454
+ }
455
+
456
+ /** The work of {@link close}, after the open it ends and the edits, within one bound. */
457
+ protected async closeAfter(opening: Promise<void> | undefined): Promise<void> {
458
+ let opened = !opening;
459
+ void opening?.then(
460
+ () => (opened = true),
461
+ () => (opened = true)
462
+ );
463
+ // Waited for, so an open still under way is closed and not left behind.
464
+ await this.whenEditsIdle(opening);
465
+ // Not answered within the bound, the open ends when it answers, and the
466
+ // session closes the document again then.
467
+ this.openAbandoned = !opened;
468
+ if (!this.current && opened) {
469
+ return;
470
+ }
471
+ // An edit still queued once the bound has passed is not written to a
472
+ // closed document.
473
+ this.unload().forEach(edit => edit.outcome.resolve({ status: 'unavailable' }));
474
+ await this.session.closeDocument({ uri: this.uri });
475
+ this.changeEmitter.fire(undefined);
476
+ }
477
+
478
+ /**
479
+ * Announce nothing more. Unanswered edits are still written while the
480
+ * session lives, since dropping them would lose what the user typed, and
481
+ * the copy follows the document until they have answered, so their writes
482
+ * still heed a restore or a deletion. A host that ends the session as well
483
+ * waits for {@link whenWritesSettled} first.
484
+ */
485
+ dispose(): void {
486
+ if (this.disposed) {
487
+ return;
488
+ }
489
+ this.disposed = true;
490
+ // Nothing listens any more, so a held document waits for its request.
491
+ this.current?.holdTimer?.dispose();
492
+ this.changeEmitter.dispose();
493
+ this.dirtyEmitter.dispose();
494
+ const unsubscribe = (): void => this.subscriptions.forEach(subscription => subscription.dispose());
495
+ if (this.editsInFlight.isIdle) {
496
+ unsubscribe();
497
+ } else {
498
+ void this.editsInFlight.whenSettled().then(unsubscribe);
499
+ }
500
+ }
501
+
502
+ /** Both reads of {@link open}. */
503
+ protected async load(): Promise<void> {
504
+ const document = await this.session.openDocument({ uri: this.uri });
505
+ if (this.openAbandoned) {
506
+ throw new Error('WorkingCopy is closed');
507
+ }
508
+ const restores = this.restores;
509
+ const open: WorkingCopyOpen<TTransfer> = {
510
+ snapshot: document,
511
+ snapshotRestores: restores,
512
+ queued: [],
513
+ sending: [],
514
+ writing: false,
515
+ serverDirty: false,
516
+ snapshotRequests: 1,
517
+ dirtyFromEvent: false
518
+ };
519
+ this.current = open;
520
+ // Taken now, so a save while the second read is out sees the text dirty.
521
+ this.takeDirty(open, document.text);
522
+ // Heard apart from the restore count, which skips a restore that found the text unchanged.
523
+ let restored = false;
524
+ const listening = this.session.onDidRestoreDocument(event => {
525
+ restored ||= event.uri === this.uri && event.reopened;
526
+ });
527
+ let answer: TransferDocument<TTransfer>;
528
+ try {
529
+ answer = await (await this.session.connected()).getModelDocument({ uri: this.uri, includeDiagnostics: true });
530
+ } catch (error: unknown) {
531
+ this.settleRequest(open, undefined);
532
+ // After a dropped connection, the restore opens the document again
533
+ // and hands it to the copy before the session is connected.
534
+ await this.session.connected().catch(() => undefined);
535
+ if (open !== this.current) {
536
+ throw error;
537
+ }
538
+ // A restore took the document over, so the copy follows it and the open stands.
539
+ if (restored) {
540
+ return;
541
+ }
542
+ // Following nothing, the copy would take edits it cannot show. Undone,
543
+ // so the open can be tried again.
544
+ this.unload().forEach(edit => edit.outcome.resolve({ status: 'unavailable' }));
545
+ await this.session.closeDocument({ uri: this.uri }).catch(() => undefined);
546
+ throw error;
547
+ } finally {
548
+ listening.dispose();
549
+ }
550
+ this.settleRequest(open, answer);
551
+ // The read found the file gone, or a deletion came meanwhile.
552
+ if (open !== this.current) {
553
+ throw new DocumentNotOpenError(this.uri, this.session.clientId);
554
+ }
555
+ this.changeEmitter.fire(undefined);
556
+ }
557
+
558
+ /** Write the batches of `open` until none is left, one at a time, so each can be based on the answer to the last. */
559
+ protected async writeQueued(open: WorkingCopyOpen<TTransfer>): Promise<void> {
560
+ open.writing = true;
561
+ try {
562
+ // The edits made in the same turn go out as one write.
563
+ await Promise.resolve();
564
+ // Once the open ended, the copy opened again writes on its own.
565
+ while (open.queued.length > 0 && open === this.current) {
566
+ const base = open.base;
567
+ open.sending = open.queued;
568
+ open.queued = [];
569
+ open.base = undefined;
570
+ const batch = open.sending;
571
+ await this.writeBatch(open, batch, base).catch((error: unknown) => {
572
+ // `writeBatch` settles its own failures, so only a subclass that
573
+ // throws past them gets here. Settled already, an edit keeps its
574
+ // outcome. The edits queued meanwhile may be based on this
575
+ // batch, so their write is reconciled.
576
+ batch.forEach(edit => edit.outcome.reject(error));
577
+ if (open.base) {
578
+ open.base = { ...open.base, unconfirmed: true };
579
+ }
580
+ });
581
+ }
582
+ } finally {
583
+ open.writing = false;
584
+ open.sending = [];
585
+ this.announceDirty();
586
+ }
587
+ }
588
+
589
+ /**
590
+ * Write one batch, made on `base`, and settle its edits. The batch leaves
591
+ * {@link root} before the server's answer is taken and the one change is
592
+ * fired for it, since a reader would otherwise apply it again over an
593
+ * answer that holds or refused it.
594
+ */
595
+ protected async writeBatch(
596
+ open: WorkingCopyOpen<TTransfer>,
597
+ batch: WorkingCopyOpen<TTransfer>['queued'],
598
+ base: WorkingCopyOpen<TTransfer>['base']
599
+ ): Promise<void> {
600
+ let answer: TransferDocument<TTransfer> | undefined;
601
+ let settle: (edit: WorkingCopyOpen<TTransfer>['queued'][number], index: number) => void = edit =>
602
+ edit.outcome.resolve({ status: 'unavailable' });
603
+ let failed = true;
604
+ let outcomes: (WorkingCopyEditOutcome | Promise<never>)[] = [];
605
+ let seen: TTransfer | undefined;
606
+ open.snapshotRequests++;
607
+ if (base) {
608
+ try {
609
+ // The server keeps the other client's value of a field a dropped
610
+ // edit wrote, so an edit of it conflicts, a revert included.
611
+ const lost = writesDropped(batch, base.dropped);
612
+ const written = batch.filter((_, index) => !lost[index]);
613
+ const roots = written.reduce((made, edit) => [...made, edit.change(made[made.length - 1])], [base.model]);
614
+ seen = rootOrUndefined(() => batch.reduce((root, edit) => edit.change(root), base.model));
615
+ let writtenOutcomes: (WorkingCopyEditOutcome | Promise<never>)[] = written.map(() => ({ status: 'unchanged' }));
616
+ // Written anyway, a batch that changes nothing would make the text dirty.
617
+ if (compare(base.model, roots[written.length]).length > 0) {
618
+ const reconciled = await this.reconcile(open, base, roots[written.length], true);
619
+ answer = reconciled.answer;
620
+ writtenOutcomes = writtenOutcomes.map(() => this.toEditOutcome(reconciled.outcome));
621
+ if (reconciled.outcome.status === 'conflict' && written.length > 1 && answer && TransferDocument.isLoaded(answer)) {
622
+ ({ answer, outcomes: writtenOutcomes } = await this.splitConflict(open, roots, answer, reconciled.restores));
623
+ }
624
+ }
625
+ let next = 0;
626
+ outcomes = batch.map((_, index) => (lost[index] ? { status: 'conflict' } : writtenOutcomes[next++]));
627
+ settle = (edit, index) => edit.outcome.resolve(outcomes[index]);
628
+ failed = false;
629
+ } catch (error: unknown) {
630
+ settle = edit => edit.outcome.reject(error);
631
+ }
632
+ }
633
+ open.sending = [];
634
+ if (open.base) {
635
+ // The edits queued meanwhile were made over this batch, which the
636
+ // server may not hold. Gated on a version it still has, their write
637
+ // would pass and carry it.
638
+ if (failed) {
639
+ open.base = open.snapshot.model && {
640
+ model: open.snapshot.model.root,
641
+ baseVersion: open.snapshot.model.version,
642
+ restores: open.snapshotRestores
643
+ };
644
+ } else if (base && outcomes.some(isDropped)) {
645
+ // Dropped in part over a foreign edit, so reconciled against it,
646
+ // and measured with only the edits that were not dropped, so an
647
+ // edit restating a dropped value conflicts too and one over a
648
+ // merged value does not.
649
+ const kept = keptOf(batch, outcomes, base.model);
650
+ const dropped = seen && { seen, paths: compare(kept, seen).map(operation => operation.path) };
651
+ open.base = { ...open.base, model: kept, unconfirmed: true, dropped };
652
+ }
653
+ }
654
+ this.settleRequest(open, answer);
655
+ this.changeEmitter.fire(undefined);
656
+ batch.forEach(settle);
657
+ }
658
+
659
+ /**
660
+ * Replay a conflicting batch onto `theirs` one edit at a time, each as the
661
+ * change from the root before it, and write the edits that do not collide.
662
+ * One edit's collision then drops only that edit.
663
+ */
664
+ protected async splitConflict(
665
+ open: WorkingCopyOpen<TTransfer>,
666
+ roots: readonly TTransfer[],
667
+ theirs: LoadedTransferDocument<TTransfer>,
668
+ restores: number
669
+ ): Promise<{ answer?: TransferDocument<TTransfer>; outcomes: (WorkingCopyEditOutcome | Promise<never>)[] }> {
670
+ let merged = theirs.model.root;
671
+ const replays: ReconcileOutcome<TTransfer>['status'][] = [];
672
+ for (let i = 1; i < roots.length; i++) {
673
+ const current = merged;
674
+ const replayed = await this.conflictResolver.resolve(roots[i - 1], roots[i], async () => current);
675
+ replays.push(replayed.status);
676
+ if (replayed.status === 'merged') {
677
+ merged = replayed.merged;
678
+ }
679
+ }
680
+ let written: WorkingCopyEditOutcome | Promise<never> = { status: 'unchanged' };
681
+ let answer: TransferDocument<TTransfer> | undefined = theirs;
682
+ if (replays.includes('merged')) {
683
+ try {
684
+ const reconciled = await this.reconcile(
685
+ open,
686
+ { model: theirs.model.root, baseVersion: theirs.model.version, restores },
687
+ merged,
688
+ false
689
+ );
690
+ answer = reconciled.answer ?? theirs;
691
+ const outcome = this.toEditOutcome(reconciled.outcome);
692
+ // Written after a conflict, so the edits raced a foreign one.
693
+ written = outcome.status === 'applied' ? { status: 'merged' } : outcome;
694
+ } catch (error: unknown) {
695
+ // Only the edits it carried fail, the ones that collided still conflict.
696
+ written = Promise.reject(error);
697
+ written.catch(() => undefined);
698
+ }
699
+ }
700
+ return {
701
+ answer,
702
+ outcomes: replays.map(status =>
703
+ status === 'merged'
704
+ ? written
705
+ : status === 'already-applied' || status === 'no-op'
706
+ ? { status: 'unchanged' }
707
+ : { status: 'conflict' }
708
+ )
709
+ };
710
+ }
711
+
712
+ /**
713
+ * Write `ours`, based on `base`, and reconcile a conflict. Resolves to the
714
+ * last document the write and its rereads answered, which {@link writeBatch}
715
+ * takes, so the copy shows the server's value for an edit the reconcile
716
+ * dropped. `restores` is {@link restores} when its last reread was taken.
717
+ *
718
+ * A base from before a restore is not sent but refused as a conflict, so
719
+ * the write is reconciled against the restarted server's document. Its
720
+ * version could match that server's by chance and the write would land.
721
+ * An unconfirmed base is refused the same way. Only a batch's own write,
722
+ * `whole`, can move the base of the edits queued meanwhile.
723
+ */
724
+ protected async reconcile(
725
+ open: WorkingCopyOpen<TTransfer>,
726
+ base: NonNullable<WorkingCopyOpen<TTransfer>['base']>,
727
+ ours: TTransfer,
728
+ whole: boolean
729
+ ): Promise<{
730
+ readonly outcome: ReconcileWriteOutcome;
731
+ readonly answer?: TransferDocument<TTransfer>;
732
+ readonly restores: number;
733
+ }> {
734
+ let written: TransferDocument<TTransfer> | undefined;
735
+ let theirs: TransferDocument<TTransfer> | undefined;
736
+ let first = true;
737
+ let went = false;
738
+ // The restores heard when the version written next was read.
739
+ let restores = base.restores;
740
+ const outcome = await reconcileWrite(ours, base.baseVersion, {
741
+ persist: async (model, baseVersion) => {
742
+ const onBase = first;
743
+ first = false;
744
+ // Refused, not sent, on an unconfirmed base, after a restore or
745
+ // once the document went. The reread after a document went
746
+ // reports the edit unavailable.
747
+ const unconfirmed = onBase && base.unconfirmed;
748
+ if (open !== this.current || unconfirmed || (restores !== this.restores && isModelVersion(baseVersion))) {
749
+ went = open !== this.current;
750
+ const refused = isModelVersion(baseVersion) ? baseVersion : base.baseVersion;
751
+ throw new ConflictError(this.uri, refused, UNRECORDED_VERSION, STALE_BASE_VERSION_UPDATE);
752
+ }
753
+ const answer = await this.session.updateDocument({ uri: this.uri, model, baseVersion });
754
+ written = answer;
755
+ // Landed unchanged, the answer is what the edits queued meanwhile
756
+ // were made on, so their write needs no reconcile of its own. One
757
+ // that differs may hold a later write too, whose fields they would
758
+ // overwrite, and its version does not tell, since a write that
759
+ // changes no text makes none.
760
+ const queued = open.base;
761
+ const landed = TransferDocument.isLoaded(answer) && compare(answer.model.root, model).length === 0;
762
+ if (whole && onBase && queued?.baseVersion === base.baseVersion && landed) {
763
+ open.base = { model: answer.model.root, baseVersion: answer.model.version, restores: this.restores };
764
+ }
765
+ },
766
+ refetch: async () => {
767
+ // Checked before the read too, since a session ended with the open rejects it.
768
+ if (open !== this.current) {
769
+ return undefined;
770
+ }
771
+ // A syncing read, so its root and version come from the same text.
772
+ const read = await (await this.session.connected()).getModelDocument({ uri: this.uri, includeDiagnostics: true });
773
+ // The document went meanwhile, so the edit is not written to one opened again.
774
+ if (open !== this.current) {
775
+ return undefined;
776
+ }
777
+ theirs = read;
778
+ restores = this.restores;
779
+ // Gone, the edit is not written, so a deleted file is not written back.
780
+ return theirs.model && { model: theirs.model.root, baseVersion: theirs.model.version };
781
+ },
782
+ base: base.model,
783
+ conflictResolver: this.conflictResolver,
784
+ maxWrites: this.maxWrites
785
+ });
786
+ // A resolver that refetches nothing would retry a write refused this way until it ran out.
787
+ if (went && outcome.status !== 'persisted') {
788
+ return { outcome: { ...outcome, status: 'unavailable' }, answer: theirs, restores };
789
+ }
790
+ if (outcome.status === 'dropped') {
791
+ // The last refetch is older than the write that conflicted after it.
792
+ // A failed reread leaves the snapshot as it is, the edits still lost.
793
+ const reread = await this.session
794
+ .connected()
795
+ .then(server => server.getModelDocument({ uri: this.uri, includeDiagnostics: true }))
796
+ .catch(() => undefined);
797
+ return { outcome, answer: reread, restores };
798
+ }
799
+ if (outcome.status === 'conflict' || outcome.status === 'already-applied' || outcome.status === 'unavailable') {
800
+ return { outcome, answer: theirs, restores };
801
+ }
802
+ return { outcome, answer: written, restores };
803
+ }
804
+
805
+ /** The outcome an edit reports for what {@link reconcile} did. */
806
+ protected toEditOutcome(outcome: ReconcileWriteOutcome): WorkingCopyEditOutcome {
807
+ switch (outcome.status) {
808
+ case 'persisted':
809
+ return { status: 'applied' };
810
+ case 'merged':
811
+ return { status: 'merged' };
812
+ case 'conflict':
813
+ case 'dropped':
814
+ return { status: 'conflict' };
815
+ case 'already-applied':
816
+ case 'unchanged':
817
+ return { status: 'unchanged' };
818
+ case 'unavailable':
819
+ return { status: 'unavailable' };
820
+ }
821
+ }
822
+
823
+ /** Drop the snapshot when the document's file is removed, so no edit is based on a document the server no longer has. */
824
+ protected handleDocumentDeleted(event: { uri: string }): void {
825
+ if (event.uri !== this.current?.snapshot.uri) {
826
+ return;
827
+ }
828
+ this.dropDocument();
829
+ }
830
+
831
+ /** End the open as a deletion does, and settle the edits not yet written as `unavailable`. */
832
+ protected dropDocument(): void {
833
+ const unwritten = this.unload();
834
+ // So `open` loads the document again.
835
+ this.opening = undefined;
836
+ this.changeEmitter.fire(undefined);
837
+ unwritten.forEach(edit => edit.outcome.resolve({ status: 'unavailable' }));
838
+ }
839
+
840
+ /**
841
+ * Drop the document and every edit not yet written, and return those edits
842
+ * for the caller to settle. Made on the document that went, they never
843
+ * reach one opened again. Requests still out answer to nothing, since a
844
+ * late answer would bring the document back.
845
+ */
846
+ protected unload(): WorkingCopyOpen<TTransfer>['queued'] {
847
+ const unwritten = this.current?.queued ?? [];
848
+ if (this.current) {
849
+ this.unhold(this.current);
850
+ }
851
+ this.current = undefined;
852
+ this.announceDirty();
853
+ return unwritten;
854
+ }
855
+
856
+ /**
857
+ * Follow the document again after a reconnect. A restarted server numbers
858
+ * versions afresh, so the versions held from before compare with nothing,
859
+ * and a change made while the connection was down sent no event. Queued
860
+ * edits keep their base, so their write is reconciled against what the
861
+ * restarted server holds and a field changed meanwhile conflicts. A restore
862
+ * that finds the snapshot's text at its version is not counted, as in the
863
+ * session, so a brief drop costs no conflict.
864
+ */
865
+ protected handleDocumentRestored(event: DataSessionRestoreEvent<TTransfer>): void {
866
+ const open = this.current;
867
+ if (event.uri !== this.uri || !open) {
868
+ return;
869
+ }
870
+ // The session no longer has it open, so neither does the copy.
871
+ if (!event.reopened) {
872
+ this.restores++;
873
+ this.dropDocument();
874
+ return;
875
+ }
876
+ // The text the copy last saw, at its version, is still named by it.
877
+ const text = event.document?.text;
878
+ const known = open.snapshot.text;
879
+ if (
880
+ !text ||
881
+ text.version !== known?.version ||
882
+ text.hash !== known.hash ||
883
+ event.document?.model?.version !== open.snapshot.model?.version
884
+ ) {
885
+ this.restores++;
886
+ }
887
+ this.unhold(open);
888
+ open.dirtyVersion = undefined;
889
+ open.dirtyFromEvent = false;
890
+ // The restore reads without diagnostics, so the ones held stay until
891
+ // the reread answers.
892
+ const kept = open.snapshot.model?.diagnostics;
893
+ const restored =
894
+ event.document?.model && event.document.model.diagnostics === undefined && kept
895
+ ? { ...event.document, model: { ...event.document.model, diagnostics: kept } }
896
+ : event.document;
897
+ if (restored && TransferDocument.isLoaded(restored)) {
898
+ this.takeDirty(open, restored.text);
899
+ if (open.sending.length > 0) {
900
+ this.hold(open, restored);
901
+ } else {
902
+ this.adopt(open, restored);
903
+ }
904
+ }
905
+ // A listener of the adoption may have ended the open.
906
+ if (open === this.current) {
907
+ void this.reread(open);
908
+ }
909
+ }
910
+
911
+ /** Read the document of `open` again with diagnostics, and take the answer. */
912
+ protected async reread(open: WorkingCopyOpen<TTransfer>): Promise<void> {
913
+ let answer: TransferDocument<TTransfer> | undefined;
914
+ open.snapshotRequests++;
915
+ try {
916
+ answer = await (await this.session.connected()).getModelDocument({ uri: this.uri, includeDiagnostics: true });
917
+ } catch {
918
+ // The snapshot the restore read stays.
919
+ } finally {
920
+ this.settleRequest(open, answer, true);
921
+ }
922
+ }
923
+
924
+ /** Follow a server push for the document. */
925
+ protected handleDocumentUpdated(event: { document: TransferDocument<TTransfer>; sourceClientId: string }): void {
926
+ const open = this.current;
927
+ if (!open || event.document.uri !== open.snapshot.uri) {
928
+ return;
929
+ }
930
+ // While a write is out, its answer carries it. With none out it may be
931
+ // a late write of a copy before this one, which no answer carries, and
932
+ // `offer` skips an echo older than the snapshot.
933
+ if (open.sending.length > 0 && this.session.isOwnEcho(event.sourceClientId)) {
934
+ return;
935
+ }
936
+ this.offer(open, event.document, true);
937
+ }
938
+
939
+ /**
940
+ * Take the dirty flag of a document the server sent, and the document itself
941
+ * unless a request is out. A document older than the snapshot is skipped.
942
+ */
943
+ protected offer(open: WorkingCopyOpen<TTransfer>, document: TransferDocument<TTransfer>, announce: boolean): void {
944
+ // Versions rise per server, so an older one was built before the
945
+ // snapshot. Held, it would be taken when a request answers.
946
+ const offered = document.model?.version;
947
+ const current = open.snapshot.model?.version;
948
+ if (offered !== undefined && current !== undefined && offered < current && open.snapshotRestores === this.restores) {
949
+ return;
950
+ }
951
+ this.takeDirty(open, document.text);
952
+ if (open.snapshotRequests > 0) {
953
+ const held = open.held?.model?.version;
954
+ if (held === undefined || offered === undefined || offered >= held) {
955
+ this.hold(open, document);
956
+ }
957
+ return;
958
+ }
959
+ this.adopt(open, document, announce);
960
+ }
961
+
962
+ /**
963
+ * Hold `document` until a request answers. A document first held
964
+ * {@link settleWritesMs} ago is taken then, so a request that hangs does
965
+ * not hide other clients' changes for longer.
966
+ */
967
+ protected hold(open: WorkingCopyOpen<TTransfer>, document: TransferDocument<TTransfer>): void {
968
+ open.held = document;
969
+ if (this.disposed) {
970
+ return;
971
+ }
972
+ open.holdTimer ??= this.clock.setTimer(() => {
973
+ const held = this.unhold(open);
974
+ if (open === this.current && held) {
975
+ this.adopt(open, held);
976
+ }
977
+ }, this.settleWritesMs);
978
+ }
979
+
980
+ /** Stop holding a document for `open`, and return the one it held. */
981
+ protected unhold(open: WorkingCopyOpen<TTransfer>): TransferDocument<TTransfer> | undefined {
982
+ const held = open.held;
983
+ open.held = undefined;
984
+ open.holdTimer?.dispose();
985
+ open.holdTimer = undefined;
986
+ return held;
987
+ }
988
+
989
+ /**
990
+ * Take `document` as the new snapshot. Fires a change unless the model's
991
+ * hash is the one held, which also means equally validated, or `announce`
992
+ * is false, as for a write's answers, which {@link writeBatch} announces
993
+ * once the write's edits are off {@link root}.
994
+ */
995
+ protected adopt(open: WorkingCopyOpen<TTransfer>, document: TransferDocument<TTransfer>, announce = true): void {
996
+ const held = open.snapshot.model?.hash;
997
+ open.snapshot = document;
998
+ open.snapshotRestores = this.restores;
999
+ open.snapshotUnderWrite = open.sending.length > 0;
1000
+ if (announce && (held === undefined || held !== document.model?.hash)) {
1001
+ this.changeEmitter.fire(undefined);
1002
+ }
1003
+ }
1004
+
1005
+ /**
1006
+ * End a request, and take the newest document the server sent while
1007
+ * requests were out, its answer included. None is taken while a write is
1008
+ * out, since it may already hold that write's edits, which {@link root}
1009
+ * would then apply again. Versions are compared only within that window,
1010
+ * and a restore clears what it held, since a restarted server numbers
1011
+ * afresh. {@link dirty} is announced once the answer is taken.
1012
+ */
1013
+ protected settleRequest(open: WorkingCopyOpen<TTransfer>, answer: TransferDocument<TTransfer> | undefined, announce = false): void {
1014
+ if (open !== this.current) {
1015
+ return;
1016
+ }
1017
+ this.settling++;
1018
+ try {
1019
+ this.settleAnswer(open, answer, announce);
1020
+ } finally {
1021
+ this.settling--;
1022
+ }
1023
+ this.announceDirty();
1024
+ }
1025
+
1026
+ /** {@link settleRequest} past its check that `open` is followed, without announcing {@link dirty}. */
1027
+ protected settleAnswer(open: WorkingCopyOpen<TTransfer>, answer: TransferDocument<TTransfer> | undefined, announce: boolean): void {
1028
+ // A read found the file gone, and a deletion event need not follow, so
1029
+ // the session's open is closed too. An open called later follows it.
1030
+ if (answer && !TransferDocument.isLoaded(answer)) {
1031
+ // Closed first, so an open a listener of the drop calls is not closed.
1032
+ void this.session.closeDocument({ uri: this.uri }).catch(() => undefined);
1033
+ this.dropDocument();
1034
+ return;
1035
+ }
1036
+ if (answer) {
1037
+ this.offer(open, answer, announce);
1038
+ }
1039
+ open.snapshotRequests--;
1040
+ // Taken with other requests still out, it is held no longer, so its
1041
+ // timer does not take it again over a write sent meanwhile.
1042
+ if (open.snapshotRequests === 0 || open.sending.length === 0) {
1043
+ const held = this.unhold(open);
1044
+ if (held && open.sending.length === 0) {
1045
+ this.adopt(open, held, announce);
1046
+ }
1047
+ }
1048
+ }
1049
+
1050
+ /**
1051
+ * Take a dirty flag the server sent, `announced` when it came as a dirty
1052
+ * event. While a request is out, one decided on older text than the last
1053
+ * is skipped, and so is a document's at the version of the last event,
1054
+ * since a save changes the flag but not the version, and that document may
1055
+ * have been built before it. Outside that window any is taken, as after a
1056
+ * restart that numbers versions afresh.
1057
+ */
1058
+ protected takeDirty(
1059
+ open: WorkingCopyOpen<TTransfer>,
1060
+ text: (Pick<TextState, 'dirty'> & Partial<Pick<TextState, 'version'>>) | undefined,
1061
+ announced = false
1062
+ ): void {
1063
+ if (!text) {
1064
+ return;
1065
+ }
1066
+ if (open.snapshotRequests > 0 && open.dirtyVersion !== undefined && text.version !== undefined) {
1067
+ if (text.version < open.dirtyVersion || (text.version === open.dirtyVersion && open.dirtyFromEvent && !announced)) {
1068
+ return;
1069
+ }
1070
+ }
1071
+ open.dirtyVersion = text.version ?? open.dirtyVersion;
1072
+ open.dirtyFromEvent = announced;
1073
+ open.serverDirty = text.dirty;
1074
+ this.announceDirty();
1075
+ }
1076
+
1077
+ /** Fire {@link onDidChangeDirty} when {@link dirty} differs from what was last announced, unless a request is being settled. */
1078
+ protected announceDirty(): void {
1079
+ const dirty = this.dirty;
1080
+ if (this.settling === 0 && dirty !== this.announcedDirty) {
1081
+ this.announcedDirty = dirty;
1082
+ this.dirtyEmitter.fire(undefined);
1083
+ }
1084
+ }
1085
+
1086
+ /** The open the copy follows, or a throw when it follows none. */
1087
+ protected requireOpen(): WorkingCopyOpen<TTransfer> {
1088
+ if (!this.current) {
1089
+ throw new DocumentNotOpenError(this.uri, this.session.clientId);
1090
+ }
1091
+ return this.current;
1092
+ }
1093
+
1094
+ /** Throw once {@link dispose} ran. */
1095
+ protected assertLive(): void {
1096
+ if (this.disposed) {
1097
+ throw new Error('WorkingCopy is disposed');
1098
+ }
1099
+ }
1100
+ }