Skip to content

Commit f9422a9

Browse files
fix: don't fail extension activation when a workspace folder has no dbt project
Production telemetry surfaced cluster `extensionActivationError` (83 unique machines / 104 events / 24h, both 0.60.7 and 0.61.0) with the stack pinned at: Error: no projects found. retrying at DBTWorkspaceFolder.retryWithBackoff at async DBTWorkspaceFolder.discoverProjects at async DBTProjectContainer.registerWorkspaceFolder at async Promise.all (index 0) at async DBTProjectContainer.initializeDBTProjects at async DBTPowerUserExtension.activate Distribution: ~95% darwin + linux, ~95% `core` integration mode, every top-version contributing — consistent with the multi-root workspace pattern common on dev machines, and inconsistent with a regression. **Root cause.** VS Code's `workspaceContains:**/dbt_project.yml` activation matches when ANY workspace folder contains a `dbt_project.yml`. The extension then runs `discoverProjects` on EVERY workspace folder via `Promise.all(folders.map(registerWorkspaceFolder))`. For a folder that genuinely has no dbt project — typical for multi-root workspaces where a sibling folder triggered activation — `findFiles` returns `[]` on every attempt, `retryWithBackoff` exhausts after ~10s (5 attempts with 1+2+3+4 backoff), and the thrown `"no projects found. retrying"` propagates up to the activate-level catch. The visible damage isn't just the telemetry noise: the throw also short-circuits the rest of `activate()`, so `statusBars.initialize()` and the workspace-folder change listener never run. Users with a mixed-folder workspace get a working dbt project for the folder that matched, plus silently-degraded status-bar UX for the rest of the session. **Fix.** In `retryWithBackoff`, treat a consistently-empty result after the retry budget as legitimately empty and return `[]` instead of throwing. The retry schedule and the empty/non-empty differentiation are unchanged — only the terminal disposition. Real errors from `fn()` (permission failures, malformed pattern, indexer crashes) still throw after retries, preserving the existing escalation for non-empty failure modes. The constructor's `createConfigWatcher` keeps watching for `dbt_project.yml` creation in this folder, so a project added later is still picked up. This also restructures the loop so empty results are handled by normal control flow rather than via a thrown-and-caught sentinel error, which is cleaner and removes the previously-unreachable `"no projects found after maximum retries"` throw at the bottom of the function. **Verification.** Adds `src/test/suite/discoverProjectsRepro.test.ts` with four cases that instantiate the real `DBTWorkspaceFolder` and stub only `workspace.findFiles`: 1. Consistently empty `findFiles` → `discoverProjects` resolves cleanly (was: rejects after ~10s). 2. Retry budget unchanged at ~10s — backoff schedule identical to pre-fix. 3. Multi-root `Promise.all([happy, empty])` resolves successfully — reproduces the production "Promise.all (index 0)" stack frame and proves the fix unblocks the rest of `activate()`. 4. `findFiles` rejecting (e.g. EACCES) still throws after 5 retries — guards against accidentally swallowing genuine errors. Pre-fix output captured at `screenshots/no-projects-found-investigation/repro-output.txt`; post-fix at `screenshots/no-projects-found-investigation/after-fix-repro.txt`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 68ef665 commit f9422a9

2 files changed

Lines changed: 182 additions & 5 deletions

File tree

src/dbt_client/dbtWorkspaceFolder.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -94,29 +94,50 @@ export class DBTWorkspaceFolder implements Disposable {
9494
while (attempt < retries) {
9595
try {
9696
const result = await fn();
97-
if (Array.isArray(result) && result.length === 0) {
97+
const isEmptyArray = Array.isArray(result) && result.length === 0;
98+
if (!isEmptyArray) {
99+
return result;
100+
}
101+
// Empty array — try again, in case `findFiles` returned early
102+
// during VS Code's initial workspace indexing.
103+
attempt++;
104+
if (attempt < retries) {
98105
this.dbtTerminal.debug(
99106
"discoverProjects",
100107
"no projects found. retrying...",
101108
false,
102109
);
103-
throw new Error("no projects found. retrying");
110+
await new Promise((resolve) =>
111+
setTimeout(resolve, backoff * attempt),
112+
);
104113
}
105-
return result;
106114
} catch (error) {
115+
// Real error from fn() (e.g. permission failure). Preserve the
116+
// existing retry-then-throw behaviour for these.
107117
attempt++;
108118
if (attempt >= retries) {
109119
throw error;
110120
}
111121
await new Promise((resolve) => setTimeout(resolve, backoff * attempt));
112122
}
113123
}
124+
// Bounded-loop fall-through: every attempt returned an empty array.
125+
// Treat as legitimately empty rather than throwing. This is the
126+
// multi-root case: VS Code's `workspaceContains:**/dbt_project.yml`
127+
// activates the extension when ANY workspace folder matches, but
128+
// `discoverProjects` runs on EVERY folder. A sibling folder that
129+
// genuinely has no dbt project should not surface as
130+
// `extensionActivationError` (telemetry cluster of 83 machines / 104
131+
// events on top-2 versions in 24h was driven by exactly this). The
132+
// constructor's `createConfigWatcher` keeps watching for
133+
// `dbt_project.yml` creation in this folder, so a project added later
134+
// will still be picked up.
114135
this.dbtTerminal.debug(
115136
"discoverProjects",
116-
"no projects found after maximum retries",
137+
"no projects found after maximum retries — treating folder as empty",
117138
false,
118139
);
119-
throw new Error("no projects found after maximum retries");
140+
return [] as T;
120141
}
121142

122143
async discoverProjects() {
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/**
2+
* Regression tests for production telemetry cluster
3+
* `extensionActivationError` (83 machines / 104 events / 24h, top 2 versions),
4+
* stack pinned at:
5+
* Error: no projects found. retrying
6+
* at DBTWorkspaceFolder.retryWithBackoff
7+
* at async DBTWorkspaceFolder.discoverProjects
8+
* at async DBTProjectContainer.registerWorkspaceFolder
9+
* at async Promise.all (index 0)
10+
* at async DBTProjectContainer.initializeDBTProjects
11+
* at async DBTPowerUserExtension.activate
12+
*
13+
* Bug: VS Code's `workspaceContains:**\/dbt_project.yml` activation matches
14+
* when ANY workspace folder contains a `dbt_project.yml`. The extension then
15+
* runs `discoverProjects` on EVERY workspace folder via
16+
* `Promise.all(folders.map(registerWorkspaceFolder))`. Pre-fix, a folder with
17+
* no `dbt_project.yml` exhausted `retryWithBackoff` (5 attempts, ~10s) and
18+
* threw `"no projects found. retrying"`, which propagated up and fired
19+
* `extensionActivationError` plus *short-circuited* the rest of `activate()`
20+
* (so `statusBars.initialize` and the workspace-folder change listener never
21+
* ran).
22+
*
23+
* Fix: `retryWithBackoff` treats a consistently-empty result after the retry
24+
* budget as legitimately empty and returns `[]` instead of throwing. Real
25+
* errors (e.g. `findFiles` itself rejecting) still throw after retries —
26+
* preserving the existing escalation for non-empty failure modes.
27+
*/
28+
import { describe, expect, it, jest } from "@jest/globals";
29+
import { EventEmitter, Uri, workspace } from "vscode";
30+
import { DBTWorkspaceFolder } from "../../dbt_client/dbtWorkspaceFolder";
31+
32+
// Lightweight stand-ins for the DI graph. None of the project-registration
33+
// path is exercised — we're testing the discoverProjects → retryWithBackoff
34+
// boundary against the real source.
35+
const fakeTelemetry = {
36+
sendTelemetryEvent: jest.fn(),
37+
sendTelemetryError: jest.fn(),
38+
} as any;
39+
40+
const fakeTerminal = {
41+
debug: jest.fn(),
42+
info: jest.fn(),
43+
warn: jest.fn(),
44+
error: jest.fn(),
45+
log: jest.fn(),
46+
trace: jest.fn(),
47+
} as any;
48+
49+
const fakeProjectFactory = jest.fn() as any;
50+
const fakeDetectionFactory = jest.fn(() => ({
51+
discoverProjects: jest.fn(async (paths: string[]) => paths),
52+
})) as any;
53+
54+
function makeFolder(): DBTWorkspaceFolder {
55+
const ws = {
56+
uri: Uri.file("/tmp/empty-folder-without-dbt-project"),
57+
name: "folder-b",
58+
index: 0,
59+
} as any;
60+
return new DBTWorkspaceFolder(
61+
fakeProjectFactory,
62+
fakeDetectionFactory,
63+
fakeTelemetry,
64+
fakeTerminal,
65+
ws,
66+
new EventEmitter() as any,
67+
new EventEmitter() as any,
68+
);
69+
}
70+
71+
function stubWorkspaceFindFiles(impl: () => Promise<Uri[]>) {
72+
(workspace as any).findFiles = jest.fn(impl);
73+
(workspace as any).getConfiguration = jest.fn(() => ({
74+
get: jest.fn(() => []),
75+
}));
76+
return (workspace as any).findFiles as jest.Mock;
77+
}
78+
79+
describe("discoverProjects: multi-root activation no-projects-found regression", () => {
80+
it("returns no projects (does not throw) when findFiles is consistently empty", async () => {
81+
const findFilesMock = stubWorkspaceFindFiles(() =>
82+
Promise.resolve([] as Uri[]),
83+
);
84+
85+
const folder = makeFolder();
86+
// Pre-fix this rejected with "no projects found. retrying" — and that
87+
// rejection bubbled up to extensionActivationError + short-circuited
88+
// the rest of activate(). Post-fix it resolves cleanly so activation
89+
// continues to set up status bars and workspace listeners.
90+
await expect(folder.discoverProjects()).resolves.toBeUndefined();
91+
92+
// Five attempts (1 initial + 4 retries with 1s/2s/3s/4s backoff). The
93+
// budget is the same as before — only the terminal disposition changed
94+
// from throw to graceful return.
95+
expect(findFilesMock).toHaveBeenCalledTimes(5);
96+
}, 20000);
97+
98+
it("retry budget is still ~10s — confirms backoff schedule unchanged", async () => {
99+
stubWorkspaceFindFiles(() => Promise.resolve([] as Uri[]));
100+
const folder = makeFolder();
101+
102+
const start = Date.now();
103+
await folder.discoverProjects();
104+
const elapsed = Date.now() - start;
105+
106+
// 1+2+3+4 = 10s of waits. Same window as the pre-fix timing assertion;
107+
// the fix only changes the terminal action, not the schedule.
108+
expect(elapsed).toBeGreaterThanOrEqual(9500);
109+
expect(elapsed).toBeLessThan(12000);
110+
}, 20000);
111+
112+
it("multi-root: empty folder no longer fails Promise.all alongside happy folder", async () => {
113+
// Reproduces the production stack frame `at async Promise.all (index 0)`:
114+
// initializeDBTProjects does `Promise.all(folders.map(registerWorkspaceFolder))`
115+
// — pre-fix, one folder rejecting caused the whole activation to throw.
116+
// Post-fix, both branches resolve and activate() runs to completion.
117+
stubWorkspaceFindFiles(() => Promise.resolve([] as Uri[]));
118+
119+
const emptyFolder = makeFolder();
120+
const happyFolder = {
121+
discoverProjects: () => Promise.resolve(),
122+
};
123+
124+
const start = Date.now();
125+
await expect(
126+
Promise.all([
127+
happyFolder.discoverProjects(),
128+
emptyFolder.discoverProjects(),
129+
]),
130+
).resolves.toBeDefined();
131+
const elapsed = Date.now() - start;
132+
133+
// Slowest branch (empty folder) still drives the duration.
134+
expect(elapsed).toBeGreaterThanOrEqual(9500);
135+
}, 20000);
136+
137+
it("real findFiles errors still propagate after retries (preserves existing escalation)", async () => {
138+
// Critical that the fix doesn't accidentally swallow genuine errors —
139+
// e.g. permission failures, malformed RelativePattern, indexer crashes.
140+
// Only the empty-array case is treated as legitimately empty; thrown
141+
// errors retain the retry-then-throw behaviour.
142+
let calls = 0;
143+
const findFilesMock = stubWorkspaceFindFiles(() => {
144+
calls += 1;
145+
return Promise.reject(new Error("EACCES: permission denied"));
146+
});
147+
148+
const folder = makeFolder();
149+
150+
await expect(folder.discoverProjects()).rejects.toThrow(
151+
"EACCES: permission denied",
152+
);
153+
expect(findFilesMock).toHaveBeenCalledTimes(5);
154+
expect(calls).toBe(5);
155+
}, 20000);
156+
});

0 commit comments

Comments
 (0)