diff --git a/docs/pubsub-waste-findings.md b/docs/pubsub-waste-findings.md new file mode 100644 index 0000000000..3dc6ee396a --- /dev/null +++ b/docs/pubsub-waste-findings.md @@ -0,0 +1,93 @@ +# PubSub Waste Measurement Findings + +## Summary + +The `sourceTrackingStatusBar` file watcher generated ~45K raw events during `npm install`, producing ~15-17 redundant `getStatus` calls to the org. Three fixes were applied incrementally: + +1. **Callback-level debounce** — reduced Effect stream pushes from 45K to 20 +2. **In-flight cancellation** — `{ switch: true }` interrupts stale `getStatus` calls when new signals arrive +3. **Scoped watchers + ForceIgnore filtering** — eliminates irrelevant callbacks entirely, reducing `getStatus` calls during `npm install` to 0 + +## Problem + +The source tracking status bar used `vscode.workspace.createFileSystemWatcher('**/*')` — a glob that fires on every file event in the workspace. During `npm install`, this produces ~45K events. Each event was pushed into an Effect `Stream.async`, where `Stream.debounce(500ms)` discarded all but the last value per window. Each `emit.single()` call: + +- Allocates a `Chunk` +- Resolves/enqueues a `Deferred` +- May schedule Effect fiber work + +Every debounce window that fires triggers `refresh()` → `getStatus({ local: true, remote: true })`, which re-reads local tracking cache and makes a remote tracking check. During a 60-second `npm install`, this produced ~15-17 pointless status checks against the org. + +## Measurement Results + +**Test:** `rm -rf node_modules && npm install` with a scratch org (source-tracking enabled). + +### Phase 1: Before (Stream.debounce only) + +| Metric | Value | +|--------|-------| +| Raw callbacks | 41,446 | +| Stream pushes (`emit.single`) | 41,446 | +| Timer resets | N/A (no callback debounce) | +| Estimated `getStatus` calls | ~15-17 | + +### Phase 2: After callback-level debounce + +| Metric | Value | +|--------|-------| +| Raw callbacks | 44,939 | +| Stream pushes (`emit.single`) | **20** | +| Timer resets | 44,939 | +| Estimated `getStatus` calls | ~15-17 (unchanged — timer still resets per event) | + +Stream push waste eliminated (2,247x reduction), but the debounce timer still resets on every raw callback, so every 500ms quiet window during the install still triggers a `getStatus`. + +### Phase 3: After scoped watchers + ForceIgnore (final) + +| Metric | Value | +|--------|-------| +| Raw callbacks reaching JS | ~0 (node_modules outside pkg dirs) or denied by ForceIgnore | +| Timer resets | **0** | +| Stream pushes | **0** | +| `getStatus` calls from file watcher | **0** | + +Only the poll stream (every 60s) triggers `getStatus` during this period — which is the intended behavior for catching remote-only changes. + +## Progression of Fixes + +### Fix 1: Callback-level debounce + +Moved debouncing from `Stream.debounce` into the raw callback using `clearTimeout`/`setTimeout`. The stream only receives a value when 500ms of quiet passes. + +```typescript +// Before: every event pushes into the stream +const fire = () => { void emit.single(undefined); }; + +// After: only quiet periods push into the stream +let timer: ReturnType | undefined; +const fire = () => { + if (timer !== undefined) clearTimeout(timer); + timer = setTimeout(() => { timer = undefined; void emit.single(undefined); }, 500); +}; +``` + +### Fix 2: In-flight cancellation + +Replaced `Stream.runForEach(() => refresh(...))` with `Stream.flatMap(() => Stream.fromEffect(refresh(...)), { switch: true })`. When a new signal arrives while `refresh` is in-flight (e.g., waiting on a network call for remote tracking), Effect interrupts the running refresh and starts a fresh one. + +### Fix 3: Scoped watchers + ForceIgnore + +Two-layer filtering eliminates irrelevant events before they touch the debounce timer: + +| Layer | What it eliminates | Mechanism | +|-------|-------------------|-----------| +| `RelativePattern` per package directory | Events from outside all package dirs (e.g., root-level `node_modules` when root is not a package dir) | OS-level file notification filtering, zero callback cost | +| `ForceIgnore.denies()` in callback | Events from ignored paths within package dirs (e.g., `force-app/node_modules/`, `**/__tests__/**`) | Regex match per event (~1μs), checked before timer reset | + +Uses the same `.forceignore` file that `@salesforce/source-tracking` and `@salesforce/source-deploy-retrieve` already respect, ensuring the watcher's filter is consistent with what source tracking considers relevant. + +Watchers automatically rebuild when `sfdx-project.json` or `.forceignore` changes. + +## Other Watchers + +The `aliasFileWatcher` and `configFileWatcher` are already well-scoped to specific files in `.sfdx`/`.sf` directories. No changes needed. diff --git a/knip.json b/knip.json index c2b027b442..fe36e43417 100644 --- a/knip.json +++ b/knip.json @@ -13,6 +13,9 @@ "workspaces": { "packages/salesforcedx-aura-language-server": { "entry": ["src/index.ts", "src/server.ts"] + }, + "packages/salesforcedx-lightning-lsp-common": { + "entry": ["src/index.ts", "src/workspaceReadFileHandler.ts"] } } } diff --git a/packages/salesforcedx-vscode-apex-log/src/logs/logAutoCollect.ts b/packages/salesforcedx-vscode-apex-log/src/logs/logAutoCollect.ts index 1b47b99687..b4e89f9e3a 100644 --- a/packages/salesforcedx-vscode-apex-log/src/logs/logAutoCollect.ts +++ b/packages/salesforcedx-vscode-apex-log/src/logs/logAutoCollect.ts @@ -110,13 +110,18 @@ export const createLogAutoCollect = Effect.fn('ApexLog.createLogAutoCollect')(fu const knownIdsRef = yield* Ref.make(new Set()); const targetOrgRef = yield* api.services.TargetOrgRef(); - const settingsChangePubSub = yield* api.services.SettingsChangePubSub; const pollIntervalRef = yield* SubscriptionRef.make(Duration.seconds(getPollIntervalSeconds())); // watch the setting to update poll freq yield* Effect.fork( - Stream.fromPubSub(settingsChangePubSub).pipe( - Stream.filter(event => event.affectsConfiguration('salesforcedx-vscode-apex-log.logPollIntervalSeconds')), + Stream.async(emit => { + const disposable = vscode.workspace.onDidChangeConfiguration(event => { + if (event.affectsConfiguration('salesforcedx-vscode-apex-log.logPollIntervalSeconds')) { + void emit.single(undefined); + } + }); + return Effect.sync(() => disposable.dispose()); + }).pipe( Stream.runForEach(() => SubscriptionRef.set(pollIntervalRef, Duration.seconds(getPollIntervalSeconds()))) ) ); diff --git a/packages/salesforcedx-vscode-apex-testing/src/watchers/testResultsFileWatcher.ts b/packages/salesforcedx-vscode-apex-testing/src/watchers/testResultsFileWatcher.ts index 97bbb05c71..1be405067c 100644 --- a/packages/salesforcedx-vscode-apex-testing/src/watchers/testResultsFileWatcher.ts +++ b/packages/salesforcedx-vscode-apex-testing/src/watchers/testResultsFileWatcher.ts @@ -5,32 +5,39 @@ * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -import { ExtensionProviderService } from '@salesforce/effect-ext-utils'; import * as Effect from 'effect/Effect'; import * as Stream from 'effect/Stream'; -import type { FileChangeEvent } from 'salesforcedx-vscode-services'; -import { Utils } from 'vscode-uri'; +import * as vscode from 'vscode'; +import { type URI, Utils } from 'vscode-uri'; import { getTestController } from '../views/testController'; -/** Check if a file event is a test result JSON file */ -const isTestResultJsonFile = (event: FileChangeEvent): boolean => - // uri.path is already normalized - event.uri.path.includes('.sfdx/tools/testresults/apex') && event.uri.path.endsWith('.json'); +const TEST_RESULTS_GLOB = '**/.sfdx/tools/testresults/apex/*.json'; + +// --- INSTRUMENTATION: remove before shipping --- +let received = 0; +setInterval(() => { + console.log(`[After Measurement] testResultsFileWatcher: received ${received}`); +}, 10_000); +// --- END INSTRUMENTATION --- -/** Set up file watcher for test result JSON files using FileWatcherService */ export const setupTestResultsFileWatcher = Effect.fn('apex-testing.watchTestResults')(function* ( testController: ReturnType ) { - const api = yield* (yield* ExtensionProviderService).getServicesApi; - const fileChangePubSub = yield* api.services.FileChangePubSub; - - yield* Stream.fromPubSub(fileChangePubSub).pipe( - Stream.filter(e => e.type !== 'delete'), - Stream.filter(isTestResultJsonFile), - Stream.runForEach(event => { - const apexDirUri = Utils.dirname(event.uri); - void testController.onResultFileCreate(apexDirUri, event.uri); - return Effect.void; - }) + yield* Effect.acquireUseRelease( + Effect.sync(() => vscode.workspace.createFileSystemWatcher(TEST_RESULTS_GLOB)), + watcher => + Stream.async(emit => { + watcher.onDidCreate(uri => { + received++; + void emit.single(uri); + }); + return Effect.sync(() => watcher.dispose()); + }).pipe( + Stream.runForEach(uri => { + void testController.onResultFileCreate(Utils.dirname(uri), uri); + return Effect.void; + }) + ), + watcher => Effect.sync(() => watcher.dispose()) ); }); diff --git a/packages/salesforcedx-vscode-lwc/src/index.ts b/packages/salesforcedx-vscode-lwc/src/index.ts index 04f185df2f..35087da620 100644 --- a/packages/salesforcedx-vscode-lwc/src/index.ts +++ b/packages/salesforcedx-vscode-lwc/src/index.ts @@ -13,6 +13,7 @@ import { import { registerWorkspaceReadFileHandler } from '@salesforce/salesforcedx-lightning-lsp-common/workspaceReadFileHandler'; import { ActivationTracker, detectWorkspaceType } from '@salesforce/salesforcedx-utils-vscode'; import type { TelemetryServiceInterface } from '@salesforce/vscode-service-provider'; +import * as Effect from 'effect/Effect'; import { ExtensionContext, workspace } from 'vscode'; import { URI, Utils } from 'vscode-uri'; import { channelService } from './channel'; @@ -20,7 +21,7 @@ import { log } from './constants'; import { createLanguageClient } from './languageClient'; import LwcLspStatusBarItem from './lwcLspStatusBarItem'; import { metaSupport } from './metasupport'; -import { startLwcFileWatcherViaServices } from './util/lwcFileWatcher'; +import { startLwcFileWatcher } from './util/lwcFileWatcher'; const getTelemetryService = async (): Promise => { const telemetryModule = await import('./telemetry/index.js'); @@ -137,7 +138,7 @@ export const activate = async (extensionContext: ExtensionContext) => { // Watch for newly created LWC files and auto-open them to trigger delayed initialization // This handles the case where files are downloaded from org browser after server starts // Opening files syncs them to the server via onDidOpen, which triggers delayed initialization - startLwcFileWatcherViaServices(); + Effect.runFork(startLwcFileWatcher()); // Activate Test support (skip in web mode - test execution requires Node.js/terminal) if (process.env.ESBUILD_PLATFORM !== 'web') { diff --git a/packages/salesforcedx-vscode-lwc/src/util/lwcFileWatcher.ts b/packages/salesforcedx-vscode-lwc/src/util/lwcFileWatcher.ts index 343643c24f..597d1236e7 100644 --- a/packages/salesforcedx-vscode-lwc/src/util/lwcFileWatcher.ts +++ b/packages/salesforcedx-vscode-lwc/src/util/lwcFileWatcher.ts @@ -5,51 +5,37 @@ * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -import { getServicesApi } from '@salesforce/effect-ext-utils'; -import { Effect } from 'effect'; -import * as Layer from 'effect/Layer'; +import * as Effect from 'effect/Effect'; import * as Stream from 'effect/Stream'; -import { workspace } from 'vscode'; +import * as vscode from 'vscode'; +import type { URI } from 'vscode-uri'; -/** True if the URI path is under lwc/ and matches *.js, *.ts, *.html, or *js-meta.xml */ -const isLwcFile = (uri: { path: string; fsPath?: string }): boolean => { - const pathSegment = uri.fsPath ?? uri.path; - if (!pathSegment.includes('/lwc/') && !pathSegment.includes('\\lwc\\')) { - return false; - } - return ( - pathSegment.endsWith('.js') || - pathSegment.endsWith('.ts') || - pathSegment.endsWith('.html') || - pathSegment.endsWith('js-meta.xml') - ); -}; +const LWC_GLOB = '**/lwc/**/*.{js,ts,html,js-meta.xml}'; -/** - * Start LWC file watcher using FileWatcherService from salesforcedx-vscode-services. - */ -export const startLwcFileWatcherViaServices = (): void => { - const apiResult = Effect.runSync(getServicesApi.pipe(Effect.either)); - if (apiResult._tag === 'Left') { - throw new Error('Failed to get services API'); - } - const api = apiResult.right; - const layer = Layer.mergeAll(api.services.ChannelServiceLayer('LWC'), api.services.FileChangePubSub.Default); - const subscriptionEffect = Effect.gen(function* () { - const fileChangePubSub = yield* api.services.FileChangePubSub; - yield* Effect.forkDaemon( - Stream.fromPubSub(fileChangePubSub).pipe( - Stream.filter(e => e.type === 'create' && isLwcFile(e.uri)), - Stream.runForEach(e => - Effect.tryPromise(() => workspace.openTextDocument(e.uri)).pipe(Effect.orElseSucceed(() => undefined)) +// --- INSTRUMENTATION: remove before shipping --- +let received = 0; +setInterval(() => { + console.log(`[After Measurement] lwcFileWatcher: received ${received}`); +}, 10_000); +// --- END INSTRUMENTATION --- + +export const startLwcFileWatcher = Effect.fn('lwc.fileWatcher')(function* () { + yield* Effect.acquireUseRelease( + Effect.sync(() => vscode.workspace.createFileSystemWatcher(LWC_GLOB)), + watcher => + Stream.async(emit => { + watcher.onDidCreate(uri => { + received++; + void emit.single(uri); + }); + return Effect.sync(() => watcher.dispose()); + }).pipe( + Stream.runForEach(uri => + Effect.tryPromise(() => vscode.workspace.openTextDocument(uri)).pipe( + Effect.orElseSucceed(() => undefined) + ) ) - ) - ); - return yield* Effect.never; - }); - try { - Effect.runSync(Effect.forkDaemon(Effect.scoped(Effect.provide(subscriptionEffect, layer)))); - } catch { - throw new Error('Failed to start LWC file watcher'); - } -}; + ), + watcher => Effect.sync(() => watcher.dispose()) + ); +}); diff --git a/packages/salesforcedx-vscode-metadata/package.json b/packages/salesforcedx-vscode-metadata/package.json index 28652ddd44..83b10d5889 100644 --- a/packages/salesforcedx-vscode-metadata/package.json +++ b/packages/salesforcedx-vscode-metadata/package.json @@ -28,12 +28,12 @@ ], "dependencies": { "@salesforce/effect-ext-utils": "*", + "@salesforce/source-deploy-retrieve": "^12.32.5", "effect": "^3.20.0", "@salesforce/vscode-i18n": "*", "vscode-uri": "^3.1.0" }, "devDependencies": { - "@salesforce/source-deploy-retrieve": "^12.32.5", "@salesforce/source-tracking": "^7.8.11", "@salesforce/playwright-vscode-ext": "*", "salesforcedx-vscode-services": "*" diff --git a/packages/salesforcedx-vscode-metadata/src/statusBar/sourceTrackingStatusBar.ts b/packages/salesforcedx-vscode-metadata/src/statusBar/sourceTrackingStatusBar.ts index 4609d0d910..0af38ca863 100644 --- a/packages/salesforcedx-vscode-metadata/src/statusBar/sourceTrackingStatusBar.ts +++ b/packages/salesforcedx-vscode-metadata/src/statusBar/sourceTrackingStatusBar.ts @@ -6,6 +6,7 @@ */ import { ExtensionProviderService } from '@salesforce/effect-ext-utils'; +import { ForceIgnore } from '@salesforce/source-deploy-retrieve'; import type { StatusOutputRow } from '@salesforce/source-tracking'; import * as Duration from 'effect/Duration'; import * as Effect from 'effect/Effect'; @@ -13,6 +14,7 @@ import * as Schedule from 'effect/Schedule'; import * as Stream from 'effect/Stream'; import * as SubscriptionRef from 'effect/SubscriptionRef'; import * as vscode from 'vscode'; +import { URI } from 'vscode-uri'; import { nls } from '../messages'; import { calculateBackground, calculateCounts, dedupeStatus, getCommand, separateChanges } from './helpers'; import { buildCombinedHoverText } from './hover'; @@ -66,6 +68,7 @@ const updateDisplay = statusBarItem.show(); }; + /** Helper to read polling interval config */ const getPollingIntervalSeconds = (): number => vscode.workspace @@ -82,7 +85,6 @@ export const createSourceTrackingStatusBar = Effect.fn('createSourceTrackingStat 45 ); statusBarItem.name = 'Salesforce: Source Tracking'; - const fileChangePubSub = yield* api.services.FileChangePubSub; const targetOrgRef = yield* api.services.TargetOrgRef(); const activeOpRef = yield* api.services.ActiveMetadataOperationRef(); @@ -93,15 +95,18 @@ export const createSourceTrackingStatusBar = Effect.fn('createSourceTrackingStat ); // Setup dynamic polling interval that responds to config changes - const settingsChangePubSub = yield* api.services.SettingsChangePubSub; const pollIntervalRef = yield* SubscriptionRef.make(Duration.seconds(getPollingIntervalSeconds())); // Watch setting changes to update poll frequency dynamically yield* Effect.fork( - Stream.fromPubSub(settingsChangePubSub).pipe( - Stream.filter(event => - event.affectsConfiguration('salesforcedx-vscode-metadata.sourceTracking.pollingIntervalSeconds') - ), + Stream.async(emit => { + const disposable = vscode.workspace.onDidChangeConfiguration(event => { + if (event.affectsConfiguration('salesforcedx-vscode-metadata.sourceTracking.pollingIntervalSeconds')) { + void emit.single(undefined); + } + }); + return Effect.sync(() => disposable.dispose()); + }).pipe( Stream.runForEach(() => SubscriptionRef.set(pollIntervalRef, Duration.seconds(getPollingIntervalSeconds()))) ) ); @@ -135,18 +140,80 @@ export const createSourceTrackingStatusBar = Effect.fn('createSourceTrackingStat ) ); - const fileChangeStream = Stream.merge( - // Subscribe to file changes TODO: maybe filter out some changes by type or uri - Stream.fromPubSub(fileChangePubSub).pipe(Stream.debounce(Duration.millis(500))), - // Poll for remote changes with configurable interval - dynamicPollStream + // File watcher scoped to package directories, filtered by .forceignore. + // Rebuilds watchers when org changes, sfdx-project.json changes, or .forceignore changes. + const fileChangeStream = Stream.concat( + Stream.fromEffect(SubscriptionRef.get(targetOrgRef)), + targetOrgRef.changes ).pipe( + Stream.flatMap(orgInfo => { + if (!orgInfo.tracksSource || !orgInfo.orgId) { + return Stream.empty; + } + + // Signal to rebuild watchers when project config or .forceignore changes + const rebuildSignal = Stream.concat( + Stream.void, + Stream.async(emit => { + const projectWatcher = vscode.workspace.createFileSystemWatcher('**/sfdx-project.json'); + const ignoreWatcher = vscode.workspace.createFileSystemWatcher('**/.forceignore'); + const fire = () => { void emit.single(undefined); }; + projectWatcher.onDidChange(fire); + projectWatcher.onDidCreate(fire); + ignoreWatcher.onDidChange(fire); + ignoreWatcher.onDidCreate(fire); + ignoreWatcher.onDidDelete(fire); + return Effect.sync(() => { projectWatcher.dispose(); ignoreWatcher.dispose(); }); + }) + ); + + const scopedWatcherStream = rebuildSignal.pipe( + Stream.flatMap(() => + Stream.fromEffect( + Effect.gen(function* () { + const project = yield* api.services.ProjectService.getSfProject(); + return project.getPackageDirectories(); + }).pipe(Effect.catchAll(() => Effect.succeed([]))) + ).pipe( + Stream.flatMap(packageDirs => { + if (packageDirs.length === 0) return Stream.empty; + + return Stream.async(emit => { + const forceIgnore = ForceIgnore.findAndCreate(packageDirs[0].fullPath); + const watchers = packageDirs.map(dir => + vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(URI.file(dir.fullPath), '**/*') + ) + ); + + // eslint-disable-next-line functional/no-let + let timer: ReturnType | undefined; + const fire = (uri: URI) => { + if (forceIgnore.denies(uri.fsPath)) return; + if (timer !== undefined) clearTimeout(timer); + timer = setTimeout(() => { timer = undefined; void emit.single(undefined); }, 500); + }; + + watchers.forEach(watcher => { + watcher.onDidCreate(fire); + watcher.onDidChange(fire); + watcher.onDidDelete(fire); + }); + + return Effect.sync(() => { + clearTimeout(timer); + watchers.forEach(watcher => watcher.dispose()); + }); + }); + }) + ), + { switch: true } + ) + ); + + return Stream.merge(scopedWatcherStream, dynamicPollStream); + }, { switch: true }), Stream.debounce(Duration.millis(500)), - // we don't care about file events if source tracking is not enabled - Stream.filterEffect(() => - SubscriptionRef.get(targetOrgRef).pipe(Effect.andThen(orgInfo => Boolean(orgInfo.tracksSource))) - ), - // suppress events while a metadata operation is running suppressDuringOperation ); @@ -159,7 +226,8 @@ export const createSourceTrackingStatusBar = Effect.fn('createSourceTrackingStat yield* Effect.fork( Stream.mergeAll({ concurrency: 'unbounded' })([orgChangeStream, fileChangeStream, operationCompleteStream]).pipe( Stream.debounce(Duration.millis(500)), - Stream.runForEach(() => refresh(statusBarItem)) + Stream.flatMap(() => Stream.fromEffect(refresh(statusBarItem)), { switch: true }), + Stream.runDrain ) ); diff --git a/packages/salesforcedx-vscode-services/src/core/aliasFileWatcher.ts b/packages/salesforcedx-vscode-services/src/core/aliasFileWatcher.ts index ad9d4cfc78..372563a2d2 100644 --- a/packages/salesforcedx-vscode-services/src/core/aliasFileWatcher.ts +++ b/packages/salesforcedx-vscode-services/src/core/aliasFileWatcher.ts @@ -11,16 +11,19 @@ import * as Effect from 'effect/Effect'; import { isString } from 'effect/Predicate'; import * as Stream from 'effect/Stream'; import * as SubscriptionRef from 'effect/SubscriptionRef'; -import { join, normalize } from 'node:path'; -import { FileChangePubSub } from '../vscode/fileChangePubSub'; +import * as vscode from 'vscode'; +import { URI } from 'vscode-uri'; import { AliasService } from './alias'; import { getDefaultOrgRef } from './defaultOrgRef'; -/** - * Merges a fresh alias list from disk with the current aliases, preserving the primary alias - * (aliases[0]) at position 0 so the VS Code status bar label stays stable. - * If the primary alias was deleted externally, fall back to disk order. - */ +// --- INSTRUMENTATION: remove before shipping --- +// eslint-disable-next-line functional/no-let +let received = 0; +setInterval(() => { + console.log(`[After Measurement] aliasFileWatcher: received ${received}`); +}, 10_000); +// --- END INSTRUMENTATION --- + const mergeAliases = (currentAliases: readonly string[] | undefined, freshAliases: string[]): string[] => { const primaryAlias = currentAliases?.[0]; if (primaryAlias && freshAliases.includes(primaryAlias)) { @@ -29,27 +32,38 @@ const mergeAliases = (currentAliases: readonly string[] | undefined, freshAliase return freshAliases; }; -/** Subscribes to FileChangePubSub, filters to alias.json, debounces, and refreshes defaultOrgRef.aliases. - * Preserves aliases[0] (the primary display alias) at position 0 for status bar stability. */ export const watchAliasFile = Effect.fn('watchAliasFile')(function* () { - const aliasFilePath = normalize(join(Global.SFDX_DIR, 'alias.json')); - const [fileChangePubSub, aliasService, ref] = yield* Effect.all( - [FileChangePubSub, AliasService, getDefaultOrgRef()], + const [aliasService, ref] = yield* Effect.all( + [AliasService, getDefaultOrgRef()], { concurrency: 'unbounded' } ); - yield* Stream.fromPubSub(fileChangePubSub).pipe( - Stream.filter(event => normalize(event.uri.fsPath) === aliasFilePath), - Stream.debounce(Duration.millis(50)), - Stream.mapEffect(() => SubscriptionRef.get(ref)), - Stream.map(r => r.username), - Stream.filter(isString), - Stream.mapEffect(aliasService.getAliasesFromUsername), - Stream.runForEach(freshAliases => - SubscriptionRef.update(ref, existing => ({ - ...existing, - aliases: mergeAliases(existing.aliases, freshAliases) - })) - ) + yield* Effect.acquireUseRelease( + Effect.sync(() => + vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(URI.file(Global.SFDX_DIR), 'alias.json') + ) + ), + watcher => + Stream.async(emit => { + const fire = () => { received++; void emit.single(undefined); }; + watcher.onDidCreate(fire); + watcher.onDidChange(fire); + watcher.onDidDelete(fire); + return Effect.sync(() => watcher.dispose()); + }).pipe( + Stream.debounce(Duration.millis(50)), + Stream.mapEffect(() => SubscriptionRef.get(ref)), + Stream.map(r => r.username), + Stream.filter(isString), + Stream.mapEffect(aliasService.getAliasesFromUsername), + Stream.runForEach(freshAliases => + SubscriptionRef.update(ref, existing => ({ + ...existing, + aliases: mergeAliases(existing.aliases, freshAliases) + })) + ) + ), + watcher => Effect.sync(() => watcher.dispose()) ); }); diff --git a/packages/salesforcedx-vscode-services/src/core/configFileWatcher.ts b/packages/salesforcedx-vscode-services/src/core/configFileWatcher.ts index 840eeb696d..aef291718f 100644 --- a/packages/salesforcedx-vscode-services/src/core/configFileWatcher.ts +++ b/packages/salesforcedx-vscode-services/src/core/configFileWatcher.ts @@ -9,37 +9,48 @@ import { Global } from '@salesforce/core/global'; import * as Duration from 'effect/Duration'; import * as Effect from 'effect/Effect'; import * as Stream from 'effect/Stream'; -import { join, normalize, sep } from 'node:path'; -import { FileChangePubSub } from '../vscode/fileChangePubSub'; +import * as vscode from 'vscode'; +import { URI } from 'vscode-uri'; import { ConfigService } from './configService'; import { ConnectionService } from './connectionService'; import { clearDefaultOrgRef } from './defaultOrgRef'; -/** Check if a file path is a config file (global or project-specific) */ -const isConfigFile = (path: string, globalConfigPath: string, projectConfigPattern: string): boolean => { - const normalizedPath = normalize(path); - return normalizedPath === globalConfigPath || normalizedPath.includes(projectConfigPattern); -}; +// --- INSTRUMENTATION: remove before shipping --- +// eslint-disable-next-line functional/no-let +let received = 0; +setInterval(() => { + console.log(`[After Measurement] configFileWatcher: received ${received}`); +}, 10_000); +// --- END INSTRUMENTATION --- -/** - * watch the global and local sf/config.json files; - * reload the connection when they change - * if the connection fails, clear the defaultOrgRef - * */ export const watchConfigFiles = Effect.fn('watchConfigFiles')(function* () { const configFileName = Config.getFileName(); - const globalConfigPath = normalize(join(Global.DIR, configFileName)); - const projectConfigPattern = `${Global.SF_STATE_FOLDER}${sep}${configFileName}`; - const fileChangePubSub = yield* FileChangePubSub; + const globalWatcher = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(URI.file(Global.DIR), configFileName) + ); + const projectWatcher = vscode.workspace.createFileSystemWatcher( + `**/${Global.SF_STATE_FOLDER}/${configFileName}` + ); + + const configChangeStream = Stream.async(emit => { + const fire = () => { received++; void emit.single(undefined); }; + globalWatcher.onDidCreate(fire); + globalWatcher.onDidChange(fire); + globalWatcher.onDidDelete(fire); + projectWatcher.onDidCreate(fire); + projectWatcher.onDidChange(fire); + projectWatcher.onDidDelete(fire); + return Effect.sync(() => { + globalWatcher.dispose(); + projectWatcher.dispose(); + }); + }); - // Subscribe to file changes and clear defaultOrgRef when config files change - yield* Stream.fromPubSub(fileChangePubSub).pipe( - Stream.filter(event => isConfigFile(event.uri.fsPath, globalConfigPath, projectConfigPattern)), + yield* configChangeStream.pipe( Stream.debounce(Duration.millis(5)), Stream.tap(() => ConfigService.invalidateConfigAggregator()), Stream.tap(() => ConnectionService.invalidateCachedConnections()), - // get connection will cause defaultOrgRef to update, clear the ref if there's any error where we won't have an org connection. Stream.runForEach(() => ConnectionService.getConnection().pipe(Effect.catchAll(() => clearDefaultOrgRef()))) ); }); diff --git a/packages/salesforcedx-vscode-services/src/index.ts b/packages/salesforcedx-vscode-services/src/index.ts index 8799f906eb..3331e39846 100644 --- a/packages/salesforcedx-vscode-services/src/index.ts +++ b/packages/salesforcedx-vscode-services/src/index.ts @@ -51,16 +51,12 @@ import { watchLwcAuraExtensionActivation } from './vscode/extensionActivator'; import { setExtensionContext } from './vscode/extensionContext'; import { ExtensionContextService, ExtensionContextServiceLayer } from './vscode/extensionContextService'; import { closeExtensionScope, getExtensionScope } from './vscode/extensionScope'; -import { FileChangePubSub } from './vscode/fileChangePubSub'; -import { FileWatcherLayer } from './vscode/fileWatcherService'; import { FsService } from './vscode/fsService'; import { MediaService } from './vscode/mediaService'; import { PromptService, UserCancellationError } from './vscode/prompts/promptService'; import { registerCommandWithLayer, registerCommandWithRuntime } from './vscode/registerCommand'; import { runWebAuthEffect } from './vscode/runWebAuth'; -import { SettingsChangePubSub } from './vscode/settingsChangePubSub'; import { SettingsService } from './vscode/settingsService'; -import { SettingsWatcherLayer } from './vscode/settingsWatcherService'; import { WorkspaceService } from './vscode/workspaceService'; export type SalesforceVSCodeServicesApi = { @@ -75,7 +71,6 @@ export type SalesforceVSCodeServicesApi = { | ConnectionService | EditorService | ErrorHandlerService - | FileChangePubSub | FsService | MediaService | MetadataChangeNotificationService @@ -87,7 +82,6 @@ export type SalesforceVSCodeServicesApi = { | MetadataRetrieveService | ProjectService | Resource.Resource - | SettingsChangePubSub | SettingsService | SourceTrackingService | TemplateService @@ -111,7 +105,6 @@ export type SalesforceVSCodeServicesApi = { ErrorHandlerService: typeof ErrorHandlerService; ExtensionContextService: typeof ExtensionContextService; ExtensionContextServiceLayer: typeof ExtensionContextServiceLayer; - FileChangePubSub: typeof FileChangePubSub; FsService: typeof FsService; getErrorMessage: typeof getErrorMessage; MediaService: typeof MediaService; @@ -124,7 +117,6 @@ export type SalesforceVSCodeServicesApi = { MetadataRetrieveService: typeof MetadataRetrieveService; ProjectService: typeof ProjectService; SdkLayerFor: typeof SdkLayerFor; - SettingsChangePubSub: typeof SettingsChangePubSub; SettingsService: typeof SettingsService; SourceTrackingService: typeof SourceTrackingService; ActiveMetadataOperationRef: typeof getActiveMetadataOperationRef; @@ -296,9 +288,7 @@ export const activate = async (context: vscode.ExtensionContext): Promise authSettings.some(s => event.affectsConfiguration(s))), + Stream.async(emit => { + const disposable = vscode.workspace.onDidChangeConfiguration(event => { + if (authSettings.some(s => event.affectsConfiguration(s))) { + void emit.single(event); + } + }); + return Effect.sync(() => disposable.dispose()); + }).pipe( Stream.debounce(Duration.millis(100)), Stream.tap(() => channelService.appendToChannel('ConfigChanged: Web Auth')), - Stream.runForEach(() => ConnectionService.getConnection().pipe(Effect.catchAll(() => Effect.void))) // it's possible for the connection to fail and that's ok. Some other event will try to get a connection and display a real error + Stream.runForEach(() => ConnectionService.getConnection().pipe(Effect.catchAll(() => Effect.void))) ) ); - // watch retrieveOnLoad setting - yield* Stream.fromPubSub(settingsChangePubSub).pipe( - Stream.filter(event => event.affectsConfiguration(`${CODE_BUILDER_WEB_SECTION}.${RETRIEVE_ON_LOAD_KEY}`)), + yield* Stream.async(emit => { + const disposable = vscode.workspace.onDidChangeConfiguration(event => { + if (event.affectsConfiguration(`${CODE_BUILDER_WEB_SECTION}.${RETRIEVE_ON_LOAD_KEY}`)) { + void emit.single(event); + } + }); + return Effect.sync(() => disposable.dispose()); + }).pipe( Stream.debounce(Duration.millis(100)), Stream.tap(() => channelService.appendToChannel(`ConfigChanged: ${RETRIEVE_ON_LOAD_KEY}`)), Stream.runForEach(() => retrieveOnLoadEffect()) diff --git a/packages/salesforcedx-vscode-services/src/vscode/fileChangePubSub.ts b/packages/salesforcedx-vscode-services/src/vscode/fileChangePubSub.ts deleted file mode 100644 index c1613e6aa4..0000000000 --- a/packages/salesforcedx-vscode-services/src/vscode/fileChangePubSub.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright (c) 2026, salesforce.com, inc. - * All rights reserved. - * Licensed under the BSD 3-Clause license. - * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ - -import * as Effect from 'effect/Effect'; -import * as PubSub from 'effect/PubSub'; -import type { URI } from 'vscode-uri'; - -export type FileChangeEvent = { - readonly type: 'create' | 'change' | 'delete'; - readonly uri: URI; -}; - -/** PubSub that broadcasts all workspace file-system change events. - * The VS Code wiring (FileWatcherLayer) writes to this; consumers subscribe read-only. */ -export class FileChangePubSub extends Effect.Service()('FileChangePubSub', { - scoped: PubSub.sliding(10_000) -}) {} diff --git a/packages/salesforcedx-vscode-services/src/vscode/fileWatcherService.ts b/packages/salesforcedx-vscode-services/src/vscode/fileWatcherService.ts deleted file mode 100644 index b99bcad142..0000000000 --- a/packages/salesforcedx-vscode-services/src/vscode/fileWatcherService.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright (c) 2026, salesforce.com, inc. - * All rights reserved. - * Licensed under the BSD 3-Clause license. - * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ - -import * as Effect from 'effect/Effect'; -import * as Layer from 'effect/Layer'; -import * as PubSub from 'effect/PubSub'; -import * as Stream from 'effect/Stream'; -import * as vscode from 'vscode'; -import { ChannelService } from './channelService'; -import { FileChangePubSub, type FileChangeEvent } from './fileChangePubSub'; - -export const FileWatcherLayer = Layer.scopedDiscard( - Effect.gen(function* () { - const pubsub = yield* FileChangePubSub; - const channel = yield* ChannelService; - - yield* Effect.acquireUseRelease( - Effect.sync(() => vscode.workspace.createFileSystemWatcher('**/*')), - watcher => - Stream.async(emit => { - watcher.onDidCreate(uri => emit.single({ type: 'create', uri })); - watcher.onDidChange(uri => emit.single({ type: 'change', uri })); - watcher.onDidDelete(uri => emit.single({ type: 'delete', uri })); - }).pipe( - Stream.runForEach(event => PubSub.publish(pubsub, event).pipe(Effect.catchAll(() => Effect.void))) - ), - watcher => Effect.sync(() => watcher.dispose()).pipe(Effect.withSpan('disposing file watcher')) - ).pipe(Effect.forkScoped); - - yield* channel.appendToChannel('FileWatcherService started successfully'); - }) -); diff --git a/packages/salesforcedx-vscode-services/src/vscode/settingsChangePubSub.ts b/packages/salesforcedx-vscode-services/src/vscode/settingsChangePubSub.ts deleted file mode 100644 index 400ea8697d..0000000000 --- a/packages/salesforcedx-vscode-services/src/vscode/settingsChangePubSub.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright (c) 2026, salesforce.com, inc. - * All rights reserved. - * Licensed under the BSD 3-Clause license. - * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ - -import * as Effect from 'effect/Effect'; -import * as PubSub from 'effect/PubSub'; -import type * as vscode from 'vscode'; - -/** PubSub that broadcasts VS Code configuration-change events. - * The VS Code wiring (SettingsWatcherLayer) writes to this; consumers subscribe read-only. */ -export class SettingsChangePubSub extends Effect.Service()('SettingsChangePubSub', { - scoped: PubSub.sliding(10_000) -}) {} diff --git a/packages/salesforcedx-vscode-services/src/vscode/settingsWatcherService.ts b/packages/salesforcedx-vscode-services/src/vscode/settingsWatcherService.ts deleted file mode 100644 index ae64d271af..0000000000 --- a/packages/salesforcedx-vscode-services/src/vscode/settingsWatcherService.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright (c) 2026, salesforce.com, inc. - * All rights reserved. - * Licensed under the BSD 3-Clause license. - * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ - -import * as Effect from 'effect/Effect'; -import * as Layer from 'effect/Layer'; -import * as PubSub from 'effect/PubSub'; -import * as Stream from 'effect/Stream'; -import * as vscode from 'vscode'; -import { SettingsChangePubSub } from './settingsChangePubSub'; - -export const SettingsWatcherLayer = Layer.scopedDiscard( - Effect.gen(function* () { - const pubsub = yield* SettingsChangePubSub; - - yield* Stream.async(emit => { - const disposable = vscode.workspace.onDidChangeConfiguration(event => emit.single(event)); - return Effect.sync(() => disposable.dispose()).pipe(Effect.withSpan('disposing configuration change watcher')); - }).pipe( - Stream.runForEach(event => PubSub.publish(pubsub, event)), - Effect.forkScoped - ); - }) -); diff --git a/packages/salesforcedx-vscode-services/test/jest/core/aliasFileWatcher.test.ts b/packages/salesforcedx-vscode-services/test/jest/core/aliasFileWatcher.test.ts index 8e21cefcfe..52b6a0395d 100644 --- a/packages/salesforcedx-vscode-services/test/jest/core/aliasFileWatcher.test.ts +++ b/packages/salesforcedx-vscode-services/test/jest/core/aliasFileWatcher.test.ts @@ -8,27 +8,34 @@ import * as Effect from 'effect/Effect'; import * as Fiber from 'effect/Fiber'; import * as Layer from 'effect/Layer'; -import * as PubSub from 'effect/PubSub'; import * as SubscriptionRef from 'effect/SubscriptionRef'; -import { URI } from 'vscode-uri'; +import * as vscode from 'vscode'; import { watchAliasFile } from '../../../src/core/aliasFileWatcher'; import { AliasService } from '../../../src/core/alias'; import { getDefaultOrgRef } from '../../../src/core/defaultOrgRef'; -import { FileChangePubSub, type FileChangeEvent } from '../../../src/vscode/fileChangePubSub'; jest.mock('@salesforce/core/global', () => ({ Global: { SFDX_DIR: '/Users/testuser/.sfdx' } })); -const ALIAS_FILE_PATH = '/Users/testuser/.sfdx/alias.json'; -const OTHER_FILE_PATH = '/Users/testuser/.sfdx/config.json'; +type WatcherCallback = (...args: unknown[]) => void; -// --------------------------------------------------------------------------- -// Layer factories -// --------------------------------------------------------------------------- +const watcherCallbacks: { create?: WatcherCallback; change?: WatcherCallback; delete?: WatcherCallback } = {}; +const disposeMock = jest.fn(); -const makeFileChangePubSubLayer = (pubsub: PubSub.PubSub) => - Layer.succeed(FileChangePubSub, pubsub as unknown as FileChangePubSub); +beforeEach(() => { + watcherCallbacks.create = undefined; + watcherCallbacks.change = undefined; + watcherCallbacks.delete = undefined; + disposeMock.mockClear(); + + (vscode.workspace.createFileSystemWatcher as jest.Mock).mockReturnValue({ + onDidCreate: jest.fn((cb: WatcherCallback) => { watcherCallbacks.create = cb; }), + onDidChange: jest.fn((cb: WatcherCallback) => { watcherCallbacks.change = cb; }), + onDidDelete: jest.fn((cb: WatcherCallback) => { watcherCallbacks.delete = cb; }), + dispose: disposeMock + }); +}); const makeAliasServiceLayer = (getAliasesFromUsername: jest.Mock) => Layer.succeed( @@ -41,10 +48,6 @@ const makeAliasServiceLayer = (getAliasesFromUsername: jest.Mock) => }) ); -// --------------------------------------------------------------------------- -// watchAliasFile – integration tests -// --------------------------------------------------------------------------- - describe('watchAliasFile', () => { beforeEach(async () => { await Effect.runPromise( @@ -54,14 +57,9 @@ describe('watchAliasFile', () => { const runWatcherTest = async ( getAliasesFromUsernameMock: jest.Mock, - initialOrgInfo: { username?: string; aliases?: string[] }, - publishUri: string + initialOrgInfo: { username?: string; aliases?: string[] } ) => { - const fileChangePubSub = await Effect.runPromise(PubSub.sliding(10)); - const layer = Layer.mergeAll( - makeFileChangePubSubLayer(fileChangePubSub), - makeAliasServiceLayer(getAliasesFromUsernameMock) - ); + const layer = makeAliasServiceLayer(getAliasesFromUsernameMock); return Effect.runPromise( Effect.gen(function* () { @@ -69,17 +67,13 @@ describe('watchAliasFile', () => { yield* SubscriptionRef.set(ref, initialOrgInfo as { username?: string; aliases?: string[] }); const fiber = yield* Effect.provide(Effect.scoped(watchAliasFile()), layer).pipe(Effect.fork); - - // Yield to allow the forked fiber to subscribe before we publish yield* Effect.sleep(0); - yield* PubSub.publish(fileChangePubSub, { type: 'change' as const, uri: URI.file(publishUri) }); + watcherCallbacks.change!(); yield* Effect.sleep(200); const result = yield* SubscriptionRef.get(ref); - yield* Fiber.interrupt(fiber); - return result; }) ); @@ -87,44 +81,43 @@ describe('watchAliasFile', () => { it('updates aliases when alias.json changes', async () => { const mock = jest.fn().mockReturnValue(Effect.succeed(['myAlias', 'otherAlias'])); - const result = await runWatcherTest(mock, { username: 'user@example.com', aliases: ['myAlias'] }, ALIAS_FILE_PATH); + const result = await runWatcherTest(mock, { username: 'user@example.com', aliases: ['myAlias'] }); expect(result.aliases).toEqual(['myAlias', 'otherAlias']); }); it('preserves the primary alias at position 0 when disk order differs', async () => { const mock = jest.fn().mockReturnValue(Effect.succeed(['newAlias', 'originalAlias'])); - const result = await runWatcherTest( - mock, - { username: 'user@example.com', aliases: ['originalAlias'] }, - ALIAS_FILE_PATH - ); + const result = await runWatcherTest(mock, { username: 'user@example.com', aliases: ['originalAlias'] }); expect(result.aliases).toEqual(['originalAlias', 'newAlias']); }); it('falls back to disk order when primary alias was deleted externally', async () => { const mock = jest.fn().mockReturnValue(Effect.succeed(['remainingAlias'])); - const result = await runWatcherTest( - mock, - { username: 'user@example.com', aliases: ['deletedAlias'] }, - ALIAS_FILE_PATH - ); + const result = await runWatcherTest(mock, { username: 'user@example.com', aliases: ['deletedAlias'] }); expect(result.aliases).toEqual(['remainingAlias']); }); it('is a no-op when there is no active username in defaultOrgRef', async () => { const mock = jest.fn(); - await runWatcherTest(mock, {}, ALIAS_FILE_PATH); + await runWatcherTest(mock, {}); expect(mock).not.toHaveBeenCalled(); }); - it('does not update aliases when a non-alias file changes', async () => { - const mock = jest.fn().mockReturnValue(Effect.succeed(['myAlias'])); - const result = await runWatcherTest( - mock, - { username: 'user@example.com', aliases: ['myAlias'] }, - OTHER_FILE_PATH + it('disposes the watcher when the fiber is interrupted', async () => { + const mock = jest.fn().mockReturnValue(Effect.succeed(['alias'])); + const layer = makeAliasServiceLayer(mock); + + await Effect.runPromise( + Effect.gen(function* () { + const ref = yield* getDefaultOrgRef(); + yield* SubscriptionRef.set(ref, { username: 'user@example.com' }); + + const fiber = yield* Effect.provide(Effect.scoped(watchAliasFile()), layer).pipe(Effect.fork); + yield* Effect.sleep(0); + yield* Fiber.interrupt(fiber); + }) ); - expect(mock).not.toHaveBeenCalled(); - expect(result.aliases).toEqual(['myAlias']); + + expect(disposeMock).toHaveBeenCalled(); }); }); diff --git a/packages/salesforcedx-vscode-services/test/jest/index.test.ts b/packages/salesforcedx-vscode-services/test/jest/index.test.ts index 0e9e8ee1fa..99db92b875 100644 --- a/packages/salesforcedx-vscode-services/test/jest/index.test.ts +++ b/packages/salesforcedx-vscode-services/test/jest/index.test.ts @@ -130,21 +130,6 @@ jest.mock('../../src/virtualFsProvider/memfsWatcher', () => ({ } })); -// Mock FileWatcherLayer to avoid vscode.workspace.createFileSystemWatcher -jest.mock('../../src/vscode/fileWatcherService', () => { - const E = require('effect'); - return { - FileWatcherLayer: E.Layer.empty - }; -}); - -// Mock SettingsWatcherLayer to avoid vscode.workspace.onDidChangeConfiguration -jest.mock('../../src/vscode/settingsWatcherService', () => { - const E = require('effect'); - return { - SettingsWatcherLayer: E.Layer.empty - }; -}); // Mock node:os module jest.mock('node:os', () => ({