Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions docs/pubsub-waste-findings.md
Original file line number Diff line number Diff line change
@@ -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<typeof setTimeout> | 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.
3 changes: 3 additions & 0 deletions knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -110,13 +110,18 @@ export const createLogAutoCollect = Effect.fn('ApexLog.createLogAutoCollect')(fu
const knownIdsRef = yield* Ref.make(new Set<string>());
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<void>(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())))
)
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof getTestController>
) {
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<URI>(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())
);
});
5 changes: 3 additions & 2 deletions packages/salesforcedx-vscode-lwc/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,15 @@ 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';
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<TelemetryServiceInterface> => {
const telemetryModule = await import('./telemetry/index.js');
Expand Down Expand Up @@ -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') {
Expand Down
74 changes: 30 additions & 44 deletions packages/salesforcedx-vscode-lwc/src/util/lwcFileWatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<URI>(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())
);
});
2 changes: 1 addition & 1 deletion packages/salesforcedx-vscode-metadata/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "*"
Expand Down
Loading
Loading