diff --git a/packages/project/lib/build/helpers/fileWatcher.js b/packages/project/lib/build/helpers/fileWatcher.js index c2694c55d54..53e934e9a75 100644 --- a/packages/project/lib/build/helpers/fileWatcher.js +++ b/packages/project/lib/build/helpers/fileWatcher.js @@ -37,11 +37,11 @@ const rContainerCgroup = /\b(?:docker|libpod|containerd|kubepods)\b/; // Memoized backend decision. Computed once per process and shared by every subscribe() call. let usePolling = null; -// Memoized native backend: the @parcel/watcher module once loaded, or null when it could not load -// (e.g. no prebuilt binary for this platform). nativeBackendLoaded guards the one load attempt so a -// null result is not retried. -let nativeBackend = null; -let nativeBackendLoaded = false; +// Memoized load of the native backend. nativeBackendPromise holds the single in-flight (or +// settled) load so concurrent subscribe() calls await the same import instead of each reading +// nativeBackend before it resolves. It resolves to the @parcel/watcher module, or null when it +// could not load (e.g. no prebuilt binary for this platform); a null result is not retried. +let nativeBackendPromise = null; /** * Decides whether to poll, once per process. UI5_WATCH_MODE=polling|native forces the @@ -125,22 +125,21 @@ export async function subscribe(dir, callback, opts = {}) { return subscribePolling(dir, callback, opts); } -// Loads @parcel/watcher on demand and memoizes the result. Imported here rather than at module top -// because it resolves a native binding at load time and throws when the platform's prebuilt binary is -// not installed. A failure returns null (logged once) so subscribe() can fall back to polling instead -// of taking down every consumer. -async function loadNativeBackend() { - if (nativeBackendLoaded) { - return nativeBackend; - } - nativeBackendLoaded = true; - try { - nativeBackend = (await import("@parcel/watcher")).default; - } catch (err) { - nativeBackend = null; - log.warn(`Could not load the native file watcher (@parcel/watcher), falling back to ` + - `polling. This usually means the prebuilt binary for this platform was not installed. ` + - `Original error: ${err.message}`); - } - return nativeBackend; +// Loads @parcel/watcher on demand and memoizes the in-flight load. Imported here rather than at +// module top because it resolves a native binding at load time and throws when the platform's +// prebuilt binary is not installed. A failure resolves to null (logged once) so subscribe() can +// fall back to polling instead of taking down every consumer. The promise is memoized (not a +// pre-await flag) so concurrent callers cannot observe a half-initialized backend and wrongly +// fall back to polling. +function loadNativeBackend() { + return (nativeBackendPromise ??= (async () => { + try { + return (await import("@parcel/watcher")).default; + } catch (err) { + log.warn(`Could not load the native file watcher (@parcel/watcher), falling back to ` + + `polling. This usually means the prebuilt binary for this platform was not installed. ` + + `Original error: ${err.message}`); + return null; + } + })()); } diff --git a/packages/project/test/lib/build/helpers/fileWatcher.js b/packages/project/test/lib/build/helpers/fileWatcher.js index f84a78174b9..6121c96db9e 100644 --- a/packages/project/test/lib/build/helpers/fileWatcher.js +++ b/packages/project/test/lib/build/helpers/fileWatcher.js @@ -80,6 +80,30 @@ test.serial("subscribe: native delegation when UI5_WATCH_MODE=native", async (t) } }); +test.serial("subscribe: concurrent calls all use the native backend (no polling race)", async (t) => { + process.env.UI5_WATCH_MODE = "native"; + const nativeSubscription = {unsubscribe: sinon.stub().resolves()}; + const parcelSubscribe = sinon.stub().resolves(nativeSubscription); + const watcher = await importWatcherWithParcel({ + default: {subscribe: parcelSubscribe}, subscribe: parcelSubscribe, + }); + try { + // Fire concurrently, as WatchHandler does via Promise.all, before the first load resolves. + const results = await Promise.all([ + watcher.subscribe("/dir/a", () => {}, {}), + watcher.subscribe("/dir/b", () => {}, {}), + watcher.subscribe("/dir/c", () => {}, {}), + ]); + for (const s of results) { + t.is(s, nativeSubscription, "every concurrent subscription used the native backend"); + } + t.is(parcelSubscribe.callCount, 3, + "the native backend handled all subscriptions (none fell back to polling)"); + } finally { + esmock.purge(watcher); + } +}); + test.serial("subscribe: falls back to polling when the native backend is unavailable", async (t) => { // A missing prebuilt binary leaves no usable native backend. subscribe() must not fail the watch: // it uses polling, which needs no native code. The mock stands in for that unavailable module (no