@theia/filesystem 1.73.0-next.9 → 1.73.1
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/browser/file-service-watcher.spec.js +70 -0
- package/lib/browser/file-service-watcher.spec.js.map +1 -1
- package/lib/browser/file-service.d.ts +14 -0
- package/lib/browser/file-service.d.ts.map +1 -1
- package/lib/browser/file-service.js +36 -7
- package/lib/browser/file-service.js.map +1 -1
- package/lib/browser/file-tree/file-tree-widget.d.ts +2 -0
- package/lib/browser/file-tree/file-tree-widget.d.ts.map +1 -1
- package/lib/browser/file-tree/file-tree-widget.js +6 -1
- package/lib/browser/file-tree/file-tree-widget.js.map +1 -1
- package/lib/browser/filesystem-frontend-contribution.d.ts +2 -1
- package/lib/browser/filesystem-frontend-contribution.d.ts.map +1 -1
- package/lib/browser/filesystem-frontend-contribution.js +7 -2
- package/lib/browser/filesystem-frontend-contribution.js.map +1 -1
- package/lib/browser/location/location-renderer.spec.js.map +1 -1
- package/lib/node/disk-file-system-provider.d.ts +2 -0
- package/lib/node/disk-file-system-provider.d.ts.map +1 -1
- package/lib/node/disk-file-system-provider.js +9 -3
- package/lib/node/disk-file-system-provider.js.map +1 -1
- package/lib/node/parcel-watcher/parcel-filesystem-service.d.ts.map +1 -1
- package/lib/node/parcel-watcher/parcel-filesystem-service.js +29 -1
- package/lib/node/parcel-watcher/parcel-filesystem-service.js.map +1 -1
- package/lib/node/parcel-watcher/parcel-watcher-retry.spec.d.ts +2 -0
- package/lib/node/parcel-watcher/parcel-watcher-retry.spec.d.ts.map +1 -0
- package/lib/node/parcel-watcher/parcel-watcher-retry.spec.js +102 -0
- package/lib/node/parcel-watcher/parcel-watcher-retry.spec.js.map +1 -0
- package/lib/node/upload/node-file-upload-service.d.ts +2 -0
- package/lib/node/upload/node-file-upload-service.d.ts.map +1 -1
- package/lib/node/upload/node-file-upload-service.js +9 -3
- package/lib/node/upload/node-file-upload-service.js.map +1 -1
- package/package.json +4 -4
- package/src/browser/file-service-watcher.spec.ts +89 -0
- package/src/browser/file-service.ts +36 -8
- package/src/browser/file-tree/file-tree-widget.tsx +6 -3
- package/src/browser/filesystem-frontend-contribution.ts +7 -4
- package/src/browser/location/location-renderer.spec.ts +2 -2
- package/src/node/disk-file-system-provider.ts +8 -4
- package/src/node/parcel-watcher/parcel-filesystem-service.ts +28 -1
- package/src/node/parcel-watcher/parcel-watcher-retry.spec.ts +117 -0
- package/src/node/upload/node-file-upload-service.ts +9 -4
|
@@ -68,7 +68,7 @@ import { Mutable } from '@theia/core/lib/common/types';
|
|
|
68
68
|
import { readFileIntoStream } from '../common/io';
|
|
69
69
|
import { FileSystemWatcherErrorHandler } from './filesystem-watcher-error-handler';
|
|
70
70
|
import { FileSystemUtils } from '../common/filesystem-utils';
|
|
71
|
-
import { nls } from '@theia/core';
|
|
71
|
+
import { nls, ILogger } from '@theia/core';
|
|
72
72
|
import { MarkdownString } from '@theia/core/lib/common/markdown-rendering';
|
|
73
73
|
|
|
74
74
|
export interface FileOperationParticipant {
|
|
@@ -305,6 +305,9 @@ export class FileService {
|
|
|
305
305
|
@inject(FileSystemWatcherErrorHandler)
|
|
306
306
|
protected readonly watcherErrorHandler: FileSystemWatcherErrorHandler;
|
|
307
307
|
|
|
308
|
+
@inject(ILogger) @named('filesystem:FileService')
|
|
309
|
+
protected readonly logger: ILogger;
|
|
310
|
+
|
|
308
311
|
@postConstruct()
|
|
309
312
|
protected init(): void {
|
|
310
313
|
for (const contribution of this.contributions.getContributions()) {
|
|
@@ -581,7 +584,7 @@ export class FileService {
|
|
|
581
584
|
|
|
582
585
|
return await this.toFileStat(provider, childResource, childStat, entries.length, resolveMetadata, recurse);
|
|
583
586
|
} catch (error) {
|
|
584
|
-
|
|
587
|
+
this.logger.error(error);
|
|
585
588
|
|
|
586
589
|
return null; // can happen e.g. due to permission errors
|
|
587
590
|
}
|
|
@@ -590,7 +593,7 @@ export class FileService {
|
|
|
590
593
|
// make sure to get rid of null values that signal a failure to resolve a particular entry
|
|
591
594
|
fileStat.children = resolvedEntries.filter(e => !!e) as FileStat[];
|
|
592
595
|
} catch (error) {
|
|
593
|
-
|
|
596
|
+
this.logger.error(error);
|
|
594
597
|
|
|
595
598
|
fileStat.children = []; // gracefully handle errors, we may not have permissions to read
|
|
596
599
|
}
|
|
@@ -615,7 +618,7 @@ export class FileService {
|
|
|
615
618
|
try {
|
|
616
619
|
return { stat: await this.doResolveFile(entry.resource, entry.options), success: true };
|
|
617
620
|
} catch (error) {
|
|
618
|
-
|
|
621
|
+
this.logger.error(error);
|
|
619
622
|
|
|
620
623
|
return { stat: undefined, success: false };
|
|
621
624
|
}
|
|
@@ -1439,8 +1442,7 @@ export class FileService {
|
|
|
1439
1442
|
watch(resource: URI, options: WatchOptions = { recursive: false, excludes: [] }): Disposable {
|
|
1440
1443
|
const resolvedOptions: WatchOptions = {
|
|
1441
1444
|
...options,
|
|
1442
|
-
|
|
1443
|
-
excludes: options.excludes.concat('**/theia_upload_*')
|
|
1445
|
+
excludes: this.resolveWatcherExcludes(resource, options.excludes)
|
|
1444
1446
|
};
|
|
1445
1447
|
|
|
1446
1448
|
let watchDisposed = false;
|
|
@@ -1454,11 +1456,37 @@ export class FileService {
|
|
|
1454
1456
|
} else {
|
|
1455
1457
|
watchDisposable = disposable;
|
|
1456
1458
|
}
|
|
1457
|
-
}, error =>
|
|
1459
|
+
}, error => this.logger.error(error));
|
|
1458
1460
|
|
|
1459
1461
|
return Disposable.create(() => watchDisposable.dispose());
|
|
1460
1462
|
}
|
|
1461
1463
|
|
|
1464
|
+
/**
|
|
1465
|
+
* Resolve the effective exclude globs for a watcher: the caller-supplied `excludes`, the
|
|
1466
|
+
* always-on temporary-upload exclude, and the user's `files.watcherExclude` preference.
|
|
1467
|
+
*
|
|
1468
|
+
* Applying `files.watcherExclude` here, for every watcher, rather than relying on individual
|
|
1469
|
+
* callers, keeps the number of OS file watches (e.g. inotify watches on Linux) bounded even for
|
|
1470
|
+
* watchers that request `excludes: []` - internal recursive watchers as well as plugin and
|
|
1471
|
+
* language-server watchers created via `vscode.workspace.createFileSystemWatcher`. It also gives
|
|
1472
|
+
* overlapping watchers a consistent set of excludes, so the watcher subsumption in `doWatch` can
|
|
1473
|
+
* collapse them into a single OS watch instead of leaving duplicates that emit duplicate events.
|
|
1474
|
+
*/
|
|
1475
|
+
protected resolveWatcherExcludes(resource: URI, excludes: string[]): string[] {
|
|
1476
|
+
const resolved = new Set(excludes);
|
|
1477
|
+
// always ignore temporary upload files
|
|
1478
|
+
resolved.add('**/theia_upload_*');
|
|
1479
|
+
const configured = this.preferences.get('files.watcherExclude', undefined, resource.toString());
|
|
1480
|
+
if (configured) {
|
|
1481
|
+
for (const pattern of Object.keys(configured)) {
|
|
1482
|
+
if (configured[pattern]) {
|
|
1483
|
+
resolved.add(pattern);
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
return Array.from(resolved);
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1462
1490
|
async doWatch(resource: URI, options: WatchOptions): Promise<Disposable> {
|
|
1463
1491
|
const provider = await this.withProvider(resource);
|
|
1464
1492
|
const key = this.toWatchKey(provider, resource, options);
|
|
@@ -2030,7 +2058,7 @@ export class FileService {
|
|
|
2030
2058
|
timeout(participantsTimeout, cancellationTokenSource.token).then(() => cancellationTokenSource.dispose(), () => { /* no-op if cancelled */ })
|
|
2031
2059
|
]);
|
|
2032
2060
|
} catch (err) {
|
|
2033
|
-
|
|
2061
|
+
this.logger.warn(err);
|
|
2034
2062
|
}
|
|
2035
2063
|
}
|
|
2036
2064
|
},
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// *****************************************************************************
|
|
16
16
|
|
|
17
17
|
import * as React from '@theia/core/shared/react';
|
|
18
|
-
import { injectable, inject } from '@theia/core/shared/inversify';
|
|
18
|
+
import { injectable, inject, named } from '@theia/core/shared/inversify';
|
|
19
19
|
import { DisposableCollection, Disposable } from '@theia/core/lib/common/disposable';
|
|
20
20
|
import URI from '@theia/core/lib/common/uri';
|
|
21
21
|
import { UriSelection } from '@theia/core/lib/common/selection';
|
|
@@ -26,7 +26,7 @@ import { FileTreeModel } from './file-tree-model';
|
|
|
26
26
|
import { IconThemeService } from '@theia/core/lib/browser/icon-theme-service';
|
|
27
27
|
import { ApplicationShell } from '@theia/core/lib/browser/shell';
|
|
28
28
|
import { FileStat, FileType } from '../../common/files';
|
|
29
|
-
import { isOSX } from '@theia/core';
|
|
29
|
+
import { isOSX, ILogger } from '@theia/core';
|
|
30
30
|
import { FileUploadService } from '../../common/upload/file-upload';
|
|
31
31
|
|
|
32
32
|
export const FILE_TREE_CLASS = 'theia-FileTree';
|
|
@@ -45,6 +45,9 @@ export class FileTreeWidget extends CompressedTreeWidget {
|
|
|
45
45
|
@inject(IconThemeService)
|
|
46
46
|
protected readonly iconThemeService: IconThemeService;
|
|
47
47
|
|
|
48
|
+
@inject(ILogger) @named('filesystem:FileTreeWidget')
|
|
49
|
+
protected readonly logger: ILogger;
|
|
50
|
+
|
|
48
51
|
constructor(
|
|
49
52
|
@inject(TreeProps) props: TreeProps,
|
|
50
53
|
@inject(FileTreeModel) override readonly model: FileTreeModel,
|
|
@@ -201,7 +204,7 @@ export class FileTreeWidget extends CompressedTreeWidget {
|
|
|
201
204
|
}
|
|
202
205
|
} catch (e) {
|
|
203
206
|
if (!isCancelled(e)) {
|
|
204
|
-
|
|
207
|
+
this.logger.error(e);
|
|
205
208
|
}
|
|
206
209
|
}
|
|
207
210
|
}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
|
15
15
|
// *****************************************************************************
|
|
16
16
|
|
|
17
|
-
import { CorePreferences, nls } from '@theia/core';
|
|
17
|
+
import { CorePreferences, nls, ILogger } from '@theia/core';
|
|
18
18
|
import {
|
|
19
19
|
ApplicationShell,
|
|
20
20
|
CommonCommands,
|
|
@@ -35,7 +35,7 @@ import { Command, CommandContribution, CommandRegistry } from '@theia/core/lib/c
|
|
|
35
35
|
import { Deferred } from '@theia/core/lib/common/promise-util';
|
|
36
36
|
import URI from '@theia/core/lib/common/uri';
|
|
37
37
|
import { environment } from '@theia/core/shared/@theia/application-package/lib/environment';
|
|
38
|
-
import { inject, injectable } from '@theia/core/shared/inversify';
|
|
38
|
+
import { inject, injectable, named } from '@theia/core/shared/inversify';
|
|
39
39
|
import { UserWorkingDirectoryProvider } from '@theia/core/lib/browser/user-working-directory-provider';
|
|
40
40
|
import { FileChangeType, FileChangesEvent, FileOperation } from '../common/files';
|
|
41
41
|
import { FileDialogService, SaveFileDialogProps } from './file-dialog';
|
|
@@ -95,6 +95,9 @@ export class FileSystemFrontendContribution implements FrontendApplicationContri
|
|
|
95
95
|
@inject(UserWorkingDirectoryProvider)
|
|
96
96
|
protected readonly workingDirectory: UserWorkingDirectoryProvider;
|
|
97
97
|
|
|
98
|
+
@inject(ILogger) @named('filesystem:FileSystemFrontendContribution')
|
|
99
|
+
protected readonly logger: ILogger;
|
|
100
|
+
|
|
98
101
|
protected onDidChangeEditorFileEmitter = new Emitter<{ editor: NavigatableWidget, type: FileChangeType }>();
|
|
99
102
|
readonly onDidChangeEditorFile = this.onDidChangeEditorFileEmitter.event;
|
|
100
103
|
|
|
@@ -171,7 +174,7 @@ export class FileSystemFrontendContribution implements FrontendApplicationContri
|
|
|
171
174
|
return fileUploadResult;
|
|
172
175
|
} catch (e) {
|
|
173
176
|
if (!isCancelled(e)) {
|
|
174
|
-
|
|
177
|
+
this.logger.error(e);
|
|
175
178
|
}
|
|
176
179
|
}
|
|
177
180
|
}
|
|
@@ -227,7 +230,7 @@ export class FileSystemFrontendContribution implements FrontendApplicationContri
|
|
|
227
230
|
try {
|
|
228
231
|
await operation();
|
|
229
232
|
} catch (e) {
|
|
230
|
-
|
|
233
|
+
this.logger.error(e);
|
|
231
234
|
}
|
|
232
235
|
});
|
|
233
236
|
}
|
|
@@ -174,7 +174,7 @@ describe('LocationListRenderer', () => {
|
|
|
174
174
|
service.location = location;
|
|
175
175
|
const r = new TestableLocationListRenderer({ model: service as unknown as FileDialogModel, host });
|
|
176
176
|
|
|
177
|
-
const selectElement = r.testRenderSelectInput() as React.ReactElement
|
|
177
|
+
const selectElement = r.testRenderSelectInput() as React.ReactElement<{ value: string }>;
|
|
178
178
|
|
|
179
179
|
expect(selectElement.props.value).to.equal(location.toString());
|
|
180
180
|
r.dispose();
|
|
@@ -190,7 +190,7 @@ describe('LocationListRenderer', () => {
|
|
|
190
190
|
const newLocation = URI.fromFilePath('/c:/Users');
|
|
191
191
|
service.location = newLocation;
|
|
192
192
|
|
|
193
|
-
const selectElement = r.testRenderSelectInput() as React.ReactElement
|
|
193
|
+
const selectElement = r.testRenderSelectInput() as React.ReactElement<{ value: string }>;
|
|
194
194
|
|
|
195
195
|
expect(selectElement.props.value).to.equal(newLocation.toString());
|
|
196
196
|
r.dispose();
|
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
/* eslint-disable no-null/no-null */
|
|
23
23
|
/* eslint-disable @typescript-eslint/no-shadow */
|
|
24
24
|
|
|
25
|
-
import { injectable, inject, postConstruct } from '@theia/core/shared/inversify';
|
|
25
|
+
import { injectable, inject, postConstruct, named } from '@theia/core/shared/inversify';
|
|
26
|
+
import { ILogger } from '@theia/core';
|
|
26
27
|
import { basename, dirname, normalize, join } from 'path';
|
|
27
28
|
import { generateUuid } from '@theia/core/lib/common/uuid';
|
|
28
29
|
import * as os from 'os';
|
|
@@ -109,6 +110,9 @@ export class DiskFileSystemProvider implements Disposable,
|
|
|
109
110
|
@inject(EncodingService)
|
|
110
111
|
protected readonly encodingService: EncodingService;
|
|
111
112
|
|
|
113
|
+
@inject(ILogger) @named('filesystem:DiskFileSystemProvider')
|
|
114
|
+
protected readonly logger: ILogger;
|
|
115
|
+
|
|
112
116
|
@postConstruct()
|
|
113
117
|
protected init(): void {
|
|
114
118
|
this.toDispose.push(this.watcher);
|
|
@@ -220,7 +224,7 @@ export class DiskFileSystemProvider implements Disposable,
|
|
|
220
224
|
const stat = await this.stat(resource.resolve(child));
|
|
221
225
|
result.push([child, stat.type]);
|
|
222
226
|
} catch (error) {
|
|
223
|
-
|
|
227
|
+
this.logger.error(error); // ignore errors for individual entries that can arise from permission denied
|
|
224
228
|
}
|
|
225
229
|
}));
|
|
226
230
|
|
|
@@ -332,7 +336,7 @@ export class DiskFileSystemProvider implements Disposable,
|
|
|
332
336
|
// After a successful truncate() the flag can be set to 'r+' which will not truncate.
|
|
333
337
|
flags = 'r+';
|
|
334
338
|
} catch (error) {
|
|
335
|
-
|
|
339
|
+
this.logger.error(error);
|
|
336
340
|
}
|
|
337
341
|
}
|
|
338
342
|
|
|
@@ -384,7 +388,7 @@ export class DiskFileSystemProvider implements Disposable,
|
|
|
384
388
|
// In some exotic setups it is well possible that node fails to sync
|
|
385
389
|
// In that case we disable flushing and log the error to our logger
|
|
386
390
|
this.canFlush = false;
|
|
387
|
-
|
|
391
|
+
this.logger.error(error);
|
|
388
392
|
}
|
|
389
393
|
}
|
|
390
394
|
|
|
@@ -140,6 +140,7 @@ export class ParcelWatcher {
|
|
|
140
140
|
if (error === WatcherDisposal) {
|
|
141
141
|
return false;
|
|
142
142
|
}
|
|
143
|
+
console.error(`Watcher failed to start at "${this.fsPath}":`, error);
|
|
143
144
|
this._dispose();
|
|
144
145
|
this.fireError();
|
|
145
146
|
throw error;
|
|
@@ -227,7 +228,33 @@ export class ParcelWatcher {
|
|
|
227
228
|
this.assertNotDisposed();
|
|
228
229
|
}
|
|
229
230
|
this.assertNotDisposed();
|
|
230
|
-
|
|
231
|
+
// This race is specific to Linux/inotify: parcel-watcher's inotify backend walks
|
|
232
|
+
// the tree and then calls inotify_add_watch on every subdirectory. If a subdirectory
|
|
233
|
+
// disappears between the walk and the add (common when watching dirs that contain
|
|
234
|
+
// auto-rotated log/temp folders), the syscall returns ENOENT and parcel-watcher fails
|
|
235
|
+
// the entire subscribe. Retry a few times: by the next walk the gone-but-not-forgotten
|
|
236
|
+
// dir is no longer present. Windows (ReadDirectoryChangesW) and macOS (FSEvents) watch
|
|
237
|
+
// the whole subtree from a single handle on the root and never register per-subdirectory
|
|
238
|
+
// watches, so they cannot hit this race; the retry is simply a no-op there.
|
|
239
|
+
let watcher: AsyncSubscription | undefined;
|
|
240
|
+
let attempt = 0;
|
|
241
|
+
while (true) {
|
|
242
|
+
try {
|
|
243
|
+
watcher = await this.createWatcher();
|
|
244
|
+
break;
|
|
245
|
+
} catch (error) {
|
|
246
|
+
const message: string = (error && error.message) || '';
|
|
247
|
+
const isTransientEnoent = message.includes('No such file or directory')
|
|
248
|
+
&& await fsp.stat(this.fsPath).then(() => true, () => false);
|
|
249
|
+
if (!isTransientEnoent || attempt >= 4) {
|
|
250
|
+
throw error;
|
|
251
|
+
}
|
|
252
|
+
attempt++;
|
|
253
|
+
this.assertNotDisposed();
|
|
254
|
+
await timeout(100 * attempt);
|
|
255
|
+
this.assertNotDisposed();
|
|
256
|
+
}
|
|
257
|
+
}
|
|
231
258
|
this.assertNotDisposed();
|
|
232
259
|
this.debug('STARTED', `disposed=${this.disposed}`);
|
|
233
260
|
// The watcher could be disposed while it was starting, make sure to check for this:
|
|
@@ -0,0 +1,117 @@
|
|
|
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 Eclipse Public License v. 2.0 which is available at
|
|
6
|
+
// http://www.eclipse.org/legal/epl-2.0.
|
|
7
|
+
//
|
|
8
|
+
// This Source Code may also be made available under the following Secondary
|
|
9
|
+
// Licenses when the conditions for such availability set forth in the Eclipse
|
|
10
|
+
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
|
11
|
+
// with the GNU Classpath Exception which is available at
|
|
12
|
+
// https://www.gnu.org/software/classpath/license.html.
|
|
13
|
+
//
|
|
14
|
+
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
|
15
|
+
// *****************************************************************************
|
|
16
|
+
|
|
17
|
+
import * as chai from 'chai';
|
|
18
|
+
import * as sinon from 'sinon';
|
|
19
|
+
import * as temp from 'temp';
|
|
20
|
+
import * as fs from '@theia/core/shared/fs-extra';
|
|
21
|
+
import URI from '@theia/core/lib/common/uri';
|
|
22
|
+
import { FileUri } from '@theia/core/lib/node';
|
|
23
|
+
import { ParcelFileSystemWatcherService } from './parcel-filesystem-service';
|
|
24
|
+
|
|
25
|
+
// We require the *same* module object that the production code imports from, so that
|
|
26
|
+
// stubbing its `subscribe` export is observed by `ParcelWatcher`. The `@theia/core/shared`
|
|
27
|
+
// shim simply re-exports `require('@parcel/watcher')`, so this is the identical reference.
|
|
28
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
29
|
+
const parcel = require('@theia/core/shared/@parcel/watcher');
|
|
30
|
+
|
|
31
|
+
const expect = chai.expect;
|
|
32
|
+
const track = temp.track();
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Covers the inotify-tree-race fix in `ParcelWatcher.start()`:
|
|
36
|
+
*
|
|
37
|
+
* parcel-watcher walks the directory tree and only then calls `inotify_add_watch`
|
|
38
|
+
* on each subdirectory. If a subdirectory disappears between the walk and the add
|
|
39
|
+
* (common when watching dirs that contain rotated logs/temp folders), the syscall
|
|
40
|
+
* returns ENOENT and parcel-watcher fails the *entire* subscribe. The fix retries
|
|
41
|
+
* `createWatcher` a few times, but only when (a) the underlying error indicates a
|
|
42
|
+
* missing path and (b) the watched root itself still exists.
|
|
43
|
+
*/
|
|
44
|
+
describe('parcel-filesystem-watcher transient ENOENT handling', function (): void {
|
|
45
|
+
|
|
46
|
+
this.timeout(20000);
|
|
47
|
+
|
|
48
|
+
let root: URI;
|
|
49
|
+
let service: ParcelFileSystemWatcherService;
|
|
50
|
+
let subscribeStub: sinon.SinonStub | undefined;
|
|
51
|
+
let consoleErrorStub: sinon.SinonStub;
|
|
52
|
+
let onError: sinon.SinonStub;
|
|
53
|
+
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
const tempPath = temp.mkdirSync('parcel-enoent-root');
|
|
56
|
+
root = FileUri.create(fs.realpathSync(tempPath));
|
|
57
|
+
// start() now logs the underlying error to stderr on failure; silence it
|
|
58
|
+
// so the test output stays readable.
|
|
59
|
+
consoleErrorStub = sinon.stub(console, 'error');
|
|
60
|
+
service = new ParcelFileSystemWatcherService({ verbose: false });
|
|
61
|
+
onError = sinon.stub();
|
|
62
|
+
service.setClient({
|
|
63
|
+
onDidFilesChanged: () => undefined,
|
|
64
|
+
onError,
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
afterEach(() => {
|
|
69
|
+
subscribeStub?.restore();
|
|
70
|
+
consoleErrorStub.restore();
|
|
71
|
+
track.cleanupSync();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('retries when subscribe throws a transient ENOENT and the watched root still exists', async () => {
|
|
75
|
+
let attempts = 0;
|
|
76
|
+
subscribeStub = sinon.stub(parcel, 'subscribe').callsFake(async () => {
|
|
77
|
+
attempts++;
|
|
78
|
+
if (attempts < 3) {
|
|
79
|
+
throw new Error('No such file or directory at /tmp/rotated-log');
|
|
80
|
+
}
|
|
81
|
+
return { unsubscribe: async () => undefined };
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
await service.watchFileChanges(0, root.toString());
|
|
85
|
+
// Backoff schedule for two retries: 100 + 200 = 300ms. Leave generous margin.
|
|
86
|
+
await new Promise(resolve => setTimeout(resolve, 800));
|
|
87
|
+
|
|
88
|
+
expect(attempts, 'subscribe should have been retried until it succeeded').to.equal(3);
|
|
89
|
+
expect(onError.called, 'no error should surface to the client once the retry recovered').to.equal(false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('does not retry on non-ENOENT errors and surfaces the failure immediately', async () => {
|
|
93
|
+
subscribeStub = sinon.stub(parcel, 'subscribe').callsFake(async () => {
|
|
94
|
+
throw new Error('EACCES: permission denied');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
await service.watchFileChanges(0, root.toString());
|
|
98
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
99
|
+
|
|
100
|
+
expect(subscribeStub.callCount, 'non-transient errors must not trigger any retry').to.equal(1);
|
|
101
|
+
expect(onError.called, 'error must be reported to the client').to.equal(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('gives up after the retry budget is exhausted on persistent ENOENT', async () => {
|
|
105
|
+
subscribeStub = sinon.stub(parcel, 'subscribe').callsFake(async () => {
|
|
106
|
+
throw new Error('No such file or directory at /tmp/rotated-log');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
await service.watchFileChanges(0, root.toString());
|
|
110
|
+
// Total backoff is 100+200+300+400 = 1000ms; leave a margin for scheduling.
|
|
111
|
+
await new Promise(resolve => setTimeout(resolve, 1700));
|
|
112
|
+
|
|
113
|
+
// Initial attempt + 4 retries = 5 total subscribe calls.
|
|
114
|
+
expect(subscribeStub.callCount, 'should retry up to the budget then give up').to.equal(5);
|
|
115
|
+
expect(onError.called, 'error must surface once the retry budget is exhausted').to.equal(true);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -20,11 +20,16 @@ import os = require('os');
|
|
|
20
20
|
import express = require('@theia/core/shared/express');
|
|
21
21
|
import fs = require('@theia/core/shared/fs-extra');
|
|
22
22
|
import { BackendApplicationContribution, FileUri } from '@theia/core/lib/node';
|
|
23
|
-
import { injectable } from '@theia/core/shared/inversify';
|
|
23
|
+
import { injectable, inject, named } from '@theia/core/shared/inversify';
|
|
24
24
|
import { HTTP_FILE_UPLOAD_PATH } from '../../common/file-upload';
|
|
25
|
+
import { ILogger } from '@theia/core';
|
|
25
26
|
|
|
26
27
|
@injectable()
|
|
27
28
|
export class NodeFileUploadService implements BackendApplicationContribution {
|
|
29
|
+
|
|
30
|
+
@inject(ILogger) @named('filesystem:NodeFileUploadService')
|
|
31
|
+
protected readonly logger: ILogger;
|
|
32
|
+
|
|
28
33
|
private static readonly UPLOAD_DIR = 'theia_upload';
|
|
29
34
|
|
|
30
35
|
async configure(app: express.Application): Promise<void> {
|
|
@@ -32,8 +37,8 @@ export class NodeFileUploadService implements BackendApplicationContribution {
|
|
|
32
37
|
this.getTemporaryUploadDest(),
|
|
33
38
|
this.getHttpFileUploadPath()
|
|
34
39
|
]);
|
|
35
|
-
|
|
36
|
-
|
|
40
|
+
this.logger.debug(`HTTP file upload URL path: ${http_path}`);
|
|
41
|
+
this.logger.debug(`Backend file upload cache path: ${dest}`);
|
|
37
42
|
app.post(
|
|
38
43
|
http_path,
|
|
39
44
|
// `multer` handles `multipart/form-data` containing our file to upload.
|
|
@@ -72,7 +77,7 @@ export class NodeFileUploadService implements BackendApplicationContribution {
|
|
|
72
77
|
}
|
|
73
78
|
response.status(200).send(target); // ok
|
|
74
79
|
} catch (error) {
|
|
75
|
-
|
|
80
|
+
this.logger.error(error);
|
|
76
81
|
if (error.message) {
|
|
77
82
|
// internal server error with error message as response
|
|
78
83
|
response.status(500).send(error.message);
|