@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.
- package/lib/documents/dirty-state-tracker.d.ts +7 -0
- package/lib/documents/dirty-state-tracker.d.ts.map +1 -1
- package/lib/documents/dirty-state-tracker.js +3 -0
- package/lib/documents/dirty-state-tracker.js.map +1 -1
- package/lib/documents/document-release-handler.d.ts +43 -18
- package/lib/documents/document-release-handler.d.ts.map +1 -1
- package/lib/documents/document-release-handler.js +74 -35
- package/lib/documents/document-release-handler.js.map +1 -1
- package/lib/documents/hydranium-text-documents.d.ts +3 -1
- package/lib/documents/hydranium-text-documents.d.ts.map +1 -1
- package/lib/documents/hydranium-text-documents.js +22 -8
- package/lib/documents/hydranium-text-documents.js.map +1 -1
- package/lib/langium/workspace/file-system-provider.d.ts +16 -4
- package/lib/langium/workspace/file-system-provider.d.ts.map +1 -1
- package/lib/langium/workspace/file-system-provider.js +8 -4
- package/lib/langium/workspace/file-system-provider.js.map +1 -1
- package/lib/node/node-file-system-provider.d.ts +9 -2
- package/lib/node/node-file-system-provider.d.ts.map +1 -1
- package/lib/node/node-file-system-provider.js +29 -3
- package/lib/node/node-file-system-provider.js.map +1 -1
- package/lib/node/rename-over-open-readers.d.ts +7 -0
- package/lib/node/rename-over-open-readers.d.ts.map +1 -1
- package/lib/node/rename-over-open-readers.js +12 -2
- package/lib/node/rename-over-open-readers.js.map +1 -1
- package/package.json +5 -5
- package/src/documents/dirty-state-tracker.ts +10 -0
- package/src/documents/document-release-handler.ts +81 -36
- package/src/documents/hydranium-text-documents.ts +21 -9
- package/src/langium/workspace/file-system-provider.ts +20 -4
- package/src/node/node-file-system-provider.ts +30 -3
- 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
|
|
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
|
|
109
|
-
* serve is rebuilt from its text, and
|
|
110
|
-
* buffer, a file never saved, or one
|
|
111
|
-
* from the workspace. A `virtual:` document
|
|
112
|
-
* provider for that scheme serves it from the
|
|
113
|
-
* host passes as `context.fileSystemProvider`;
|
|
114
|
-
* its last client's text, and keeping it
|
|
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.
|
|
185
|
-
*
|
|
186
|
-
* the build
|
|
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
|
-
|
|
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
|
|
254
|
+
return { kind: 'discarded', decision };
|
|
223
255
|
}
|
|
224
256
|
if (progress === 'building') {
|
|
225
|
-
return { kind: 'cancelled', decision
|
|
257
|
+
return { kind: 'cancelled', decision, parsed };
|
|
226
258
|
}
|
|
227
|
-
return { kind: 'completed', decision
|
|
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
|
-
|
|
262
|
-
|
|
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
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
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 `
|
|
156
|
-
* provider. Where that provider lacks one, `realpath` returns the
|
|
157
|
-
*
|
|
158
|
-
*
|
|
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
|
|
150
|
-
* the loser's content is
|
|
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
|
|
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) ||
|
|
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
|