diff --git a/packages/filesystem/src/node/parcel-watcher/parcel-filesystem-service.ts b/packages/filesystem/src/node/parcel-watcher/parcel-filesystem-service.ts index 41da3baef65ae..87331ca086b4b 100644 --- a/packages/filesystem/src/node/parcel-watcher/parcel-filesystem-service.ts +++ b/packages/filesystem/src/node/parcel-watcher/parcel-filesystem-service.ts @@ -220,10 +220,13 @@ export class ParcelWatcher { /** * When starting a watcher, we'll first check and wait for the path to exists - * before running a parcel watcher. + * before running a parcel watcher. If the path only came into existence while we + * were waiting, its creation is reported synthetically once we are subscribed. */ protected async start(): Promise { + let createdWhileWaiting = false; while (await fsp.stat(this.fsPath).then(() => false, () => true)) { + createdWhileWaiting = true; await timeout(500); this.assertNotDisposed(); } @@ -263,6 +266,14 @@ export class ParcelWatcher { throw WatcherDisposal; } this.watcher = watcher; + if (createdWhileWaiting) { + // The path did not exist when we were asked to watch it, so the actual creation + // happened before parcel was subscribed and its event is lost. Report the creation + // synthetically, otherwise clients keep the state they observed while the path was + // still missing until the next change. VS Code behaves the same way when it resumes + // a watch request that was suspended because its path did not exist. + this.handleWatcherEvents([{ type: 'create', path: this.fsPath }]); + } } /** diff --git a/packages/filesystem/src/node/parcel-watcher/parcel-watcher-missing-path.spec.ts b/packages/filesystem/src/node/parcel-watcher/parcel-watcher-missing-path.spec.ts new file mode 100644 index 0000000000000..bb4f9b2f2d7ae --- /dev/null +++ b/packages/filesystem/src/node/parcel-watcher/parcel-watcher-missing-path.spec.ts @@ -0,0 +1,98 @@ +// ***************************************************************************** +// Copyright (C) 2026 EclipseSource and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import * as chai from 'chai'; +import * as temp from 'temp'; +import * as fs from '@theia/core/shared/fs-extra'; +import URI from '@theia/core/lib/common/uri'; +import { FileUri } from '@theia/core/lib/node'; +import { ParcelFileSystemWatcherService } from './parcel-filesystem-service'; +import { FileChange, FileChangeType } from '../../common/filesystem-watcher-protocol'; + +const expect = chai.expect; +const track = temp.track(); + +/** + * `ParcelWatcher.start()` waits for the watched path to exist before subscribing, so the + * creation itself happens while nobody is subscribed and its event is lost. Clients then keep + * the state they observed while the path was missing until the next change, e.g. the backend + * preference service never picks up a `settings.json` that was created after startup. + * See https://github.com/eclipse-theia/theia/issues/17842. + */ +describe('parcel-filesystem-watcher missing path handling', function (): void { + + this.timeout(20000); + + let root: URI; + let service: ParcelFileSystemWatcherService; + let changes: FileChange[]; + + beforeEach(() => { + root = FileUri.create(fs.realpathSync(temp.mkdirSync('parcel-missing-path-root'))); + changes = []; + service = new ParcelFileSystemWatcherService({ verbose: false }); + service.setClient({ + onDidFilesChanged: event => changes.push(...event.changes), + onError: () => undefined + }); + }); + + afterEach(() => { + service.dispose(); + track.cleanupSync(); + }); + + it('reports the creation of a watched file that did not exist when the watcher was requested', async () => { + const file = root.resolve('settings.json'); + + await service.watchFileChanges(0, file.toString()); + // The watcher is now polling for the path: nothing can be reported yet. + await sleep(200); + expect(changes, 'no change should be reported while the path does not exist').to.be.empty; + + fs.writeFileSync(FileUri.fsPath(file), '{ "breadcrumbs.enabled": false }'); + + // The path is polled every 500ms, so allow a few intervals plus the subscribe. + await waitFor(() => changes.some(change => change.uri === file.toString() && change.type === FileChangeType.ADDED), 5000); + }); + + it('reports subsequent changes of a watched file that did not exist when the watcher was requested', async () => { + const file = root.resolve('settings.json'); + + await service.watchFileChanges(0, file.toString()); + await sleep(200); + fs.writeFileSync(FileUri.fsPath(file), '{ "breadcrumbs.enabled": false }'); + await waitFor(() => changes.some(change => change.uri === file.toString()), 5000); + + changes.length = 0; + fs.writeFileSync(FileUri.fsPath(file), '{ "breadcrumbs.enabled": true }'); + await waitFor(() => changes.some(change => change.uri === file.toString()), 5000); + }); +}); + +function sleep(time: number): Promise { + return new Promise(resolve => setTimeout(resolve, time)); +} + +async function waitFor(condition: () => boolean, timeout: number): Promise { + const deadline = Date.now() + timeout; + while (!condition()) { + if (Date.now() > deadline) { + expect.fail(`condition was not met within ${timeout}ms`); + } + await sleep(50); + } +}