@hydranium/core 1.0.0-next.269 → 1.0.0-next.270

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 (31) hide show
  1. package/lib/documents/dirty-state-tracker.d.ts +7 -0
  2. package/lib/documents/dirty-state-tracker.d.ts.map +1 -1
  3. package/lib/documents/dirty-state-tracker.js +3 -0
  4. package/lib/documents/dirty-state-tracker.js.map +1 -1
  5. package/lib/documents/document-release-handler.d.ts +43 -18
  6. package/lib/documents/document-release-handler.d.ts.map +1 -1
  7. package/lib/documents/document-release-handler.js +74 -35
  8. package/lib/documents/document-release-handler.js.map +1 -1
  9. package/lib/documents/hydranium-text-documents.d.ts +3 -1
  10. package/lib/documents/hydranium-text-documents.d.ts.map +1 -1
  11. package/lib/documents/hydranium-text-documents.js +22 -8
  12. package/lib/documents/hydranium-text-documents.js.map +1 -1
  13. package/lib/langium/workspace/file-system-provider.d.ts +16 -4
  14. package/lib/langium/workspace/file-system-provider.d.ts.map +1 -1
  15. package/lib/langium/workspace/file-system-provider.js +8 -4
  16. package/lib/langium/workspace/file-system-provider.js.map +1 -1
  17. package/lib/node/node-file-system-provider.d.ts +9 -2
  18. package/lib/node/node-file-system-provider.d.ts.map +1 -1
  19. package/lib/node/node-file-system-provider.js +29 -3
  20. package/lib/node/node-file-system-provider.js.map +1 -1
  21. package/lib/node/rename-over-open-readers.d.ts +7 -0
  22. package/lib/node/rename-over-open-readers.d.ts.map +1 -1
  23. package/lib/node/rename-over-open-readers.js +12 -2
  24. package/lib/node/rename-over-open-readers.js.map +1 -1
  25. package/package.json +5 -5
  26. package/src/documents/dirty-state-tracker.ts +10 -0
  27. package/src/documents/document-release-handler.ts +81 -36
  28. package/src/documents/hydranium-text-documents.ts +21 -9
  29. package/src/langium/workspace/file-system-provider.ts +20 -4
  30. package/src/node/node-file-system-provider.ts +30 -3
  31. package/src/node/rename-over-open-readers.ts +13 -2
@@ -22,6 +22,13 @@ import { isConnectionGoneError } from '../util/connection-liveness.js';
22
22
  */
23
23
  export interface ReleasedDocument {
24
24
  readonly uri: CanonicalUri;
25
+ /**
26
+ * Whether the store last knew a file behind the document: `false` for one
27
+ * created from a session's text and never saved, or whose file a reload
28
+ * found gone; any other open counts as on disk. Unlike the queries, a
29
+ * snapshot taken at the release, since the store forgets it there.
30
+ */
31
+ readonly wasOnDisk: boolean;
25
32
  /** Whether `uri` names this document, under the store's own keying. */
26
33
  isFor(uri: string): boolean;
27
34
  /**
@@ -102,20 +109,22 @@ export type DocumentReleaseOutcome =
102
109
  /**
103
110
  * Rebuilds a released document from the file system provider, so the build
104
111
  * stops carrying the unsaved text of its last client. Override
105
- * `decideRelease` or `applyReleaseDecision` to change the policy and keep the
106
- * locking and the wait for the build.
112
+ * `decideRelease`, `redecideRelease` or `applyReleaseDecision` to change the
113
+ * policy and keep the locking and the wait for the build.
107
114
  *
108
- * The provider decides, for every scheme, by `exists`: a document it can
109
- * serve is rebuilt from its text, and any other — an editor's `untitled:`
110
- * buffer, a file never saved, or one deleted meanwhile — is removed
111
- * from the workspace. A `virtual:` document survives, since the framework's
112
- * provider for that scheme serves it from the index, whatever provider the
113
- * host passes as `context.fileSystemProvider`; an edited one therefore keeps
114
- * its last client's text, and keeping it read-only is the client's job.
115
+ * The provider decides, for every scheme, by `exists`, with a miss confirmed
116
+ * by `existsSettled`: a document it can serve is rebuilt from its text, and
117
+ * any other — an editor's `untitled:` buffer, a file never saved, or one
118
+ * deleted meanwhile — is removed from the workspace. A `virtual:` document
119
+ * survives, since the framework's provider for that scheme serves it from the
120
+ * index, whatever provider the host passes as `context.fileSystemProvider`;
121
+ * an edited one therefore keeps its last client's text, and keeping it
122
+ * read-only is the client's job.
115
123
  *
116
124
  * The answer is read in the document's disk queue, so the rebuild follows
117
125
  * any save still queued rather than reverting past it; a file that goes
118
- * after that read is removed when its rebuild finds none.
126
+ * after that read is removed when its rebuild finds none and the provider
127
+ * confirms it gone.
119
128
  *
120
129
  * Whether the document is still released is checked again inside the write
121
130
  * lock, as its holder: checked only before waiting for the lock, a client that
@@ -181,9 +190,13 @@ export class DefaultDocumentReleaseHandler implements DocumentReleaseHandler {
181
190
 
182
191
  /**
183
192
  * Decide, then carry the decision out under the write lock unless a client
184
- * holds the document again. Answers how that went; a parse of the document
185
- * seen meanwhile counts even when the build was cancelled after it, since
186
- * the build it yields to does not parse again.
193
+ * holds the document again. A rebuild that finds no file is redecided by
194
+ * {@link redecideRelease} within the same lock section: the build has
195
+ * already reset the document, so a build let in meanwhile would read the
196
+ * missing file and fail. Its answer is carried out unless a client
197
+ * reclaimed the document meanwhile. Answers how that went; a parse of the
198
+ * document seen meanwhile counts even when the build was cancelled after
199
+ * it, since the build it yields to does not parse again.
187
200
  */
188
201
  protected async runRelease(released: ReleasedDocument, target: URI): Promise<DocumentReleaseOutcome> {
189
202
  const workspace = this.services.workspace;
@@ -211,7 +224,26 @@ export class DefaultDocumentReleaseHandler implements DocumentReleaseHandler {
211
224
  parsed ||= released.isFor(document.uri.toString());
212
225
  });
213
226
  progress = 'building';
214
- await this.applyReleaseDecision(target, decided, token);
227
+ let applied = decided;
228
+ for (let misses = 1; ; misses++) {
229
+ try {
230
+ await this.applyReleaseDecision(target, applied, token);
231
+ break;
232
+ } catch (err: unknown) {
233
+ if (applied !== 'rebuild' || !isFileNotFound(err, target)) {
234
+ throw err;
235
+ }
236
+ applied = await this.redecideRelease(released, target, misses);
237
+ if (released.isReclaimed()) {
238
+ progress = 'reclaimed';
239
+ return;
240
+ }
241
+ decision = applied;
242
+ this.tracer
243
+ .with(released.uri)
244
+ .debug(`Revert found no file; ${applied === 'rebuild' ? 'rebuilding again' : 'removing the document instead'}`);
245
+ }
246
+ }
215
247
  progress = 'built';
216
248
  });
217
249
  // A cancelled build throws inside the write, and the lock resolves.
@@ -219,12 +251,12 @@ export class DefaultDocumentReleaseHandler implements DocumentReleaseHandler {
219
251
  return { kind: 'reclaimed' };
220
252
  }
221
253
  if (progress === 'queued') {
222
- return { kind: 'discarded', decision: decided };
254
+ return { kind: 'discarded', decision };
223
255
  }
224
256
  if (progress === 'building') {
225
- return { kind: 'cancelled', decision: decided, parsed };
257
+ return { kind: 'cancelled', decision, parsed };
226
258
  }
227
- return { kind: 'completed', decision: decided, parsed };
259
+ return { kind: 'completed', decision, parsed };
228
260
  } catch (error: unknown) {
229
261
  // A revert that finishes after the LSP peer went away fails its
230
262
  // diagnostics publish, and one that runs after its workspace was torn
@@ -254,34 +286,47 @@ export class DefaultDocumentReleaseHandler implements DocumentReleaseHandler {
254
286
  /**
255
287
  * The policy: rebuild a document the file system provider can serve, and
256
288
  * remove any other. Read in the document's disk queue, so it follows any
257
- * save still queued rather than deciding past it.
289
+ * save still queued rather than deciding past it. A miss is settled outside
290
+ * the queue, so waiting it out holds up no write to the file, and only for
291
+ * a document the store knew on disk: one with no file has none to replace.
258
292
  */
259
- protected decideRelease(released: ReleasedDocument, target: URI): Promise<DocumentReleaseDecision> {
293
+ protected async decideRelease(released: ReleasedDocument, target: URI): Promise<DocumentReleaseDecision> {
260
294
  const workspace = this.services.workspace;
261
- return workspace.FileSystemTaskQueue.enqueue(released.uri, async () =>
262
- (await workspace.FileSystemProvider.exists(target)) ? 'rebuild' : 'remove'
263
- );
295
+ const fileSystem = workspace.FileSystemProvider;
296
+ const served = await workspace.FileSystemTaskQueue.enqueue(released.uri, () => fileSystem.exists(target));
297
+ if (served || !released.wasOnDisk) {
298
+ return served ? 'rebuild' : 'remove';
299
+ }
300
+ return ((await fileSystem.existsSettled?.(target)) ?? false) ? 'rebuild' : 'remove';
264
301
  }
265
302
 
266
303
  /**
267
- * Carry `decision` out under the write lock; a file gone since the decision
268
- * is removed instead. An override must rebuild or remove the document:
269
- * release settlement waits for its parse or removal, and one that does
270
- * neither is followed by a recovery build that rebuilds it.
304
+ * The policy for a rebuild that found no file, the `misses`-th of this
305
+ * release: what to carry out instead. Runs inside the release's write, so
306
+ * every build waits on it. The default settles the first miss through the
307
+ * provider's `existsSettled`, rebuilding again when the file is back, and
308
+ * removes the document on any later one. An override answering `rebuild`
309
+ * on every miss rebuilds for as long as the file stays missing.
310
+ */
311
+ protected async redecideRelease(_released: ReleasedDocument, target: URI, misses: number): Promise<DocumentReleaseDecision> {
312
+ if (misses > 1) {
313
+ return 'remove';
314
+ }
315
+ const fileSystem = this.services.workspace.FileSystemProvider;
316
+ return (await (fileSystem.existsSettled?.(target) ?? fileSystem.exists(target))) ? 'rebuild' : 'remove';
317
+ }
318
+
319
+ /**
320
+ * Carry `decision` out under the write lock. A rebuild that finds no file
321
+ * rejects with that error, and {@link redecideRelease} answers what to
322
+ * carry out instead. An override must rebuild or remove the document: release settlement
323
+ * waits for its parse or removal, and one that does neither is followed by a
324
+ * recovery build that rebuilds it.
271
325
  */
272
326
  protected async applyReleaseDecision(target: URI, decision: DocumentReleaseDecision, token: CancellationToken): Promise<void> {
273
327
  const builder = this.services.workspace.DocumentBuilder;
274
328
  builder.markNextReason(HYDRANIUM_BUILD_REASONS.didRelease);
275
- try {
276
- await (decision === 'rebuild' ? builder.update([target], [], token) : builder.update([], [target], token));
277
- } catch (err: unknown) {
278
- if (decision !== 'rebuild' || !isFileNotFound(err, target)) {
279
- throw err;
280
- }
281
- this.tracer.with(target.toString()).debug('Revert found no file; removing the document instead');
282
- builder.markNextReason(HYDRANIUM_BUILD_REASONS.didRelease);
283
- await builder.update([], [target], token);
284
- }
329
+ await (decision === 'rebuild' ? builder.update([target], [], token) : builder.update([], [target], token));
285
330
  }
286
331
 
287
332
  /** The build in place of a release that stopped short; rejects with its error when it was given up. */
@@ -600,6 +600,8 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
600
600
  // reopening client's declared id.
601
601
  this.textLedger.record(uri, syncedDocument);
602
602
  this.textLedger.clearAuthors(uri);
603
+ // Before the tracker's release, which forgets whether a file was behind it.
604
+ const released = this.toReleasedDocument(uri);
603
605
  const cleanAnnouncement = this.dirtyStateTracker.release(uri);
604
606
  this.__syncedDocuments.delete(uri);
605
607
  this.__openedVersions.delete(uri);
@@ -608,7 +610,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
608
610
  this.__pendingContent.delete(uri);
609
611
  this.documentReleaseScheduler.clearLosses(uri);
610
612
  this.documentReleasedEmitter.fire(Object.freeze({ uri }));
611
- this.handOverRelease(this.toReleasedDocument(uri), cleanAnnouncement);
613
+ this.handOverRelease(released, cleanAnnouncement);
612
614
  }
613
615
 
614
616
  /**
@@ -660,6 +662,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
660
662
  protected toReleasedDocument(uri: CanonicalUri): ReleasedDocument {
661
663
  return {
662
664
  uri,
665
+ wasOnDisk: this.dirtyStateTracker.hasDiskBaseline(uri),
663
666
  isFor: other => this.documentKey(other) === uri,
664
667
  isReclaimed: () => this.isOpenInAnyClient(uri) || this.__syncedDocuments.has(uri)
665
668
  };
@@ -1192,20 +1195,29 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1192
1195
  /**
1193
1196
  * Read the file behind `uri` through its disk queue and take it as the
1194
1197
  * baseline. A file that cannot be read counts as none, the side that
1195
- * leaves the document dirty.
1198
+ * leaves the document dirty, once `existsSettled` confirms it gone: a file
1199
+ * it finds is read again. That wait runs outside the queue, so it holds up
1200
+ * no write to the file.
1196
1201
  */
1197
1202
  async reloadDiskBaseline(uri: DocumentUri): Promise<void> {
1198
1203
  const key = this.documentKey(uri);
1199
1204
  if (!this.__syncedDocuments.has(key)) {
1200
1205
  return;
1201
1206
  }
1202
- let onDisk: string | undefined;
1203
- try {
1204
- onDisk = await this.services.workspace.FileSystemTaskQueue.enqueue(key, () =>
1205
- this.services.workspace.FileSystemProvider.readFile(UriUtils.toUri(key))
1206
- );
1207
- } catch (err: unknown) {
1208
- this.tracer.with(key).debug(`Disk baseline: the file cannot be read. ${describeError(err)}`);
1207
+ const workspace = this.services.workspace;
1208
+ const fileSystem = workspace.FileSystemProvider;
1209
+ const target = UriUtils.toUri(key);
1210
+ const read = async (): Promise<string | undefined> => {
1211
+ try {
1212
+ return await workspace.FileSystemTaskQueue.enqueue(key, () => fileSystem.readFile(target));
1213
+ } catch (err: unknown) {
1214
+ this.tracer.with(key).debug(`Disk baseline: the file cannot be read. ${describeError(err)}`);
1215
+ return undefined;
1216
+ }
1217
+ };
1218
+ let onDisk = await read();
1219
+ if (onDisk === undefined && (await (fileSystem.existsSettled?.(target) ?? fileSystem.exists(target)))) {
1220
+ onDisk = await read();
1209
1221
  }
1210
1222
  this.setDiskBaseline(key, onDisk);
1211
1223
  }
@@ -61,6 +61,17 @@ export interface WritableFileSystemProvider extends FileSystemProvider {
61
61
  * browser-neutral (no `node:fs` import).
62
62
  */
63
63
  realpath?(uri: URI): URI | undefined;
64
+ /**
65
+ * Whether a file is at `uri`, where a miss counts only once it has lasted:
66
+ * on a disk where replacing a file can hide its name for a moment, a single
67
+ * {@link exists} cannot tell that from a deletion. Ask this before acting on
68
+ * a miss as a deletion; a caller expecting a miss, such as a create, asks
69
+ * {@link exists}, since here a real miss costs the whole wait.
70
+ *
71
+ * Optional: a provider whose misses are always final omits it, and a caller
72
+ * takes the answer of {@link exists} as settled.
73
+ */
74
+ existsSettled?(uri: URI): Promise<boolean>;
64
75
  /** Registry the provider records its own writes against. Optional — providers without watcher integration may omit. */
65
76
  readonly selfSaveRegistry?: SelfSaveRegistry;
66
77
  }
@@ -152,10 +163,10 @@ export interface FileSystemProviderRegistryOptions<
152
163
  * A directory is listed by the provider of its own scheme, so a workspace
153
164
  * root's tree comes from that provider alone.
154
165
  *
155
- * `realpath` and `mtimeMs` are always present and forward to the scheme's
156
- * provider. Where that provider lacks one, `realpath` returns the URI unchanged
157
- * and `mtimeMs` returns `undefined`: the answers `RealpathDocumentUriPolicy`
158
- * and the self-save filter already give a provider without them.
166
+ * `realpath`, `mtimeMs` and `existsSettled` are always present and forward to
167
+ * the scheme's provider. Where that provider lacks one, `realpath` returns the
168
+ * URI unchanged, `mtimeMs` returns `undefined` and `existsSettled` answers as
169
+ * `exists`: the answers their callers already give a provider without them.
159
170
  */
160
171
  export class DefaultFileSystemProviderRegistry<
161
172
  THost extends WritableFileSystemProvider = WritableFileSystemProvider
@@ -198,6 +209,11 @@ export class DefaultFileSystemProviderRegistry<
198
209
  return provider.realpath ? provider.realpath(uri) : uri;
199
210
  }
200
211
 
212
+ async existsSettled(uri: URI): Promise<boolean> {
213
+ const provider = this.providerFor(uri);
214
+ return provider.existsSettled ? provider.existsSettled(uri) : provider.exists(uri);
215
+ }
216
+
201
217
  async stat(uri: URI): Promise<FileSystemNode> {
202
218
  return this.providerFor(uri).stat(uri);
203
219
  }
@@ -15,7 +15,7 @@ import { realpathSync } from 'node:fs';
15
15
  import * as fsp from 'node:fs/promises';
16
16
  import { type WritableFileSystemProvider } from '../langium/workspace/file-system-provider.js';
17
17
  import { type SelfSaveRegistry } from '../langium/workspace/self-save-registry.js';
18
- import { renameOverOpenReaders } from './rename-over-open-readers.js';
18
+ import { RENAME_GAP_BUDGET_MS, renameOverOpenReaders } from './rename-over-open-readers.js';
19
19
  import { type LogNameOptions } from '../langium/diagnostics/logger.js';
20
20
  import { serverSharedFactory, type ServerSharedServicesMinimal } from '../langium/shared-services.js';
21
21
  import { NO_SUCH_FILE, NO_SUCH_PATH, UNSUPPORTED_WRITE } from '../langium/workspace/in-memory-file-system-provider.js';
@@ -122,6 +122,32 @@ export class DefaultFileSystemProvider extends NodeFileSystemProvider implements
122
122
  return this.servesFromDisk(uri) ? super.existsSync(uri) : false;
123
123
  }
124
124
 
125
+ /**
126
+ * On Windows a miss is polled for up to {@link RENAME_GAP_BUDGET_MS}, since a
127
+ * replacing rename there can hide the name from a reader for a while; it
128
+ * answers `true` as soon as the file is back. Elsewhere a miss is final.
129
+ */
130
+ async existsSettled(uri: URI): Promise<boolean> {
131
+ if (await this.exists(uri)) {
132
+ return true;
133
+ }
134
+ if (!this.servesFromDisk(uri) || process.platform !== 'win32') {
135
+ return false;
136
+ }
137
+ // A stopwatch, not two `now()` readings: a wall-clock jump would end the
138
+ // wait early, removing a file that is only being replaced.
139
+ const waited = this.services.Clock.stopwatch();
140
+ while (waited.elapsedMs < RENAME_GAP_BUDGET_MS) {
141
+ // Not `clock.setTimer`: it unrefs its timer, so a process with only
142
+ // this check pending could exit mid-wait.
143
+ await new Promise(resolve => setTimeout(resolve, 10));
144
+ if (await this.exists(uri)) {
145
+ return true;
146
+ }
147
+ }
148
+ return false;
149
+ }
150
+
125
151
  // A URI this provider cannot reach on disk has no children rather than an
126
152
  // error: a workspace walk enumerates folders it was handed, and failing one
127
153
  // aborts the walk where an empty listing simply contributes nothing.
@@ -146,8 +172,9 @@ export class DefaultFileSystemProvider extends NodeFileSystemProvider implements
146
172
  * good revision. On Windows the replacing rename also leaves the name
147
173
  * unresolvable for a moment, so a reader there can find the file missing
148
174
  * (`ENOENT`) or refused (`EPERM`), never torn, and must not take either for
149
- * a deletion. It does NOT serialise writers: two writers still race, and
150
- * the loser's content is replaced wholesale rather than interleaved.
175
+ * a deletion; {@link existsSettled} waits that moment out. It does NOT
176
+ * serialise writers: two writers still race, and the loser's content is
177
+ * replaced wholesale rather than interleaved.
151
178
  *
152
179
  * The destination is the RESOLVED path, via {@link realpath}: a `rename`
153
180
  * onto a symlink replaces the link itself with a regular file and leaves the
@@ -34,6 +34,14 @@ export const RENAME_CONTENTION_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']);
34
34
  */
35
35
  export const RENAME_BUDGET_MS = 10_000;
36
36
 
37
+ /**
38
+ * How long a miss is waited out on Windows before it counts as an absence,
39
+ * since a replacing rename there can hide the name from a reader for a while.
40
+ * Bounded: a real deletion differs only by never ending, so it pays the whole
41
+ * budget. The figure is a judgement, not an established bound.
42
+ */
43
+ export const RENAME_GAP_BUDGET_MS = 2_000;
44
+
37
45
  /** Seams for the test, which cannot make a real rename fail on demand. */
38
46
  export interface RenameOverOpenReadersOptions {
39
47
  readonly rename?: (from: string, to: string) => Promise<void>;
@@ -63,14 +71,17 @@ export async function renameOverOpenReaders(
63
71
  ): Promise<void> {
64
72
  const rename = options.rename ?? fsp.rename;
65
73
  const clock = options.clock ?? new SystemClock();
66
- const deadline = clock.now() + (options.budgetMs ?? RENAME_BUDGET_MS);
74
+ const budgetMs = options.budgetMs ?? RENAME_BUDGET_MS;
75
+ // A stopwatch, not two `now()` readings: a wall-clock jump would end the
76
+ // budget early or stretch it.
77
+ const waited = clock.stopwatch();
67
78
  for (let attempt = 0; ; attempt++) {
68
79
  try {
69
80
  await rename(staging, destination);
70
81
  return;
71
82
  } catch (err: unknown) {
72
83
  const code = err instanceof Error && 'code' in err ? String((err as NodeJS.ErrnoException).code) : undefined;
73
- if (code === undefined || !RENAME_CONTENTION_CODES.has(code) || clock.now() >= deadline) {
84
+ if (code === undefined || !RENAME_CONTENTION_CODES.has(code) || waited.elapsedMs >= budgetMs) {
74
85
  throw err;
75
86
  }
76
87
  // Backs off to keep a long contention window from spinning, capped so